diff --git a/packages/components/switch/src/use-switch.ts b/packages/components/switch/src/use-switch.ts index f92027364..f1b3d40f1 100644 --- a/packages/components/switch/src/use-switch.ts +++ b/packages/components/switch/src/use-switch.ts @@ -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 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( + (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( + (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, diff --git a/packages/core/theme/src/components/toggle.ts b/packages/core/theme/src/components/toggle.ts index eecc9df0d..5fc04d180 100644 --- a/packages/core/theme/src/components/toggle.ts +++ b/packages/core/theme/src/components/toggle.ts @@ -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 - *