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 {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 {useHover} from "@react-aria/interactions";
import {useHover, usePress} from "@react-aria/interactions";
import {toggle} from "@nextui-org/theme";
import {chain, mergeProps} from "@react-aria/utils";
import {clsx, dataAttr} from "@nextui-org/shared-utils";
@ -82,7 +82,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
as,
name,
value = "",
isReadOnly = false,
isReadOnly: isReadOnlyProp = false,
autoFocus = false,
startIcon,
endIcon,
@ -116,7 +116,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
defaultSelected,
isSelected: isSelectedProp,
isDisabled: !!originalProps.isDisabled,
isReadOnly,
isReadOnly: isReadOnlyProp,
"aria-label": ariaLabel,
"aria-labelledby": otherProps["aria-labelledby"] || labelId,
onChange: onValueChange,
@ -127,6 +127,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
labelId,
children,
autoFocus,
isReadOnlyProp,
isSelectedProp,
defaultSelected,
originalProps.isDisabled,
@ -136,12 +137,38 @@ export function useSwitch(originalProps: UseSwitchProps) {
]);
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 {hoverProps, isHovered} = useHover({
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 isDisabled = inputProps.disabled;
@ -149,80 +176,64 @@ export function useSwitch(originalProps: UseSwitchProps) {
() =>
toggle({
...variantProps,
isFocusVisible,
}),
[...Object.values(variantProps), isFocusVisible],
[...Object.values(variantProps)],
);
const baseStyles = clsx(classNames?.base, className);
const getBaseProps: PropGetter = () => {
const getBaseProps: PropGetter = (props) => {
return {
...mergeProps(hoverProps, pressProps, otherProps, props),
ref: domRef,
className: slots.base({class: baseStyles}),
className: slots.base({class: clsx(baseStyles, props?.className)}),
"data-disabled": dataAttr(isDisabled),
"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(() => {
return {
"data-hover": dataAttr(isHovered),
"data-checked": dataAttr(isSelected),
"data-focus": dataAttr(isFocused),
"data-focus-visible": dataAttr(isFocused && isFocusVisible),
"data-disabled": dataAttr(isDisabled),
"data-readonly": dataAttr(inputProps.readOnly),
"aria-hidden": true,
className: clsx(slots.wrapper({class: classNames?.wrapper})),
};
}, [
slots,
classNames?.wrapper,
isHovered,
isSelected,
isFocused,
isFocusVisible,
isDisabled,
inputProps.readOnly,
]);
const getWrapperProps: PropGetter = useCallback(
(props = {}) => {
return {
...props,
"aria-hidden": true,
className: clsx(slots.wrapper({class: clsx(classNames?.wrapper, props?.className)})),
};
},
[slots, classNames?.wrapper],
);
const getInputProps: PropGetter = () => {
const getInputProps: PropGetter = (props = {}) => {
return {
...mergeProps(inputProps, focusProps, props),
ref: inputRef,
id: inputProps.id,
...mergeProps(inputProps, focusProps),
onChange: chain(onChange, inputProps.onChange),
};
};
const getThumbProps: PropGetter = useCallback(
() => ({
"data-checked": dataAttr(isSelected),
"data-focus": dataAttr(isFocused),
"data-focus-visible": dataAttr(isFocused && isFocusVisible),
"data-disabled": dataAttr(isDisabled),
"data-readonly": dataAttr(inputProps.readOnly),
className: slots.thumb({class: classNames?.thumb}),
(props = {}) => ({
...props,
className: slots.thumb({class: clsx(classNames?.thumb, props?.className)}),
}),
[
slots,
classNames?.thumb,
isSelected,
isFocused,
isFocusVisible,
isDisabled,
inputProps.readOnly,
],
[slots, classNames?.thumb],
);
const getLabelProps: PropGetter = useCallback(
() => ({
(props = {}) => ({
...props,
id: labelId,
"data-disabled": dataAttr(isDisabled),
"data-checked": dataAttr(isSelected),
className: slots.label({class: classNames?.label}),
className: slots.label({class: clsx(classNames?.label, props?.className)}),
}),
[slots, classNames?.label, isDisabled, isSelected],
);
@ -237,8 +248,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
{
width: "1em",
height: "1em",
"data-checked": dataAttr(isSelected),
className: slots.thumbIcon({class: classNames?.thumbIcon}),
className: slots.thumbIcon({class: clsx(classNames?.thumbIcon)}),
},
props.includeStateProps
? {
@ -246,25 +256,25 @@ export function useSwitch(originalProps: UseSwitchProps) {
}
: {},
) as unknown) as SwitchThumbIconProps,
[slots, classNames?.thumbIcon, isSelected, originalProps.disableAnimation],
[slots, classNames?.thumbIcon, isSelected],
);
const getStartIconProps = useCallback(
() => ({
const getStartIconProps = useCallback<PropGetter>(
(props = {}) => ({
width: "1em",
height: "1em",
"data-checked": dataAttr(isSelected),
className: slots.startIcon({class: classNames?.startIcon}),
...props,
className: slots.startIcon({class: clsx(classNames?.startIcon, props?.className)}),
}),
[slots, classNames?.startIcon, isSelected],
);
const getEndIconProps = useCallback(
() => ({
const getEndIconProps = useCallback<PropGetter>(
(props = {}) => ({
width: "1em",
height: "1em",
"data-checked": dataAttr(isSelected),
className: slots.endIcon({class: classNames?.endIcon}),
...props,
className: slots.endIcon({class: clsx(classNames?.endIcon, props?.className)}),
}),
[slots, classNames?.endIcon, isSelected],
);
@ -280,6 +290,7 @@ export function useSwitch(originalProps: UseSwitchProps) {
endIcon,
isHovered,
isSelected,
isPressed: pressed,
isFocused,
isFocusVisible,
isDisabled,

View File

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