feat(switch): data management improved, animation improved

This commit is contained in:
Junior Garcia 2023-04-15 15:43:08 -03:00
parent af7d07ab66
commit da5aa53a6c
2 changed files with 155 additions and 99 deletions

View File

@ -3,9 +3,9 @@ import type {FocusableRef} from "@react-types/shared";
import type {AriaSwitchProps} from "@react-aria/switch"; import type {AriaSwitchProps} from "@react-aria/switch";
import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system";
import {ReactNode, Ref, useCallback, useId, useRef} from "react"; import {ReactNode, Ref, useCallback, useId, useRef, useState} from "react";
import {mapPropsVariants} from "@nextui-org/system"; import {mapPropsVariants} from "@nextui-org/system";
import {useHover} from "@react-aria/interactions"; import {useHover, usePress} from "@react-aria/interactions";
import {toggle} from "@nextui-org/theme"; import {toggle} from "@nextui-org/theme";
import {chain, mergeProps} from "@react-aria/utils"; import {chain, mergeProps} from "@react-aria/utils";
import {clsx, dataAttr} from "@nextui-org/shared-utils"; import {clsx, dataAttr} from "@nextui-org/shared-utils";
@ -82,7 +82,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
as, as,
name, name,
value = "", value = "",
isReadOnly = false, isReadOnly: isReadOnlyProp = false,
autoFocus = false, autoFocus = false,
startIcon, startIcon,
endIcon, endIcon,
@ -116,7 +116,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
defaultSelected, defaultSelected,
isSelected: isSelectedProp, isSelected: isSelectedProp,
isDisabled: !!originalProps.isDisabled, isDisabled: !!originalProps.isDisabled,
isReadOnly, isReadOnly: isReadOnlyProp,
"aria-label": ariaLabel, "aria-label": ariaLabel,
"aria-labelledby": otherProps["aria-labelledby"] || labelId, "aria-labelledby": otherProps["aria-labelledby"] || labelId,
onChange: onValueChange, onChange: onValueChange,
@ -127,6 +127,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
labelId, labelId,
children, children,
autoFocus, autoFocus,
isReadOnlyProp,
isSelectedProp, isSelectedProp,
defaultSelected, defaultSelected,
originalProps.isDisabled, originalProps.isDisabled,
@ -136,12 +137,38 @@ export function useSwitch(originalProps: UseSwitchProps) {
]); ]);
const state = useToggleState(ariaSwitchProps); const state = useToggleState(ariaSwitchProps);
const {inputProps} = useReactAriaSwitch(ariaSwitchProps, state, inputRef);
const {inputProps, isPressed: isPressedKeyboard, isReadOnly} = useReactAriaSwitch(
ariaSwitchProps,
state,
inputRef,
);
const {focusProps, isFocused, isFocusVisible} = useFocusRing({autoFocus: inputProps.autoFocus}); const {focusProps, isFocused, isFocusVisible} = useFocusRing({autoFocus: inputProps.autoFocus});
const {hoverProps, isHovered} = useHover({ const {hoverProps, isHovered} = useHover({
isDisabled: inputProps.disabled, isDisabled: inputProps.disabled,
}); });
const isInteractionDisabled = ariaSwitchProps.isDisabled || isReadOnly;
// Handle press state for full label. Keyboard press state is returned by useSwitch
// since it is handled on the <input> element itself.
const [isPressed, setPressed] = useState(false);
const {pressProps} = usePress({
isDisabled: isInteractionDisabled,
onPressStart(e) {
if (e.pointerType !== "keyboard") {
setPressed(true);
}
},
onPressEnd(e) {
if (e.pointerType !== "keyboard") {
setPressed(false);
}
},
});
const pressed = isInteractionDisabled ? false : isPressed || isPressedKeyboard;
const isSelected = inputProps.checked; const isSelected = inputProps.checked;
const isDisabled = inputProps.disabled; const isDisabled = inputProps.disabled;
@ -149,80 +176,64 @@ export function useSwitch(originalProps: UseSwitchProps) {
() => () =>
toggle({ toggle({
...variantProps, ...variantProps,
isFocusVisible,
}), }),
[...Object.values(variantProps), isFocusVisible], [...Object.values(variantProps)],
); );
const baseStyles = clsx(classNames?.base, className); const baseStyles = clsx(classNames?.base, className);
const getBaseProps: PropGetter = () => { const getBaseProps: PropGetter = (props) => {
return { return {
...mergeProps(hoverProps, pressProps, otherProps, props),
ref: domRef, ref: domRef,
className: slots.base({class: baseStyles}), className: slots.base({class: clsx(baseStyles, props?.className)}),
"data-disabled": dataAttr(isDisabled), "data-disabled": dataAttr(isDisabled),
"data-checked": dataAttr(isSelected), "data-checked": dataAttr(isSelected),
...mergeProps(hoverProps, otherProps), "data-readonly": dataAttr(isReadOnly),
"data-focus": dataAttr(isFocused),
"data-focus-visible": dataAttr(isFocusVisible),
"data-hover": dataAttr(isHovered),
"data-pressed": dataAttr(pressed),
style: {
...props?.style,
WebkitTapHighlightColor: "transparent",
},
}; };
}; };
const getWrapperProps: PropGetter = useCallback(() => { const getWrapperProps: PropGetter = useCallback(
return { (props = {}) => {
"data-hover": dataAttr(isHovered), return {
"data-checked": dataAttr(isSelected), ...props,
"data-focus": dataAttr(isFocused), "aria-hidden": true,
"data-focus-visible": dataAttr(isFocused && isFocusVisible), className: clsx(slots.wrapper({class: clsx(classNames?.wrapper, props?.className)})),
"data-disabled": dataAttr(isDisabled), };
"data-readonly": dataAttr(inputProps.readOnly), },
"aria-hidden": true, [slots, classNames?.wrapper],
className: clsx(slots.wrapper({class: classNames?.wrapper})), );
};
}, [
slots,
classNames?.wrapper,
isHovered,
isSelected,
isFocused,
isFocusVisible,
isDisabled,
inputProps.readOnly,
]);
const getInputProps: PropGetter = () => { const getInputProps: PropGetter = (props = {}) => {
return { return {
...mergeProps(inputProps, focusProps, props),
ref: inputRef, ref: inputRef,
id: inputProps.id, id: inputProps.id,
...mergeProps(inputProps, focusProps),
onChange: chain(onChange, inputProps.onChange), onChange: chain(onChange, inputProps.onChange),
}; };
}; };
const getThumbProps: PropGetter = useCallback( const getThumbProps: PropGetter = useCallback(
() => ({ (props = {}) => ({
"data-checked": dataAttr(isSelected), ...props,
"data-focus": dataAttr(isFocused), className: slots.thumb({class: clsx(classNames?.thumb, props?.className)}),
"data-focus-visible": dataAttr(isFocused && isFocusVisible),
"data-disabled": dataAttr(isDisabled),
"data-readonly": dataAttr(inputProps.readOnly),
className: slots.thumb({class: classNames?.thumb}),
}), }),
[ [slots, classNames?.thumb],
slots,
classNames?.thumb,
isSelected,
isFocused,
isFocusVisible,
isDisabled,
inputProps.readOnly,
],
); );
const getLabelProps: PropGetter = useCallback( const getLabelProps: PropGetter = useCallback(
() => ({ (props = {}) => ({
...props,
id: labelId, id: labelId,
"data-disabled": dataAttr(isDisabled), className: slots.label({class: clsx(classNames?.label, props?.className)}),
"data-checked": dataAttr(isSelected),
className: slots.label({class: classNames?.label}),
}), }),
[slots, classNames?.label, isDisabled, isSelected], [slots, classNames?.label, isDisabled, isSelected],
); );
@ -237,8 +248,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
{ {
width: "1em", width: "1em",
height: "1em", height: "1em",
"data-checked": dataAttr(isSelected), className: slots.thumbIcon({class: clsx(classNames?.thumbIcon)}),
className: slots.thumbIcon({class: classNames?.thumbIcon}),
}, },
props.includeStateProps props.includeStateProps
? { ? {
@ -246,25 +256,25 @@ export function useSwitch(originalProps: UseSwitchProps) {
} }
: {}, : {},
) as unknown) as SwitchThumbIconProps, ) as unknown) as SwitchThumbIconProps,
[slots, classNames?.thumbIcon, isSelected, originalProps.disableAnimation], [slots, classNames?.thumbIcon, isSelected],
); );
const getStartIconProps = useCallback( const getStartIconProps = useCallback<PropGetter>(
() => ({ (props = {}) => ({
width: "1em", width: "1em",
height: "1em", height: "1em",
"data-checked": dataAttr(isSelected), ...props,
className: slots.startIcon({class: classNames?.startIcon}), className: slots.startIcon({class: clsx(classNames?.startIcon, props?.className)}),
}), }),
[slots, classNames?.startIcon, isSelected], [slots, classNames?.startIcon, isSelected],
); );
const getEndIconProps = useCallback( const getEndIconProps = useCallback<PropGetter>(
() => ({ (props = {}) => ({
width: "1em", width: "1em",
height: "1em", height: "1em",
"data-checked": dataAttr(isSelected), ...props,
className: slots.endIcon({class: classNames?.endIcon}), className: slots.endIcon({class: clsx(classNames?.endIcon, props?.className)}),
}), }),
[slots, classNames?.endIcon, isSelected], [slots, classNames?.endIcon, isSelected],
); );
@ -280,6 +290,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
endIcon, endIcon,
isHovered, isHovered,
isSelected, isSelected,
isPressed: pressed,
isFocused, isFocused,
isFocusVisible, isFocusVisible,
isDisabled, isDisabled,

