From a3ca1e8ed87a855e51f813316ebf5820bdb491b3 Mon Sep 17 00:00:00 2001 From: Junior Garcia Date: Tue, 14 Mar 2023 21:40:22 -0300 Subject: [PATCH] feat(switch): component created --- .../avatar/stories/avatar-group.stories.tsx | 28 +- .../components/checkbox/src/use-checkbox.ts | 20 +- .../checkbox/stories/checkbox.stories.tsx | 36 ++- packages/components/radio/src/use-radio.ts | 17 +- .../radio/stories/radio.stories.tsx | 40 ++- .../switch/__tests__/switch.test.tsx | 187 +++++++++++- packages/components/switch/package.json | 14 +- packages/components/switch/src/index.ts | 1 + packages/components/switch/src/switch.tsx | 43 ++- packages/components/switch/src/use-switch.ts | 274 +++++++++++++++++- .../switch/stories/switch.stories.tsx | 158 +++++++++- packages/core/theme/src/colors/semantic.ts | 16 +- packages/core/theme/src/components/button.ts | 14 +- .../core/theme/src/components/checkbox.ts | 4 +- .../core/theme/src/components/pagination.ts | 5 +- packages/core/theme/src/components/toggle.ts | 171 ++++++++++- packages/core/theme/src/utilities/index.ts | 5 + packages/core/theme/src/utils/variants.ts | 2 +- packages/storybook/.storybook/main.js | 1 + .../storybook/.storybook/welcome.stories.mdx | 2 +- .../utilities/shared-icons/src/headphones.tsx | 27 ++ packages/utilities/shared-icons/src/index.ts | 6 + packages/utilities/shared-icons/src/mail.tsx | 24 ++ .../shared-icons/src/moon-filled.tsx | 18 ++ packages/utilities/shared-icons/src/moon.tsx | 22 ++ .../utilities/shared-icons/src/sun-filled.tsx | 18 ++ packages/utilities/shared-icons/src/sun.tsx | 32 ++ plop/component/src/{{componentName}}.tsx.hbs | 5 +- .../stories/{{componentName}}.stories.tsx.hbs | 2 +- pnpm-lock.yaml | 28 ++ 30 files changed, 1094 insertions(+), 126 deletions(-) create mode 100644 packages/utilities/shared-icons/src/headphones.tsx create mode 100644 packages/utilities/shared-icons/src/mail.tsx create mode 100644 packages/utilities/shared-icons/src/moon-filled.tsx create mode 100644 packages/utilities/shared-icons/src/moon.tsx create mode 100644 packages/utilities/shared-icons/src/sun-filled.tsx create mode 100644 packages/utilities/shared-icons/src/sun.tsx diff --git a/packages/components/avatar/stories/avatar-group.stories.tsx b/packages/components/avatar/stories/avatar-group.stories.tsx index 7e3f3ae01..48ace4f15 100644 --- a/packages/components/avatar/stories/avatar-group.stories.tsx +++ b/packages/components/avatar/stories/avatar-group.stories.tsx @@ -33,27 +33,17 @@ export default { }, } as ComponentMeta; -const pics = [ - "https://i.pravatar.cc/300?u=a042581f4e29026705d", - "https://i.pravatar.cc/300?u=a042581f4e29026706d", - "https://i.pravatar.cc/300?u=a042581f4e29026707d", - "https://i.pravatar.cc/300?u=a042581f4e29026709d", - "https://i.pravatar.cc/300?u=a042581f4f29026709d", - "https://i.pravatar.cc/300?u=a042581f4e29026710d", - "https://i.pravatar.cc/300?u=a042581f4e29026711d", -]; - const Template: ComponentStory = (args: AvatarGroupProps) => ( - - - - - - - - - + + + + + + + + + ); diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 1f13793f2..78a9d6075 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -2,7 +2,7 @@ import type {CheckboxVariantProps, CheckboxSlots, SlotsToClasses} from "@nextui- import type {AriaCheckboxProps} from "@react-types/checkbox"; import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; -import {ReactNode, Ref, useCallback} from "react"; +import {ReactNode, Ref, useCallback, useId} from "react"; import {useMemo, useRef} from "react"; import {useToggleState} from "@react-stately/toggle"; import {checkbox} from "@nextui-org/theme"; @@ -79,6 +79,7 @@ export function useCheckbox(props: UseCheckboxProps) { name, isRequired = false, isReadOnly = false, + autoFocus = false, isSelected: isSelectedProp, size = groupContext?.size ?? "md", color = groupContext?.color ?? "primary", @@ -115,11 +116,19 @@ export function useCheckbox(props: UseCheckboxProps) { const inputRef = useRef(null); const domRef = useFocusableRef(ref as FocusableRef, 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, + autoFocus, defaultSelected, isSelected: isSelectedProp, isDisabled, @@ -127,14 +136,17 @@ export function useCheckbox(props: UseCheckboxProps) { isRequired, isReadOnly, validationState, - "aria-label": otherProps["aria-label"], - "aria-labelledby": otherProps["aria-labelledby"], + "aria-label": ariaLabel, + "aria-labelledby": otherProps["aria-labelledby"] || labelId, onChange, }; }, [ value, name, + inputId, + labelId, children, + autoFocus, isIndeterminate, isDisabled, isSelectedProp, @@ -223,6 +235,8 @@ 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), diff --git a/packages/components/checkbox/stories/checkbox.stories.tsx b/packages/components/checkbox/stories/checkbox.stories.tsx index a137765e5..09586367a 100644 --- a/packages/components/checkbox/stories/checkbox.stories.tsx +++ b/packages/components/checkbox/stories/checkbox.stories.tsx @@ -58,6 +58,24 @@ const defaultProps: CheckboxProps = { const Template: ComponentStory = (args: CheckboxProps) => ; +const ControlledTemplate: ComponentStory = (args: CheckboxProps) => { + const [selected, setSelected] = React.useState(true); + + React.useEffect(() => { + // eslint-disable-next-line no-console + console.log("Checkbox ", selected); + }, [selected]); + + return ( +
+ + Subscribe (controlled) + +

