feat(progress): circular progress added

This commit is contained in:
Junior Garcia 2023-03-30 22:18:29 -03:00
parent 9f88594482
commit 08852c522f
27 changed files with 513 additions and 26 deletions

View File

@ -0,0 +1,41 @@
import {forwardRef} from "@nextui-org/system";
import {UseCircularProgressProps, useCircularProgress} from "./use-circular-progress";
export interface CircularProgressProps extends Omit<UseCircularProgressProps, "ref"> {}
const CircularProgress = forwardRef<CircularProgressProps, "div">((props, ref) => {
const {
Component,
slots,
styles,
label,
showValueLabel,
getProgressBarProps,
getLabelProps,
getSvgProps,
getCircleProps,
} = useCircularProgress({ref, ...props});
const progressBarProps = getProgressBarProps();
return (
<Component {...progressBarProps}>
<div className={slots.svgWrapper({class: styles?.svgWrapper})}>
<svg {...getSvgProps()}>
<circle {...getCircleProps()} />
</svg>
{showValueLabel && (
<span className={slots.value({class: styles?.value})}>
{progressBarProps["aria-valuetext"]}
</span>
)}
</div>
{label && <span {...getLabelProps()}>{label}</span>}
</Component>
);
});
CircularProgress.displayName = "NextUI.CircularProgress";
export default CircularProgress;

View File

@ -1,10 +1,12 @@
import Progress from "./progress";
import CircularProgress from "./circular-progress";
// export types
export type {ProgressProps} from "./progress";
export type {CircularProgressProps} from "./circular-progress";
// export hooks
export {useProgress} from "./use-progress";
// export component
export {Progress};
export {Progress, CircularProgress};

View File

@ -0,0 +1,180 @@
import type {
CircularProgressVariantProps,
SlotsToClasses,
CircularProgressSlots,
} from "@nextui-org/theme";
import type {PropGetter} from "@nextui-org/system";
import type {AriaProgressBarProps} from "@react-types/progress";
import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system";
import {circularProgress} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, dataAttr, ReactRef} from "@nextui-org/shared-utils";
import {mergeProps} from "@react-aria/utils";
import {useMemo, useCallback} from "react";
import {useIsMounted} from "@nextui-org/use-is-mounted";
import {useProgressBar as useAriaProgress} from "./use-aria-progress";
export interface Props extends HTMLNextUIProps<"div"> {
/**
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
/**
* Classname or List of classes to change the styles of the element.
* if `className` is passed, it will be added to the base slot.
*
* @example
* ```ts
* <CircularProgress styles={{
* base:"base-classes",
* labelWrapper: "labelWrapper-classes",
* label: "label-classes",
* value: "value-classes",
* svg: "svg-classes",
* circle: "circle-classes",
* }} />
* ```
*/
styles?: SlotsToClasses<CircularProgressSlots>;
}
export type UseCircularProgressProps = Props & AriaProgressBarProps & CircularProgressVariantProps;
export function useCircularProgress(originalProps: UseCircularProgressProps) {
const [props, variantProps] = mapPropsVariants(originalProps, circularProgress.variantKeys);
const {
ref,
as,
id,
className,
styles,
label,
valueLabel,
value = 0,
minValue = 0,
maxValue = 100,
showValueLabel = false,
formatOptions = {
style: "percent",
},
...otherProps
} = props;
const Component = as || "div";
const domRef = useDOMRef(ref);
const baseStyles = clsx(styles?.base, className);
const [, isMounted] = useIsMounted({
rerender: true,
delay: 100,
});
const isIndeterminate = originalProps.isIndeterminate;
const {progressBarProps, labelProps} = useAriaProgress({
id,
label,
value,
minValue,
maxValue,
valueLabel,
formatOptions,
isIndeterminate,
"aria-labelledby": originalProps["aria-labelledby"],
"aria-label": originalProps["aria-label"],
});
const slots = useMemo(
() =>
circularProgress({
...variantProps,
}),
[...Object.values(variantProps)],
);
const selfMounted = originalProps.disableAnimation ? true : isMounted;
const center = 16;
const strokeWidth = originalProps.size === "xs" ? 2 : 3;
const radius = 16 - strokeWidth;
const circumference = 2 * radius * Math.PI;
const percentage = !selfMounted
? 0
: isIndeterminate
? 0.25
: (value - minValue) / (maxValue - minValue);
const offset = circumference - percentage * circumference;
const getProgressBarProps = useCallback<PropGetter>(
(props = {}) => ({
ref: domRef,
"data-indeterminate": dataAttr(isIndeterminate),
"data-disabled": dataAttr(originalProps.isDisabled),
className: slots.base({class: baseStyles}),
...mergeProps(progressBarProps, otherProps, props),
}),
[
domRef,
slots,
isIndeterminate,
originalProps.isDisabled,
baseStyles,
progressBarProps,
otherProps,
],
);
const getLabelProps = useCallback<PropGetter>(
(props = {}) => ({
className: slots.label({class: styles?.label}),
...mergeProps(labelProps, props),
}),
[slots, styles, labelProps],
);
const getSvgProps = useCallback<PropGetter>(
(props = {}) => ({
viewBox: "0 0 32 32",
fill: "none",
strokeWidth,
className: slots.svg({class: styles?.svg}),
...props,
}),
[strokeWidth, slots, styles],
);
const getCircleProps = useCallback<PropGetter>(
(props = {}) => ({
cx: center,
cy: center,
r: radius,
role: "presentation",
strokeDasharray: `${circumference} ${circumference}`,
strokeDashoffset: offset,
transform: "rotate(-90 16 16)",
className: slots.circle({class: styles?.circle}),
...props,
}),
[slots, styles, offset, circumference, radius],
);
return {
Component,
domRef,
slots,
styles,
label,
showValueLabel,
getProgressBarProps,
getLabelProps,
getSvgProps,
getCircleProps,
};
}
export type UseCircularProgressReturn = ReturnType<typeof useCircularProgress>;