View File

@ -2,17 +2,22 @@ import type {VariantProps} from "tailwind-variants";
import {tv} from "tailwind-variants"; import {tv} from "tailwind-variants";
import {ringClasses} from "../utils";
/** /**
* Toggle (Switch) wrapper **Tailwind Variants** component * Toggle (Switch) wrapper **Tailwind Variants** component
* *
* const {base, wrapper, thumb, thumbIcon, label, startIcon, endIcon} = toggle({...}) * const {base, wrapper, thumb, thumbIcon, label, startIcon, endIcon} = toggle({...})
* *
* @example * @example
* <label className={base())}> * <label
* // hidden input * className={base())}
* <span className={wrapper()} aria-hidden="true" data-checked={checked}> * data-checked={checked}
* data-pressed={pressed}
* data-focus={focus}
* data-hover={hover}
* data-focus-visible={focusVisible}
* >
* <input/> // hidden input
* <span className={wrapper()} aria-hidden="true">
* <svg className={startIcon()}>...</svg> * <svg className={startIcon()}>...</svg>
* <span className={thumb()}> * <span className={thumb()}>
* <svg className={thumbIcon()}>...</svg> * <svg className={thumbIcon()}>...</svg>
@ -24,9 +29,9 @@ import {ringClasses} from "../utils";
*/ */
const toggle = tv({ const toggle = tv({
slots: { slots: {
base: "relative max-w-fit inline-flex items-center justify-start cursor-pointer", base:
"group relative max-w-fit inline-flex items-center justify-start cursor-pointer touch-none",
wrapper: [ wrapper: [
"group",
"px-1", "px-1",
"relative", "relative",
"inline-flex", "inline-flex",
@ -36,6 +41,13 @@ const toggle = tv({
"overflow-hidden", "overflow-hidden",
"bg-neutral-200", "bg-neutral-200",
"rounded-full", "rounded-full",
// focus ring
"group-data-[focus-visible]:outline-none",
"group-data-[focus-visible]:ring-2",
"group-data-[focus-visible]:!ring-primary",
"group-data-[focus-visible]:ring-offset-2",
"group-data-[focus-visible]:ring-offset-background",
"group-data-[focus-visible]:dark:ring-offset-background-dark",
], ],
thumb: [ thumb: [
"z-10", "z-10",
@ -45,7 +57,7 @@ const toggle = tv({
"bg-white", "bg-white",
"shadow-sm", "shadow-sm",
"rounded-full", "rounded-full",
"data-[checked=true]:translate-x-full", "origin-right",
], ],
startIcon: "z-0 absolute left-1.5 text-current", startIcon: "z-0 absolute left-1.5 text-current",
endIcon: "z-0 absolute right-1.5 text-neutral-600", endIcon: "z-0 absolute right-1.5 text-neutral-600",
@ -56,42 +68,52 @@ const toggle = tv({
color: { color: {
neutral: { neutral: {
wrapper: [ wrapper: [
"data-[checked=true]:bg-neutral-400", "group-data-[checked=true]:bg-neutral-400",
"data-[checked=true]:text-neutral-contrastText", "group-data-[checked=true]:text-neutral-contrastText",
], ],
}, },
primary: { primary: {
wrapper: [ wrapper: [
"data-[checked=true]:bg-primary", "group-data-[checked=true]:bg-primary",
"data-[checked=true]:text-primary-contrastText", "group-data-[checked=true]:text-primary-contrastText",
], ],
}, },
secondary: { secondary: {
wrapper: [ wrapper: [
"data-[checked=true]:bg-secondary", "group-data-[checked=true]:bg-secondary",
"data-[checked=true]:text-secondary-contrastText", "group-data-[checked=true]:text-secondary-contrastText",
], ],
}, },
success: { success: {
wrapper: [ wrapper: [
"data-[checked=true]:bg-success", "group-data-[checked=true]:bg-success",
"data-[checked=true]:text-success-contrastText", "group-data-[checked=true]:text-success-contrastText",
], ],
}, },
warning: { warning: {
wrapper: [ wrapper: [
"data-[checked=true]:bg-warning", "group-data-[checked=true]:bg-warning",
"data-[checked=true]:text-warning-contrastText", "group-data-[checked=true]:text-warning-contrastText",
], ],
}, },
danger: { danger: {
wrapper: ["data-[checked=true]:bg-danger", "data-[checked=true]:text-danger-contrastText"], wrapper: [
"group-data-[checked=true]:bg-danger",
"data-[checked=true]:text-danger-contrastText",
],
}, },
}, },
size: { size: {
xs: { xs: {
wrapper: "px-0.5 w-7 h-4 mr-1", wrapper: "px-0.5 w-7 h-4 mr-1",
thumb: "w-3 h-3 text-[0.5rem]", thumb: [
"w-3 h-3 text-[0.5rem]",
//checked
"group-data-[checked=true]:ml-3",
// pressed
"group-data-[pressed=true]:w-4",
"group-data-[checked]:group-data-[pressed]:ml-2",
],
startIcon: "text-[0.5rem] left-1", startIcon: "text-[0.5rem] left-1",
endIcon: "text-[0.5rem] right-1", endIcon: "text-[0.5rem] right-1",
right: "text-[0.5rem]", right: "text-[0.5rem]",
@ -99,38 +121,61 @@ const toggle = tv({
}, },
sm: { sm: {
wrapper: "w-8 h-5 mr-1", wrapper: "w-8 h-5 mr-1",
thumb: "w-3 h-3 text-[0.6rem]", thumb: [
"w-3 h-3 text-[0.6rem]",
//checked
"group-data-[checked=true]:ml-3",
// pressed
"group-data-[pressed=true]:w-4",
"group-data-[checked]:group-data-[pressed]:ml-2",
],
startIcon: "text-[0.6rem] left-1", startIcon: "text-[0.6rem] left-1",
endIcon: "text-[0.6rem] right-1", endIcon: "text-[0.6rem] right-1",
label: "text-sm", label: "text-sm",
}, },
md: { md: {
wrapper: "w-10 h-6 mr-2", wrapper: "w-10 h-6 mr-2",
thumb: "w-4 h-4 text-xs", thumb: [
"w-4 h-4 text-xs",
//checked
"group-data-[checked=true]:ml-4",
// pressed
"group-data-[pressed=true]:w-5",
"group-data-[checked]:group-data-[pressed]:ml-4",
],
endIcon: "text-xs", endIcon: "text-xs",
startIcon: "text-xs", startIcon: "text-xs",
label: "text-base", label: "text-base",
}, },
lg: { lg: {
wrapper: "w-12 h-7 mr-2", wrapper: "w-12 h-7 mr-2",
thumb: "w-5 h-5 text-sm", thumb: [
"w-5 h-5 text-sm",
//checked
"group-data-[checked=true]:ml-5",
// pressed
"group-data-[pressed=true]:w-6",
"group-data-[checked]:group-data-[pressed]:ml-4",
],
endIcon: "text-sm", endIcon: "text-sm",
startIcon: "text-sm", startIcon: "text-sm",
label: "text-lg", label: "text-lg",
}, },
xl: { xl: {
wrapper: "w-14 h-8 mr-2", wrapper: "w-14 h-8 mr-2",
thumb: "w-6 h-6 text-base", thumb: [
"w-6 h-6 text-base",
//checked
"group-data-[checked=true]:ml-6",
// pressed
"group-data-[pressed=true]:w-7",
"group-data-[checked]:group-data-[pressed]:ml-5",
],
endIcon: "text-base", endIcon: "text-base",
startIcon: "text-base", startIcon: "text-base",
label: "text-xl", label: "text-xl",
}, },
}, },
isFocusVisible: {
true: {
wrapper: [...ringClasses],
},
},
isDisabled: { isDisabled: {
true: { true: {
base: "opacity-50 pointer-events-none", base: "opacity-50 pointer-events-none",
@ -142,20 +187,20 @@ const toggle = tv({
thumb: "transition-none", thumb: "transition-none",
}, },
false: { false: {
wrapper: "transition-background", wrapper: "transition-background !duration-500",
thumb: "transition-transform !ease-soft-spring !duration-500", thumb: "transition-all !ease-soft-spring !duration-500",
startIcon: [ startIcon: [
"opacity-0", "opacity-0",
"scale-50", "scale-50",
"transition-transform-opacity", "transition-transform-opacity",
"data-[checked=true]:scale-100", "group-data-[checked=true]:scale-100",
"data-[checked=true]:opacity-100", "group-data-[checked=true]:opacity-100",
], ],
endIcon: [ endIcon: [
"opacity-100", "opacity-100",
"transition-transform-opacity", "transition-transform-opacity",
"data-[checked=true]:translate-x-3", "group-data-[checked=true]:translate-x-3",
"data-[checked=true]:opacity-0", "group-data-[checked=true]:opacity-0",
], ],
}, },
}, },