Selected: {selected ? "true" : "false"}

+
+ ); +}; + export const Default = Template.bind({}); Default.args = { ...defaultProps, @@ -111,21 +129,9 @@ DisableAnimation.args = { disableAnimation: true, }; -export const Controlled = () => { - const [selected, setSelected] = React.useState(true); - - React.useEffect(() => { - // eslint-disable-next-line no-console - console.log("Checkbox ", selected); - }, [selected]); - - return ( -
- - Subscribe (controlled) - -
- ); +export const Controlled = ControlledTemplate.bind({}); +Controlled.args = { + ...defaultProps, }; interface CustomCheckboxProps extends CheckboxProps { diff --git a/packages/components/radio/src/use-radio.ts b/packages/components/radio/src/use-radio.ts index 5aaef5860..97e2c34d6 100644 --- a/packages/components/radio/src/use-radio.ts +++ b/packages/components/radio/src/use-radio.ts @@ -1,7 +1,7 @@ import type {AriaRadioProps} from "@react-types/radio"; import type {RadioVariantProps, RadioSlots, SlotsToClasses} from "@nextui-org/theme"; -import {Ref, ReactNode, useCallback} from "react"; +import {Ref, ReactNode, useCallback, useId} from "react"; import {useMemo, useRef} from "react"; import {useFocusRing} from "@react-aria/focus"; import {useHover} from "@react-aria/interactions"; @@ -85,6 +85,11 @@ export function useRadio(props: UseRadioProps) { const domRef = useDOMRef(ref); const inputRef = useRef(null); + const labelId = useId(); + const inputGenId = useId(); + + const inputId = id || inputGenId; + const isDisabled = useMemo(() => !!isDisabledProp, [isDisabledProp]); const isRequired = useMemo(() => groupContext.isRequired ?? false, [groupContext.isRequired]); const isInvalid = useMemo( @@ -101,17 +106,17 @@ export function useRadio(props: UseRadioProps) { : undefined; return { + id: inputId, isDisabled, isRequired, "aria-label": ariaLabel, - "aria-labelledby": otherProps["aria-labelledby"] || ariaLabel, - "aria-describedby": otherProps["aria-describedby"] || ariaDescribedBy, + "aria-labelledby": otherProps["aria-labelledby"] || labelId, + "aria-describedby": ariaDescribedBy, }; - }, [isDisabled, isRequired]); + }, [labelId, inputId, isDisabled, isRequired]); const {inputProps} = useReactAriaRadio( { - id, value, children, ...groupContext, @@ -190,6 +195,8 @@ export function useRadio(props: UseRadioProps) { const getLabelProps: PropGetter = useCallback( (props = {}) => ({ ...props, + id: labelId, + htmlFor: inputId, "data-disabled": dataAttr(isDisabled), "data-checked": dataAttr(isSelected), "data-invalid": dataAttr(isInvalid), diff --git a/packages/components/radio/stories/radio.stories.tsx b/packages/components/radio/stories/radio.stories.tsx index 6b2e9b467..43688360e 100644 --- a/packages/components/radio/stories/radio.stories.tsx +++ b/packages/components/radio/stories/radio.stories.tsx @@ -107,6 +107,27 @@ const Template: ComponentStory = (args: RadioGroupProps) => { ); }; +const ControlledTemplate: ComponentStory = (args: RadioGroupProps) => { + const [selectedItem, setSelectedItem] = React.useState("london"); + + React.useEffect(() => { + // eslint-disable-next-line no-console + console.log("isSelected:", selectedItem); + }, [selectedItem]); + + return ( +
+ + Buenos Aires + Sydney + London + Tokyo + +

Selected: {selectedItem}

+
+ ); +}; + export const Default = Template.bind({}); Default.args = { ...defaultProps, @@ -156,22 +177,9 @@ DisableAnimation.args = { disableAnimation: true, }; -export const Controlled = () => { - const [isSelected, setIsSelected] = React.useState("london"); - - React.useEffect(() => { - // eslint-disable-next-line no-console - console.log("isSelected:", isSelected); - }, [isSelected]); - - return ( - - Buenos Aires - Sydney - London - Tokyo - - ); +export const Controlled = ControlledTemplate.bind({}); +Controlled.args = { + ...defaultProps, }; const CustomRadio = (props: RadioProps) => { diff --git a/packages/components/switch/__tests__/switch.test.tsx b/packages/components/switch/__tests__/switch.test.tsx index 51ffbdee3..2ca2092c0 100644 --- a/packages/components/switch/__tests__/switch.test.tsx +++ b/packages/components/switch/__tests__/switch.test.tsx @@ -1,11 +1,11 @@ import * as React from "react"; -import {render} from "@testing-library/react"; +import {act, render} from "@testing-library/react"; import {Switch} from "../src"; describe("Switch", () => { it("should render correctly", () => { - const wrapper = render(); + const wrapper = render(); expect(() => wrapper.unmount()).not.toThrow(); }); @@ -13,7 +13,188 @@ describe("Switch", () => { it("ref should be forwarded", () => { const ref = React.createRef(); - render(); + render(); expect(ref.current).not.toBeNull(); }); + + it("should check and uncheck", () => { + const {getByRole} = render(); + + const checkbox = getByRole("switch"); + + expect(checkbox).not.toBeChecked(); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).toBeChecked(); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).not.toBeChecked(); + }); + + it("should not check if disabled", () => { + const {getByRole} = render(); + + const checkbox = getByRole("switch"); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).not.toBeChecked(); + }); + + it("should be checked if defaultSelected", () => { + const {getByRole} = render(); + + const checkbox = getByRole("switch"); + + expect(checkbox).toBeChecked(); + }); + + it("should not check if readOnly", () => { + const {getByRole} = render(); + + const checkbox = getByRole("switch"); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).not.toBeChecked(); + }); + + it("should check and uncheck with controlled state", () => { + const ControlledSwitch = ({onChange}: any) => { + const [isSelected, setIsSelected] = React.useState(false); + + return ( + { + onChange?.(selected); + setIsSelected(selected); + }} + /> + ); + }; + + const onChange = jest.fn(); + + const {getByRole} = render(); + + const checkbox = getByRole("switch"); + + expect(checkbox).not.toBeChecked(); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).toBeChecked(); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + it("should render the thumbIcon", () => { + const wrapper = render( + } />, + ); + + expect(wrapper.getByTestId("thumb-icon")).toBeInTheDocument(); + }); + + it('should work with thumbIcon as "function"', () => { + const thumbIcon = jest.fn(() => ); + + const wrapper = render(); + + expect(thumbIcon).toHaveBeenCalled(); + expect(wrapper.getByTestId("thumb-icon")).toBeInTheDocument(); + }); + + it("should change the thumbIcon when clicked", () => { + const thumbIcon = jest.fn((props) => { + const {isSelected} = props; + + return isSelected ? ( + + ) : ( + + ); + }); + + const {getByRole, container} = render(); + + const checkbox = getByRole("switch"); + + expect(checkbox).not.toBeChecked(); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).toBeChecked(); + + expect(thumbIcon).toHaveBeenCalledWith( + expect.objectContaining({ + isSelected: true, + }), + ); + + const checkedThumbIcon = container.querySelector("[data-testid=checked-thumb-icon]"); + + expect(checkedThumbIcon).toBeInTheDocument(); + + act(() => { + checkbox.click(); + }); + + expect(checkbox).not.toBeChecked(); + + expect(thumbIcon).toHaveBeenCalledWith( + expect.objectContaining({ + isSelected: false, + }), + ); + + const uncheckedThumbIcon = container.querySelector("[data-testid=unchecked-thumb-icon]"); + + expect(uncheckedThumbIcon).toBeInTheDocument(); + }); + + it('should work with "leftIcon"', () => { + const wrapper = render( + } />, + ); + + expect(wrapper.getByTestId("left-icon")).toBeInTheDocument(); + }); + + it('should work with "rightIcon"', () => { + const wrapper = render( + } />, + ); + + expect(wrapper.getByTestId("right-icon")).toBeInTheDocument(); + }); + + it('should work with "leftIcon" and "rightIcon"', () => { + const wrapper = render( + } + rightIcon={} + />, + ); + + expect(wrapper.getByTestId("left-icon")).toBeInTheDocument(); + expect(wrapper.getByTestId("right-icon")).toBeInTheDocument(); + }); }); diff --git a/packages/components/switch/package.json b/packages/components/switch/package.json index 2e60fce7e..89971d264 100644 --- a/packages/components/switch/package.json +++ b/packages/components/switch/package.json @@ -37,14 +37,22 @@ "react": ">=18" }, "dependencies": { + "@nextui-org/dom-utils": "workspace:*", + "@nextui-org/shared-utils": "workspace:*", "@nextui-org/system": "workspace:*", "@nextui-org/theme": "workspace:*", - "@nextui-org/shared-utils": "workspace:*", - "@nextui-org/dom-utils": "workspace:*" + "@react-aria/focus": "^3.11.0", + "@react-aria/interactions": "^3.14.0", + "@react-aria/switch": "^3.4.0", + "@react-aria/utils": "^3.15.0", + "@react-aria/visually-hidden": "^3.7.0", + "@react-stately/toggle": "^3.5.0", + "@react-types/shared": "^3.17.0" }, "devDependencies": { "clean-package": "2.2.0", - "react": "^18.0.0" + "react": "^18.0.0", + "@nextui-org/shared-icons": "workspace:*" }, "tsup": { "clean": true, diff --git a/packages/components/switch/src/index.ts b/packages/components/switch/src/index.ts index 8c0ecb522..bc46d3fb3 100644 --- a/packages/components/switch/src/index.ts +++ b/packages/components/switch/src/index.ts @@ -2,6 +2,7 @@ import Switch from "./switch"; // export types export type {SwitchProps} from "./switch"; +export type {SwitchThumbIconProps} from "./use-switch"; // export hooks export {useSwitch} from "./use-switch"; diff --git a/packages/components/switch/src/switch.tsx b/packages/components/switch/src/switch.tsx index 272f3daa6..8694cd577 100644 --- a/packages/components/switch/src/switch.tsx +++ b/packages/components/switch/src/switch.tsx @@ -1,22 +1,51 @@ import {forwardRef} from "@nextui-org/system"; -import {__DEV__} from "@nextui-org/shared-utils"; +import {VisuallyHidden} from "@react-aria/visually-hidden"; +import {cloneElement, ReactElement} from "react"; import {UseSwitchProps, useSwitch} from "./use-switch"; export interface SwitchProps extends Omit {} const Switch = forwardRef((props, ref) => { - const {Component, domRef, children, styles, ...otherProps} = useSwitch({ref, ...props}); + const { + Component, + children, + leftIcon, + rightIcon, + thumbIcon, + getBaseProps, + getInputProps, + getWrapperProps, + getThumbProps, + getThumbIconProps, + getLabelProps, + getLeftIconProps, + getRightIconProps, + } = useSwitch({ref, ...props}); + + const clonedThumbIcon = + typeof thumbIcon === "function" + ? thumbIcon(getThumbIconProps({includeStateProps: true})) + : thumbIcon && cloneElement(thumbIcon as ReactElement, getThumbIconProps()); + + const clonedLeftIcon = leftIcon && cloneElement(leftIcon as ReactElement, getLeftIconProps()); + const clonedRightIcon = rightIcon && cloneElement(rightIcon as ReactElement, getRightIconProps()); return ( - - {children} + + + + + + {leftIcon && clonedLeftIcon} + {thumbIcon && clonedThumbIcon} + {rightIcon && clonedRightIcon} + + {children && {children}} ); }); -if (__DEV__) { - Switch.displayName = "NextUI.Switch"; -} +Switch.displayName = "NextUI.Switch"; export default Switch; diff --git a/packages/components/switch/src/use-switch.ts b/packages/components/switch/src/use-switch.ts index 88641ff16..3dcf13d75 100644 --- a/packages/components/switch/src/use-switch.ts +++ b/packages/components/switch/src/use-switch.ts @@ -1,37 +1,285 @@ -import type {ToggleVariantProps} from "@nextui-org/theme"; +import type {ToggleVariantProps, ToggleSlots, SlotsToClasses} from "@nextui-org/theme"; +import type {FocusableRef} from "@react-types/shared"; +import type {AriaSwitchProps} from "@react-aria/switch"; +import type {HTMLNextUIProps, PropGetter} from "@nextui-org/system"; -import {HTMLNextUIProps, mapPropsVariants} from "@nextui-org/system"; +import {ReactNode, Ref, useCallback, useId, useRef} from "react"; +import {mapPropsVariants} from "@nextui-org/system"; +import {useHover} from "@react-aria/interactions"; import {toggle} from "@nextui-org/theme"; -import {useDOMRef} from "@nextui-org/dom-utils"; -import {ReactRef} from "@nextui-org/shared-utils"; +import {mergeProps} from "@react-aria/utils"; +import {clsx, dataAttr} from "@nextui-org/shared-utils"; +import {useFocusableRef} from "@nextui-org/dom-utils"; +import {useSwitch as useReactAriaSwitch} from "@react-aria/switch"; import {useMemo} from "react"; +import {useToggleState} from "@react-stately/toggle"; +import {useFocusRing} from "@react-aria/focus"; -export interface UseSwitchProps extends HTMLNextUIProps<"div">, ToggleVariantProps { +export type SwitchThumbIconProps = { + width: string; + height: string; + "data-checked": string; + isSelected: boolean; + className: string; +}; +interface Props extends HTMLNextUIProps<"label"> { /** * Ref to the DOM node. */ - ref?: ReactRef; + ref?: Ref; + /** + * The label of the switch. + */ + children?: ReactNode; + /** + * Whether the switch is disabled. + * @default false + */ + isDisabled?: boolean; + /** + * The icon to be displayed inside the thumb. + */ + thumbIcon?: ReactNode | ((props: SwitchThumbIconProps) => ReactNode); + /** + * Left icon to be displayed inside the switch. + */ + leftIcon?: ReactNode; + /** + * Right icon to be displayed inside the switch. + */ + rightIcon?: ReactNode; + /** + * 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 + * + * ``` + */ + styles?: SlotsToClasses; } +export type UseSwitchProps = Omit & + Omit & + ToggleVariantProps; + export function useSwitch(originalProps: UseSwitchProps) { const [props, variantProps] = mapPropsVariants(originalProps, toggle.variantKeys); - const {ref, as, className, ...otherProps} = props; + const { + ref, + as, + name, + value = "", + isReadOnly = false, + autoFocus = false, + leftIcon, + rightIcon, + defaultSelected, + isSelected: isSelectedProp, + children, + thumbIcon, + className, + styles, + onChange, + ...otherProps + } = props; - const Component = as || "div"; + const Component = as || "label"; - const domRef = useDOMRef(ref); + const inputRef = useRef(null); + const domRef = useFocusableRef(ref as FocusableRef, inputRef); - const styles = useMemo( + const labelId = useId(); + const inputId = useId(); + + const ariaSwitchProps = useMemo(() => { + const ariaLabel = + otherProps["aria-label"] || typeof children === "string" ? (children as string) : undefined; + + return { + name, + value, + children, + autoFocus, + defaultSelected, + isSelected: isSelectedProp, + isDisabled: !!originalProps.isDisabled, + isReadOnly, + "aria-label": ariaLabel, + "aria-labelledby": otherProps["aria-labelledby"] || labelId, + onChange, + }; + }, [ + value, + name, + labelId, + children, + autoFocus, + isSelectedProp, + defaultSelected, + originalProps.isDisabled, + otherProps["aria-label"], + otherProps["aria-labelledby"], + onChange, + ]); + + const state = useToggleState(ariaSwitchProps); + const {inputProps} = useReactAriaSwitch(ariaSwitchProps, state, inputRef); + const {focusProps, isFocused, isFocusVisible} = useFocusRing({autoFocus: inputProps.autoFocus}); + const {hoverProps, isHovered} = useHover({ + isDisabled: inputProps.disabled, + }); + + const isSelected = inputProps.checked; + const isDisabled = inputProps.disabled; + + const slots = useMemo( () => toggle({ ...variantProps, - className, + isFocusVisible, }), - [...Object.values(variantProps), className], + [...Object.values(variantProps), isFocusVisible], ); - return {Component, styles, domRef, ...otherProps}; + const baseStyles = clsx(styles?.base, className); + + const getBaseProps: PropGetter = () => { + return { + ref: domRef, + className: slots.base({class: baseStyles}), + "data-disabled": dataAttr(isDisabled), + "data-checked": dataAttr(isSelected), + ...mergeProps(hoverProps, otherProps), + }; + }; + + 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: styles?.wrapper})), + }; + }, [ + slots, + styles?.wrapper, + isHovered, + isSelected, + isFocused, + isFocusVisible, + isDisabled, + inputProps.readOnly, + ]); + + const getInputProps: PropGetter = () => { + return { + ref: inputRef, + id: inputProps.id || inputId, + ...mergeProps(inputProps, focusProps), + }; + }; + + 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: styles?.thumb}), + }), + [slots, styles?.thumb, isSelected, isFocused, isFocusVisible, isDisabled, inputProps.readOnly], + ); + + const getLabelProps: PropGetter = useCallback( + () => ({ + id: labelId, + htmlFor: inputProps.id || inputId, + "data-disabled": dataAttr(isDisabled), + "data-checked": dataAttr(isSelected), + className: slots.label({class: styles?.label}), + }), + [slots, styles?.label, isDisabled, isSelected], + ); + + const getThumbIconProps = useCallback( + ( + props = { + includeStateProps: false, + }, + ) => + mergeProps( + { + width: "1em", + height: "1em", + "data-checked": dataAttr(isSelected), + className: slots.thumbIcon({class: styles?.thumbIcon}), + }, + props.includeStateProps + ? { + isSelected: isSelected, + } + : {}, + ) as unknown as SwitchThumbIconProps, + [slots, styles?.thumbIcon, isSelected, originalProps.disableAnimation], + ); + + const getLeftIconProps = useCallback( + () => ({ + width: "1em", + height: "1em", + "data-checked": dataAttr(isSelected), + className: slots.leftIcon({class: styles?.leftIcon}), + }), + [slots, styles?.leftIcon, isSelected], + ); + + const getRightIconProps = useCallback( + () => ({ + width: "1em", + height: "1em", + "data-checked": dataAttr(isSelected), + className: slots.rightIcon({class: styles?.rightIcon}), + }), + [slots, styles?.rightIcon, isSelected], + ); + + return { + Component, + slots, + styles, + domRef, + children, + thumbIcon, + leftIcon, + rightIcon, + isHovered, + isSelected, + isFocused, + isFocusVisible, + isDisabled, + getBaseProps, + getWrapperProps, + getInputProps, + getLabelProps, + getThumbProps, + getThumbIconProps, + getLeftIconProps, + getRightIconProps, + }; } export type UseSwitchReturn = ReturnType; diff --git a/packages/components/switch/stories/switch.stories.tsx b/packages/components/switch/stories/switch.stories.tsx index 9df3b543a..830d24ba7 100644 --- a/packages/components/switch/stories/switch.stories.tsx +++ b/packages/components/switch/stories/switch.stories.tsx @@ -1,11 +1,14 @@ import React from "react"; import {ComponentStory, ComponentMeta} from "@storybook/react"; import {toggle} from "@nextui-org/theme"; +import {VisuallyHidden} from "@react-aria/visually-hidden"; +import {SunFilledIcon, MoonFilledIcon} from "@nextui-org/shared-icons"; +import {clsx} from "@nextui-org/shared-utils"; -import {Switch, SwitchProps} from "../src"; +import {Switch, SwitchProps, SwitchThumbIconProps, useSwitch} from "../src"; export default { - title: "Switch", + title: "Components/Switch", component: Switch, argTypes: { color: { @@ -14,12 +17,6 @@ export default { options: ["neutral", "primary", "secondary", "success", "warning", "danger"], }, }, - radius: { - control: { - type: "select", - options: ["none", "base", "sm", "md", "lg", "xl", "full"], - }, - }, size: { control: { type: "select", @@ -31,6 +28,11 @@ export default { type: "boolean", }, }, + disableAnimation: { + control: { + type: "boolean", + }, + }, }, } as ComponentMeta; @@ -40,7 +42,147 @@ const defaultProps = { const Template: ComponentStory = (args: SwitchProps) => ; +const WithIconsTemplate: ComponentStory = (args: SwitchProps) => { + const [isSelected, setIsSelected] = React.useState(true); + + return ( +
+ } + rightIcon={} + styles={{ + leftIcon: "text-white", + }} + onChange={setIsSelected} + /> +

Selected: {isSelected ? "true" : "false"}

+
+ ); +}; + +const ControlledTemplate: ComponentStory = (args: SwitchProps) => { + const [isSelected, setIsSelected] = React.useState(true); + + return ( +
+ +

Selected: {isSelected ? "true" : "false"}

+
+ ); +}; + +const CustomWithStylesTemplate: ComponentStory = (args: SwitchProps) => { + const [isSelected, setIsSelected] = React.useState(true); + + return ( +
+ +
+

Enable early access

+

+ Get access to new features before they are released. +

+
+
+

Selected: {isSelected ? "true" : "false"}

+
+ ); +}; + +const CustomWithHooksTemplate: ComponentStory = (args: SwitchProps) => { + const {Component, slots, isSelected, getBaseProps, getInputProps, getWrapperProps} = + useSwitch(args); + + return ( +
+ + + + +
+ {isSelected ? : } +
+
+

Lights: {isSelected ? "on" : "off"}

+
+ ); +}; + export const Default = Template.bind({}); Default.args = { ...defaultProps, }; + +export const IsReadOnly = Template.bind({}); +IsReadOnly.args = { + ...defaultProps, + isReadOnly: true, + defaultSelected: true, +}; + +export const WithLabel = Template.bind({}); +WithLabel.args = { + ...defaultProps, + children: "Bluetooth", +}; + +export const DisableAnimation = Template.bind({}); +DisableAnimation.args = { + ...defaultProps, + disableAnimation: true, +}; + +export const WithThumbIcon = Template.bind({}); +WithThumbIcon.args = { + ...defaultProps, + thumbIcon: (props: SwitchThumbIconProps) => + props.isSelected ? ( + + ) : ( + + ), +}; + +export const WithIcons = WithIconsTemplate.bind({}); +WithIcons.args = { + ...defaultProps, +}; + +export const Controlled = ControlledTemplate.bind({}); +Controlled.args = { + ...defaultProps, +}; + +export const CustomWithStyles = CustomWithStylesTemplate.bind({}); +CustomWithStyles.args = { + ...defaultProps, +}; + +export const CustomWithHooks = CustomWithHooksTemplate.bind({}); +CustomWithHooks.args = { + ...defaultProps, +}; diff --git a/packages/core/theme/src/colors/semantic.ts b/packages/core/theme/src/colors/semantic.ts index 6902c9010..c3bb45b78 100644 --- a/packages/core/theme/src/colors/semantic.ts +++ b/packages/core/theme/src/colors/semantic.ts @@ -20,19 +20,19 @@ const base: SemanticBaseColors = { }, content1: { DEFAULT: twColors.zinc[50], - contrastText: readableColor(twColors.zinc[50]), + contrastText: twColors.zinc[900], }, content2: { DEFAULT: twColors.zinc[100], - contrastText: readableColor(twColors.zinc[100]), + contrastText: twColors.zinc[800], }, content3: { DEFAULT: twColors.zinc[200], - contrastText: readableColor(twColors.zinc[200]), + contrastText: twColors.zinc[700], }, content4: { DEFAULT: twColors.zinc[300], - contrastText: readableColor(twColors.zinc[300]), + contrastText: twColors.zinc[600], }, }, dark: { @@ -47,19 +47,19 @@ const base: SemanticBaseColors = { }, content1: { DEFAULT: twColors.zinc[900], - contrastText: readableColor(twColors.zinc[900]), + contrastText: twColors.zinc[50], }, content2: { DEFAULT: twColors.zinc[800], - contrastText: readableColor(twColors.zinc[800]), + contrastText: twColors.zinc[100], }, content3: { DEFAULT: twColors.zinc[700], - contrastText: readableColor(twColors.zinc[700]), + contrastText: twColors.zinc[200], }, content4: { DEFAULT: twColors.zinc[600], - contrastText: readableColor(twColors.zinc[600]), + contrastText: twColors.zinc[300], }, }, }; diff --git a/packages/core/theme/src/components/button.ts b/packages/core/theme/src/components/button.ts index 1da6f033f..eadce9a89 100644 --- a/packages/core/theme/src/components/button.ts +++ b/packages/core/theme/src/components/button.ts @@ -77,8 +77,8 @@ const button = tv({ true: "[&:not(:first-child):not(:last-child)]:rounded-none", }, disableAnimation: { - false: "transition-transform", true: "!transition-none", + false: "transition-transform-background", }, }, defaultVariants: { @@ -220,32 +220,32 @@ const button = tv({ { variant: "light", color: "neutral", - class: colorVariants.light.neutral, + class: [colorVariants.light.neutral, "hover:!bg-neutral-100"], }, { variant: "light", color: "primary", - class: colorVariants.light.primary, + class: [colorVariants.light.primary, "hover:!bg-primary-50"], }, { variant: "light", color: "secondary", - class: colorVariants.light.secondary, + class: [colorVariants.light.secondary, "hover:!bg-secondary-100"], }, { variant: "light", color: "success", - class: colorVariants.light.success, + class: [colorVariants.light.success, "hover:!bg-success-50"], }, { variant: "light", color: "warning", - class: colorVariants.light.warning, + class: [colorVariants.light.warning, "hover:!bg-warning-50"], }, { variant: "light", color: "danger", - class: colorVariants.light.danger, + class: [colorVariants.light.danger, "hover:!bg-danger-50"], }, // ghost / color { diff --git a/packages/core/theme/src/components/checkbox.ts b/packages/core/theme/src/components/checkbox.ts index d9c2f5ee2..1b636638d 100644 --- a/packages/core/theme/src/components/checkbox.ts +++ b/packages/core/theme/src/components/checkbox.ts @@ -9,7 +9,7 @@ import {ringClasses} from "../utils"; * * @example *