View File

@ -0,0 +1,88 @@
import React from "react";
import {ComponentStory, ComponentMeta} from "@storybook/react";
import {progress} from "@nextui-org/theme";
import {CircularProgress, CircularProgressProps} from "../src";
export default {
title: "Components/CircularProgress",
component: CircularProgress,
argTypes: {
color: {
control: {
type: "select",
options: ["neutral", "primary", "secondary", "success", "warning", "danger"],
},
},
size: {
control: {
type: "select",
options: ["xs", "sm", "md", "lg", "xl"],
},
},
isDisabled: {
control: {
type: "boolean",
},
},
},
} as ComponentMeta<typeof CircularProgress>;
const defaultProps = {
...progress.defaultVariants,
value: 55,
};
const Template: ComponentStory<typeof CircularProgress> = (args: CircularProgressProps) => (
<CircularProgress {...args} />
);
const IntervalTemplate: ComponentStory<typeof CircularProgress> = (args: CircularProgressProps) => {
const [value, setValue] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
setValue((v) => (v >= 100 ? 0 : v + 10));
}, 800);
return () => clearInterval(interval);
}, []);
return <CircularProgress {...args} value={value} />;
};
export const Default = Template.bind({});
Default.args = {
...defaultProps,
"aria-label": "Loading...",
};
export const WithLabel = Template.bind({});
WithLabel.args = {
...defaultProps,
label: "Loading...",
};
export const WithValueLabel = IntervalTemplate.bind({});
WithValueLabel.args = {
...defaultProps,
size: "lg",
color: "secondary",
showValueLabel: true,
};
export const WithValueFormatting = Template.bind({});
WithValueFormatting.args = {
...defaultProps,
label: "Loading...",
size: "xl",
color: "warning",
showValueLabel: true,
formatOptions: {style: "unit", unit: "kilometer"},
};
export const Indeterminate = Template.bind({});
Indeterminate.args = {
...defaultProps,
isIndeterminate: true,
};

View File

