feat(progress): component done, needless useId removed, SSR provider exported again

This commit is contained in:
Junior Garcia 2023-03-29 17:06:23 -03:00
parent 187e8dde83
commit 791b955800
18 changed files with 312 additions and 133 deletions

View File

@ -117,14 +117,12 @@ export function useCheckbox(props: UseCheckboxProps) {
const domRef = useFocusableRef(ref as FocusableRef<HTMLLabelElement>, inputRef);
const labelId = useId();
const inputId = useId();
const ariaCheckboxProps = useMemo(() => {
const ariaLabel =
otherProps["aria-label"] || typeof children === "string" ? (children as string) : undefined;
return {
id: inputId,
name,
value,
children,
@ -143,7 +141,6 @@ export function useCheckbox(props: UseCheckboxProps) {
}, [
value,
name,
inputId,
labelId,
children,
autoFocus,
@ -236,7 +233,6 @@ export function useCheckbox(props: UseCheckboxProps) {
const getLabelProps: PropGetter = useCallback(
() => ({
id: labelId,
htmlFor: inputProps.id || inputId,
"data-disabled": dataAttr(isDisabled),
"data-checked": dataAttr(isSelected),
"data-invalid": dataAttr(isInvalid),

View File

@ -5,7 +5,7 @@ import {Progress} from "../src";
describe("Progress", () => {
it("should render correctly", () => {
const wrapper = render(<Progress />);
const wrapper = render(<Progress aria-label="progress" />);
expect(() => wrapper.unmount()).not.toThrow();
});
@ -13,7 +13,82 @@ describe("Progress", () => {
it("ref should be forwarded", () => {
const ref = React.createRef<HTMLDivElement>();
render(<Progress ref={ref} />);
render(<Progress ref={ref} aria-label="progress" />);
expect(ref.current).not.toBeNull();
});
it("should contain progress aria attributes", () => {
const {container} = render(<Progress aria-label="progress" />);
const div = container.querySelector("div");
expect(div).toHaveAttribute("role", "progressbar");
expect(div).toHaveAttribute("aria-valuemin", "0");
expect(div).toHaveAttribute("aria-valuemax", "100");
expect(div).toHaveAttribute("aria-valuenow", "0");
expect(div).toHaveAttribute("aria-valuetext", "0%");
});
it("should display the correct value", () => {
const {container} = render(<Progress aria-label="progress" value={55} />);
// get the "aria-valuenow" attribute
const value = container.querySelector("div")?.getAttribute("aria-valuenow");
expect(value).toBe("55");
});
it("should support label value formatting", () => {
const {container} = render(
<Progress
aria-label="progress"
formatOptions={{style: "currency", currency: "ARS"}}
value={55}
/>,
);
// get the "aria-valuetext" attribute
const value = container.querySelector("div")?.getAttribute("aria-valuetext");
expect(value).toBe("ARS 55.00");
});
it("should ignore a value under the minimum", () => {
const {container} = render(<Progress aria-label="progress" value={-1} />);
// get the "aria-valuenow" attribute
const value = container.querySelector("div")?.getAttribute("aria-valuenow");
expect(value).toBe("0");
});
it("should ignore a value over the maximum", () => {
const {container} = render(<Progress aria-label="progress" value={101} />);
// get the "aria-valuenow" attribute
const value = container.querySelector("div")?.getAttribute("aria-valuenow");
expect(value).toBe("100");
});
it("should render a label", () => {
const {container} = render(<Progress aria-label="progress" label="Loading..." />);
expect(container.querySelector("span")).not.toBeNull();
});
it("should render a value label", () => {
const {container} = render(<Progress showValueLabel aria-label="progress" value={55} />);
expect(container.querySelector("span")).not.toBeNull();
});
it("the aria-valuenow should not be set if isIndeterminate is true", () => {
const {container} = render(<Progress isIndeterminate aria-label="progress" />);
// get the "aria-valuenow" attribute
const value = container.querySelector("div")?.getAttribute("aria-valuenow");
expect(value).toBeNull();
});
});

View File

@ -40,6 +40,7 @@
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/dom-utils": "workspace:*",
"@nextui-org/shared-utils": "workspace:*",
"@nextui-org/use-is-mounted": "workspace:*",
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@react-aria/i18n": "^3.7.0",

View File

@ -10,8 +10,8 @@ const Progress = forwardRef<ProgressProps, "div">((props, ref) => {
slots,
styles,
label,
percentage,
showValueLabel,
barWidth,
getProgressBarProps,
getLabelProps,
} = useProgress({ref, ...props});
@ -28,11 +28,11 @@ const Progress = forwardRef<ProgressProps, "div">((props, ref) => {
</span>
)}
</div>
<div className={slots.wrapper({class: styles?.wrapper})}>
<div className={slots.track({class: styles?.track})}>
<div
className={slots.filler({class: styles?.filler})}
style={{
width: barWidth,
transform: `translateX(-${100 - (percentage || 0)}%)`,
}}
/>
</div>

View File

@ -5,9 +5,10 @@ import type {AriaProgressBarProps} from "@react-types/progress";
import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system";
import {progress} from "@nextui-org/theme";
import {useDOMRef} from "@nextui-org/dom-utils";
import {clsx, ReactRef} from "@nextui-org/shared-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";
@ -24,11 +25,11 @@ export interface Props extends HTMLNextUIProps<"div"> {
* ```ts
* <Progress styles={{
* base:"base-classes",
* wrapper: "wrapper-classes",
* filler: "filler-classes",
* labelWrapper: "labelWrapper-classes",
* label: "label-classes",
* value: "value-classes",
* track: "track-classes",
* filler: "filler-classes",
* }} />
* ```
*/
@ -52,7 +53,6 @@ export function useProgress(originalProps: UseProgressProps) {
minValue = 0,
maxValue = 100,
showValueLabel = false,
isIndeterminate = false,
formatOptions = {
style: "percent",
},
@ -64,6 +64,11 @@ export function useProgress(originalProps: UseProgressProps) {
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,
@ -72,8 +77,8 @@ export function useProgress(originalProps: UseProgressProps) {
minValue,
maxValue,
valueLabel,
isIndeterminate,
formatOptions,
isIndeterminate,
"aria-labelledby": originalProps["aria-labelledby"],
"aria-label": originalProps["aria-label"],
});
@ -86,21 +91,34 @@ export function useProgress(originalProps: UseProgressProps) {
[...Object.values(variantProps)],
);
const selfMounted = originalProps.disableAnimation ? true : isMounted;
// Calculate the width of the progress bar as a percentage
const percentage = useMemo(
() => (isIndeterminate ? undefined : ((value - minValue) / (maxValue - minValue)) * 100),
[isIndeterminate, value, minValue, maxValue],
() =>
isIndeterminate || !selfMounted
? undefined
: ((value - minValue) / (maxValue - minValue)) * 100,
[selfMounted, isIndeterminate, value, minValue, maxValue],
);
const barWidth = typeof percentage === "number" ? `${Math.round(percentage)}%` : undefined;
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, baseStyles, progressBarProps, otherProps],
[
domRef,
slots,
isIndeterminate,
originalProps.isDisabled,
baseStyles,
progressBarProps,
otherProps,
],
);
const getLabelProps = useCallback<PropGetter>(
@ -117,7 +135,6 @@ export function useProgress(originalProps: UseProgressProps) {
slots,
styles,
label,
barWidth,
percentage,
showValueLabel,
getProgressBarProps,

View File

@ -39,11 +39,16 @@ const defaultProps = {
value: 55,
};
const Template: ComponentStory<typeof Progress> = (args: ProgressProps) => <Progress {...args} />;
const Template: ComponentStory<typeof Progress> = (args: ProgressProps) => (
<div className="max-w-[400px]">
<Progress {...args} />
</div>
);
export const Default = Template.bind({});
Default.args = {
...defaultProps,
"aria-label": "Loading...",
};
export const WithLabel = Template.bind({});
@ -58,3 +63,23 @@ WithValueLabel.args = {
label: "Loading...",
showValueLabel: true,
};
export const WithValueFormatting = Template.bind({});
WithValueFormatting.args = {
...defaultProps,
label: "Loading...",
showValueLabel: true,
formatOptions: {style: "currency", currency: "ARS"},
};
export const Indeterminate = Template.bind({});
Indeterminate.args = {
...defaultProps,
isIndeterminate: true,
};
export const Striped = Template.bind({});
Striped.args = {
...defaultProps,
isStriped: true,
};

View File

@ -86,9 +86,6 @@ export function useRadio(props: UseRadioProps) {
const inputRef = useRef<HTMLInputElement>(null);
const labelId = useId();
const inputGenId = useId();
const inputId = id || inputGenId;
const isDisabled = useMemo(() => !!isDisabledProp, [isDisabledProp]);
const isRequired = useMemo(() => groupContext.isRequired ?? false, [groupContext.isRequired]);
@ -106,14 +103,14 @@ export function useRadio(props: UseRadioProps) {
: undefined;
return {
id: inputId,
id,
isDisabled,
isRequired,
"aria-label": ariaLabel,
"aria-labelledby": otherProps["aria-labelledby"] || labelId,
"aria-describedby": ariaDescribedBy,
};
}, [labelId, inputId, isDisabled, isRequired]);
}, [labelId, id, isDisabled, isRequired]);
const {inputProps} = useReactAriaRadio(
{
@ -196,7 +193,6 @@ export function useRadio(props: UseRadioProps) {
(props = {}) => ({
...props,
id: labelId,
htmlFor: inputId,
"data-disabled": dataAttr(isDisabled),
"data-checked": dataAttr(isSelected),
"data-invalid": dataAttr(isInvalid),

View File

@ -98,7 +98,6 @@ export function useSwitch(originalProps: UseSwitchProps) {
const domRef = useFocusableRef(ref as FocusableRef<HTMLLabelElement>, inputRef);
const labelId = useId();
const inputId = useId();
const ariaSwitchProps = useMemo(() => {
const ariaLabel =
@ -187,7 +186,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
const getInputProps: PropGetter = () => {
return {
ref: inputRef,
id: inputProps.id || inputId,
id: inputProps.id,
...mergeProps(inputProps, focusProps),
};
};
@ -207,7 +206,6 @@ export function useSwitch(originalProps: UseSwitchProps) {
const getLabelProps: PropGetter = useCallback(
() => ({
id: labelId,
htmlFor: inputProps.id || inputId,
"data-disabled": dataAttr(isDisabled),
"data-checked": dataAttr(isSelected),
className: slots.label({class: styles?.label}),

View File

@ -37,8 +37,8 @@
"react": ">=18"
},
"devDependencies": {
"react": "^18.0.0",
"clean-package": "2.2.0"
"clean-package": "2.2.0",
"react": "^18.0.0"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
@ -48,5 +48,8 @@
"cjs",
"esm"
]
},
"dependencies": {
"@react-aria/ssr": "^3.5.0"
}
}

View File

@ -1,2 +1,3 @@
export * from "./types";
export * from "./utils";
export * from "./provider";

View File

@ -0,0 +1,3 @@
import {SSRProvider} from "@react-aria/ssr";
export const NextUIProvider = SSRProvider;

View File

@ -5,6 +5,8 @@ export const animations = {
"spinner-linear-spin": "spinner-spin 0.8s linear infinite",
"appearance-in": "appearance-in 250ms ease-out normal both",
"appearance-out": "appearance-out 60ms ease-in normal both",
"indeterminate-bar":
"indeterminate-bar 1.5s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite normal none running",
},
keyframes: {
"spinner-spin": {
@ -52,5 +54,13 @@ export const animations = {
transform: "scale(0.85)",
},
},
"indeterminate-bar": {
"0%": {
transform: "translateX(-150%)",
},
"100%": {
transform: "translateX(200%)",
},
},
},
};

View File

@ -33,36 +33,35 @@ const card = tv({
],
header: [
"flex",
"p-3",
"z-10",
"justify-start",
"items-center",
"shrink-0",
"w-full",
"overflow-inherit",
"color-inherit",
"p-3",
"z-10",
"subpixel-antialiased",
],
body: [
"relative",
"flex",
"p-5",
"flex-auto",
"flex-col",
"place-content-inherit",
"align-items-inherit",
"w-full",
"h-auto",
"py-5",
"px-3",
"break-words",
"text-left",
"overflow-y-auto",
"subpixel-antialiased",
],
footer: [
"p-3",
"w-full",
"h-auto",
"p-3",
"flex",
"items-center",
"overflow-hidden",

View File

@ -5,113 +5,143 @@ import {tv, type VariantProps} from "tailwind-variants";
*
* @example
* ```js
* const {base, wrapper, filler, labelWrapper, label, value} = progress({...})
* const {base, labelWrapper, label, value, track, filler} = progress({...})
*
* <div className={base()} aria-label="progress" role="progressbar" aria-valuenow={value} aria-valuemin={min} aria-valuemax={max}>
* <div className={labelWrapper()}>
* <span className={label()}>{label}</span>
* <span className={value()}>{value}</span>
* </div>
* <div className={wrapper()}>
* <div className={track()}>
* <div className={filler()} style={{width: `${value}%`}} />
* </div>
* </div>
* ```
*/
const progress = tv({
slots: {
base: "flex flex-col gap-2",
label: "",
labelWrapper: "flex justify-between",
value: "",
wrapper: "flex flex-1 bg-neutral",
filler: "",
const progress = tv(
{
slots: {
base: "flex flex-col gap-2",
label: "",
labelWrapper: "flex justify-between",
value: "",
track: "z-0 relative bg-neutral-200 overflow-hidden",
filler: "h-full",
},
variants: {
color: {
neutral: {
filler: "bg-neutral-400",
},
primary: {
filler: "bg-primary",
},
secondary: {
filler: "bg-secondary",
},
success: {
filler: "bg-success",
},
warning: {
filler: "bg-warning",
},
danger: {
filler: "bg-danger",
},
},
size: {
xs: {
track: "h-1",
},
sm: {
track: "h-2",
},
md: {
track: "h-4",
},
lg: {
track: "h-6",
},
xl: {
track: "h-7",
},
},
radius: {
none: {
track: "rounded-none",
filler: "rounded-none",
},
base: {
track: "rounded",
filler: "rounded",
},
sm: {
track: "rounded-sm",
filler: "rounded-sm",
},
md: {
track: "rounded-md",
filler: "rounded-md",
},
lg: {
track: "rounded-lg",
filler: "rounded-lg",
},
xl: {
track: "rounded-xl",
filler: "rounded-xl",
},
full: {
track: "rounded-full",
filler: "rounded-full",
},
},
isStriped: {
true: {
filler: "bg-stripe-gradient bg-[length:1.25rem_1.25rem]",
},
},
isIndeterminate: {
true: {
filler: ["absolute", "w-1/2", "animate-indeterminate-bar"],
},
},
isDisabled: {
true: {
base: "opacity-50 cursor-not-allowed",
},
},
disableAnimation: {
true: {},
false: {
filler: "transition-transform !duration-500",
},
},
},
defaultVariants: {
color: "primary",
size: "md",
radius: "full",
isStriped: false,
isIndeterminate: false,
isDisabled: false,
disableAnimation: false,
},
compoundVariants: [
// disableAnimation && !isIndeterminate
{
disableAnimation: true,
isIndeterminate: false,
class: {
filler: "!transition-none motion-reduce:transition-none",
},
},
],
},
variants: {
color: {
neutral: {
filler: "bg-neutral-500",
},
primary: {
filler: "bg-primary",
},
secondary: {
filler: "bg-secondary",
},
success: {
filler: "bg-success",
},
warning: {
filler: "bg-warning",
},
danger: {
filler: "bg-danger",
},
},
size: {
xs: {
filler: "h-1",
},
sm: {
filler: "h-2",
},
md: {
filler: "h-4",
},
lg: {
filler: "h-6",
},
xl: {
filler: "h-7",
},
},
radius: {
none: {
wrapper: "rounded-none",
filler: "rounded-none",
},
wrapper: {
wrapper: "rounded",
filler: "rounded",
},
sm: {
wrapper: "rounded-sm",
filler: "rounded-sm",
},
md: {
wrapper: "rounded-md",
filler: "rounded-md",
},
lg: {
wrapper: "rounded-lg",
filler: "rounded-lg",
},
xl: {
wrapper: "rounded-xl",
filler: "rounded-xl",
},
full: {
wrapper: "rounded-full",
filler: "rounded-full",
},
},
isDisabled: {
true: {
base: "opacity-50 cursor-not-allowed",
},
},
disableAnimation: {
true: {},
},
{
twMerge: false,
},
defaultVariants: {
color: "primary",
size: "md",
radius: "full",
isDisabled: false,
disableAnimation: false,
},
});
);
export type ProgressVariantProps = VariantProps<typeof progress>;
export type ProgressSlots = keyof ReturnType<typeof progress>;

View File

@ -198,6 +198,10 @@ const corePlugin = (config: ConfigObject | ConfigFunction = {}, defaultTheme: De
3: "3px",
5: "5px",
},
backgroundImage: {
"stripe-gradient":
"linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent)",
},
transitionDuration: {
0: "0ms",
250: "250ms",

View File

@ -51,6 +51,11 @@ export const utilities = {
"transition-timing-function": "ease",
"transition-duration": DEFAULT_TRANSITION_DURATION,
},
".transition-left": {
"transition-property": "left",
"transition-timing-function": "ease",
"transition-duration": DEFAULT_TRANSITION_DURATION,
},
".transition-shadow": {
"transition-property": "box-shadow",
"transition-timing-function": "ease",

View File

@ -2,10 +2,11 @@ import {useCallback, useEffect, useRef, useState} from "react";
export type UseIsMountedProps = {
rerender?: boolean;
delay?: number;
};
export function useIsMounted(props: UseIsMountedProps = {}) {
const {rerender = false} = props;
const {rerender = false, delay = 0} = props;
const isMountedRef = useRef(false);
const [isMounted, setIsMounted] = useState(false);
@ -13,9 +14,16 @@ export function useIsMounted(props: UseIsMountedProps = {}) {
// Update the ref when the component mounts
useEffect(() => {
isMountedRef.current = true;
let timer: any = null;
if (rerender) {
setIsMounted(true);
if (delay > 0) {
timer = setTimeout(() => {
setIsMounted(true);
}, delay);
} else {
setIsMounted(true);
}
}
// Update the ref when the component unmounts
@ -24,6 +32,9 @@ export function useIsMounted(props: UseIsMountedProps = {}) {
if (rerender) {
setIsMounted(false);
}
if (timer) {
clearTimeout(timer);
}
};
}, [rerender]);

5
pnpm-lock.yaml generated
View File

@ -629,6 +629,7 @@ importers:
'@nextui-org/shared-utils': workspace:*
'@nextui-org/system': workspace:*
'@nextui-org/theme': workspace:*
'@nextui-org/use-is-mounted': workspace:*
'@react-aria/i18n': ^3.7.0
'@react-aria/progress': ^3.4.0
'@react-aria/utils': ^3.15.0
@ -641,6 +642,7 @@ importers:
'@nextui-org/shared-utils': link:../../utilities/shared-utils
'@nextui-org/system': link:../../core/system
'@nextui-org/theme': link:../../core/theme
'@nextui-org/use-is-mounted': link:../../hooks/use-is-mounted
'@react-aria/i18n': 3.7.0_react@18.2.0
'@react-aria/progress': 3.4.0_react@18.2.0
'@react-aria/utils': 3.15.0_react@18.2.0
@ -874,8 +876,11 @@ importers:
packages/core/system:
specifiers:
'@react-aria/ssr': ^3.5.0
clean-package: 2.2.0
react: ^18.2.0
dependencies:
'@react-aria/ssr': 3.5.0_react@18.2.0
devDependencies:
clean-package: 2.2.0
react: 18.2.0