@ -45,6 +45,20 @@ const Template: ComponentStory<typeof Progress> = (args: ProgressProps) => (
</div>
);
const IntervalTemplate: ComponentStory<typeof Progress> = (args: ProgressProps) => {
const [value, setValue] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
setValue((v) => (v >= 100 ? 0 : v + 10));
}, 800);
return () => clearInterval(interval);
}, []);
return <Progress {...args} value={value} />;
};
export const Default = Template.bind({});
Default.args = {
...defaultProps,
@ -57,10 +71,11 @@ WithLabel.args = {
label: "Loading...",
};
export const WithValueLabel = Template.bind({});
export const WithValueLabel = IntervalTemplate.bind({});
WithValueLabel.args = {
...defaultProps,
label: "Loading...",
label: "Downloading...",
color: "success",
showValueLabel: true,
};

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";
/**

View File

@ -1,4 +1,6 @@
import {tv, VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* Accordion wrapper **Tailwind Variants** component

View File

@ -1,4 +1,6 @@
import {tv, VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* AvatarGroup wrapper **Tailwind Variants** component

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {translateCenterClasses, ringClasses, colorVariants} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {colorVariants} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* ButtonGroup wrapper **Tailwind Variants** component

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses, colorVariants} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses, colorVariants} from "../utils";

View File

@ -0,0 +1,119 @@
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* CircularProgress **Tailwind Variants** component
*
* @example
* ```js
* const {base, svgWrapper, svg, circle, value, label} = circularProgress({...})
*
* <div className={base()} aria-label="progress" role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}>
* <div className={svgWrapper()}>
* <svg className={svg()}>
* <circle className={circle()} style={{width: `${value}%`}} />
* </svg>
* <span className={value()}>{value}</span>
* </div>
* <span className={label()}>{label}</span>
* </div>
* ```
*/
const circularProgress = tv({
slots: {
base: "flex flex-col justify-center gap-1 max-w-fit items-center",
label: "",
svgWrapper: "relative block",
svg: "z-0 relative overflow-hidden",
circle: "h-full stroke-current",
value: "absolute font-semibold top-0 left-0 w-full h-full flex items-center justify-center",
},
variants: {
color: {
neutral: {
svg: "text-neutral-400",
},
primary: {
svg: "text-primary",
},
secondary: {
svg: "text-secondary",
},
success: {
svg: "text-success",
},
warning: {
svg: "text-warning",
},
danger: {
svg: "text-danger",
},
},
size: {
xs: {
svg: "w-6 h-6",
label: "text-xs",
value: "text-[0.4rem]",
},
sm: {
svg: "w-8 h-8",
label: "text-sm",
value: "text-[0.5rem]",
},
md: {
svg: "w-10 h-10",
label: "text-sm",
value: "text-[0.6rem]",
},
lg: {
svg: "w-12 h-12",
label: "text-base",
value: "text-xs",
},
xl: {
svg: "w-14 h-14",
label: "text-lg",
value: "text-xs",
},
},
isIndeterminate: {
true: {
svg: "animate-spinner-ease-spin",
},
},
isDisabled: {
true: {
base: "opacity-50 cursor-not-allowed",
},
},
disableAnimation: {
true: {},
false: {
circle: "transition-all !duration-500",
},
},
},
defaultVariants: {
color: "primary",
size: "md",
isIndeterminate: false,
isDisabled: false,
disableAnimation: false,
},
compoundVariants: [
// disableAnimation && !isIndeterminate
{
disableAnimation: true,
isIndeterminate: false,
class: {
svg: "!transition-none motion-reduce:transition-none",
},
},
],
});
export type CircularProgressVariantProps = VariantProps<typeof circularProgress>;
export type CircularProgressSlots = keyof ReturnType<typeof circularProgress>;
export {circularProgress};

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {colorVariants} from "../utils";

View File

@ -21,3 +21,4 @@ export * from "./toggle";
export * from "./accordion-item";
export * from "./accordion";
export * from "./progress";
export * from "./circular-progress";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {focusVisibleClasses} from "../utils";
@ -31,7 +33,8 @@ const link = tv({
false: "no-underline",
},
isBlock: {
true: "px-2 py-1 hover:after:opacity-100 after:content-[' '] after:inset-0 after:opacity-0 after:w-full after:h-full after:rounded-xl after:transition-background after:absolute",
true:
"px-2 py-1 hover:after:opacity-100 after:content-[' '] after:inset-0 after:opacity-0 after:w-full after:h-full after:rounded-xl after:transition-background after:absolute",
false: "hover:opacity-80 transition-opacity",
},
isDisabled: {

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {colorVariants, ringClasses} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* Progress **Tailwind Variants** component
@ -139,8 +141,8 @@ const progress = tv(
],
},
{
twMerge: false
}
twMerge: false,
},
);
export type ProgressVariantProps = VariantProps<typeof progress>;

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses, colorVariants} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
/**
* Spinner wrapper **Tailwind Variants** component

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";

View File

@ -1,4 +1,6 @@
import {tv, type VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {colorVariants} from "../utils";
/**

View File

@ -1,4 +1,6 @@
import {tv, VariantProps} from "tailwind-variants";
import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";