diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index 981981c3..0e9faf71 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -3,10 +3,10 @@ import styled from '@xstyled/styled-components'; import { forwardRef, useCallback, useId, useMemo } from 'react'; import type { CheckboxProps } from './Checkbox.props'; -import { useIndeterminate } from './hooks'; import { stylesBuilder } from './stylesBuilder'; import { HelperText } from '../HelperText'; +import { useIndeterminate } from '@/hooks'; import { extractInputProps } from '@/services'; import { tet } from '@/tetrisly'; import { MarginProps } from '@/types/MarginProps'; diff --git a/src/components/Checkbox/hooks/index.ts b/src/components/Checkbox/hooks/index.ts deleted file mode 100644 index 496ac61c..00000000 --- a/src/components/Checkbox/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useIndeterminate } from './useIndeterminate'; diff --git a/src/components/Checkbox/hooks/useIconChecked.ts b/src/components/Checkbox/hooks/useIconChecked.ts deleted file mode 100644 index 24ed28a1..00000000 --- a/src/components/Checkbox/hooks/useIconChecked.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useIconChecked = (isChecked: boolean | undefined) => { - const [isIconChecked, setIsIconChecked] = useState(isChecked); - - useEffect(() => { - setIsIconChecked(isChecked); - }, [isChecked]); - - return [isIconChecked, setIsIconChecked] as const; -}; diff --git a/src/components/Toggle/Toggle.props.ts b/src/components/Toggle/Toggle.props.ts new file mode 100644 index 00000000..e9a2c930 --- /dev/null +++ b/src/components/Toggle/Toggle.props.ts @@ -0,0 +1,22 @@ +import { InputHTMLAttributes } from 'react'; + +import type { ToggleConfig } from './Toggle.styles'; +import { HelperTextProps } from '../HelperText'; + +export type ToggleProps = { + isIndeterminate?: boolean; + isChecked?: boolean; + size?: 'small' | 'large'; + state?: 'disabled'; + custom?: ToggleConfig; +} & Omit< + InputHTMLAttributes, + 'checked' | 'disabled' | 'color' | 'type' | 'size' +> & + ( + | { label?: string; helperText?: never } + | { + label: string; + helperText?: Pick; + } + ); diff --git a/src/components/Toggle/Toggle.stories.tsx b/src/components/Toggle/Toggle.stories.tsx new file mode 100644 index 00000000..0cd99742 --- /dev/null +++ b/src/components/Toggle/Toggle.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { Toggle } from './Toggle'; + +import { TetDocs } from '@/docs-components/TetDocs'; +import { ToggleDocs } from '@/docs-components/ToggleDocs.tsx'; +import { tet } from '@/tetrisly'; + +const meta = { + title: 'Toggle', + component: Toggle, + tags: ['autodocs'], + argTypes: { + state: { + control: { + type: 'select', + options: [undefined, 'disabled'], + }, + }, + }, + parameters: { + docs: { + description: { + component: + 'A visual representation of the switch that allows the user to choose between two states, such as on and off or enable and disable. Toggles are often used in forms or settings to represent binary options and provide clear visual feedback of the active state.', + }, + page: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Checked: Story = { + args: { + isChecked: true, + }, +}; + +export const Disabled: Story = { + args: { + state: 'disabled', + }, +}; + +export const Indeterminate = () => { + const [mainChecked, setMainChecked] = useState(false); + const [toggle1Value, setToggle1Value] = useState(true); + const [toggle2Value, setToggle2Value] = useState(false); + const isInitialRender = useRef(true); + + useLayoutEffect(() => { + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + setToggle1Value(mainChecked); + setToggle2Value(mainChecked); + }, [mainChecked]); + + return ( + + setMainChecked((prevValue) => !prevValue)} + label="Main label" + /> + setToggle1Value((prevValue) => !prevValue)} + label="Label 1" + /> + setToggle2Value((prevValue) => !prevValue)} + label="Label 2" + /> + + ); +}; + +export const Label: Story = { + args: { + label: 'Label', + }, +}; + +export const HelperText: Story = { + args: { + label: 'Label', + helperText: { text: 'Helper text' }, + }, +}; diff --git a/src/components/Toggle/Toggle.styles.ts b/src/components/Toggle/Toggle.styles.ts new file mode 100644 index 00000000..f0c84262 --- /dev/null +++ b/src/components/Toggle/Toggle.styles.ts @@ -0,0 +1,158 @@ +import { SystemProps } from '@xstyled/styled-components'; + +import { HelperTextConfig } from '../HelperText/HelperText.styles'; + +import { BaseProps } from '@/types/BaseProps'; + +type ToggleSize = { size?: Record<'small' | 'large', BaseProps> }; +export type ToggleConfig = { + innerElements?: { + toggle?: { + input?: SystemProps; + slider?: BaseProps & ToggleSize; + toggleOval?: BaseProps & ToggleSize; + }; + labelContainer?: BaseProps; + label?: BaseProps; + helperText?: HelperTextConfig; + }; +} & BaseProps; + +export const defaultConfig = { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '$space-component-gap-xSmall', + opacity: { + _: 1, + disabled: 0.5, + }, + innerElements: { + toggle: { + toggleOval: { + size: { + large: { + w: '36px', + h: '20px', + }, + small: { + w: '28px', + h: '16px', + }, + }, + p: '$space-component-padding-2xSmall', + backgroundColor: { + _: '$color-interaction-disabled-normal', + hover: '$color-interaction-disabled-hover', + focus: '$color-interaction-disabled-focus', + active: '$color-interaction-disabled-active', + disabled: '$color-interaction-disabled-normal', + selected: { + _: '$color-interaction-default-normal', + hover: '$color-interaction-default-hover', + focus: '$color-interaction-default-focus', + active: '$color-interaction-default-active', + disabled: '$color-interaction-default-normal', + }, + indeterminate: { + _: '$color-interaction-default-normal', + hover: '$color-interaction-default-hover', + focus: '$color-interaction-default-focus', + active: '$color-interaction-default-active', + disabled: '$color-interaction-default-normal', + }, + }, + transition: '0.2s', + borderRadius: '100px', + display: 'flex', + position: 'relative', + alignItems: 'center', + outlineColor: { + focusWithin: '$color-interaction-focus-default', + }, + outlineWidth: { + focusWithin: '$border-width-focus', + }, + outlineStyle: { + focusWithin: 'solid', + }, + outlineOffset: { + focusWithin: '$border-width-small', + }, + }, + slider: { + size: { + large: { + w: { + _: '16px', + indeterminate: '15px', + }, + h: { + _: '16px', + indeterminate: '1.5px', + }, + transform: { + selected: 'translateX(16px)', + indeterminate: 'translateX(8px)', + }, + }, + small: { + w: { + _: '12px', + indeterminate: '10px', + }, + h: { + _: '12px', + indeterminate: '1.5px', + }, + transform: { + selected: 'translateX(12px)', + indeterminate: 'translateX(7px)', + }, + }, + }, + transition: 'transform 0.2s ease-in-out', + backgroundColor: '$color-whiteA-0', + borderRadius: '$border-radius-full', + borderWidth: '$border-width-small', + borderStyle: '$border-style-solid', + borderColor: '$color-border-defaultA', + boxShadow: '$elevation-bottom-100', + position: 'absolute', + }, + input: { + borderRadius: '100px', + w: '100%', + h: '100%', + appearance: 'none', + zIndex: 1, + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + }, + labelContainer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '$space-component-gap-medium', + text: '$typo-body-medium', + color: '$color-content-primary', + }, + label: { + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + helperText: { + paddingLeft: '$space-component-padding-2xLarge', + cursor: 'default', + }, + }, +} satisfies ToggleConfig; + +export const toggleStyles = { + defaultConfig, +}; diff --git a/src/components/Toggle/Toggle.test.tsx b/src/components/Toggle/Toggle.test.tsx new file mode 100644 index 00000000..ea58cb99 --- /dev/null +++ b/src/components/Toggle/Toggle.test.tsx @@ -0,0 +1,108 @@ +import { vi } from 'vitest'; + +import { fireEvent, render } from '../../tests/render'; + +import { Toggle } from '@/components/Toggle/Toggle.tsx'; +import { customPropTester } from '@/tests/customPropTester'; + +const handleEventMock = vi.fn(); + +const getToggle = (jsx: JSX.Element) => { + const { getByTestId, queryByTestId } = render(jsx); + + return { + toggle: getByTestId('toggle'), + toggleOval: getByTestId('toggle-oval'), + input: getByTestId('toggle-input'), + label: queryByTestId('toggle-label'), + labelContainer: getByTestId('toggle-label-container'), + }; +}; + +describe('Toggle', () => { + customPropTester(, { + containerId: 'toggle', + }); + + beforeEach(() => { + handleEventMock.mockReset(); + }); + + it('should render the toggle', () => { + const { toggle } = getToggle(); + expect(toggle).toBeInTheDocument(); + }); + + it('should render the correct label', () => { + const { label } = getToggle(); + expect(label).toHaveTextContent('label'); + }); + + it('should render the correct helper text', () => { + const { toggle } = getToggle( + , + ); + expect(toggle).toHaveTextContent('helper text'); + }); + + it('should emit onChange', () => { + const { input } = getToggle(); + fireEvent.click(input); + expect(handleEventMock).toHaveBeenCalled(); + }); + + it('should not emit onChange when disable', () => { + const { input } = getToggle( + , + ); + fireEvent.click(input); + expect(handleEventMock).not.toHaveBeenCalled(); + }); + + it('should render the correct color when unchecked and disabled', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('background-color: rgba(158, 168, 179, 1);'); + }); + + it('should render the correct color when checked and disabled', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('background-color: rgba(48, 98, 212, 1);'); + }); + + it('should render the right cursor on disabled toggleOval', () => { + const { input } = getToggle(); + expect(input).toHaveStyle('cursor: default'); + }); + + it('should render the right cursor on disabled label', () => { + const { label } = getToggle(); + expect(label).toHaveStyle('cursor: default'); + }); + + it('should propagate custom props', () => { + const { toggleOval } = getToggle( + , + ); + expect(toggleOval).toHaveStyle('background-color: rgb(254, 245, 245)'); + }); + + it('should render small size toggle', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('width: 28px'); + }); + + it('should render large size toggle', () => { + const { toggleOval } = getToggle(); + expect(toggleOval).toHaveStyle('width: 36px'); + }); +}); diff --git a/src/components/Toggle/Toggle.tsx b/src/components/Toggle/Toggle.tsx new file mode 100644 index 00000000..e8e9585f --- /dev/null +++ b/src/components/Toggle/Toggle.tsx @@ -0,0 +1,96 @@ +import { ChangeEventHandler, FC, useCallback, useId, useMemo } from 'react'; + +import { stylesBuilder } from './stylesBuilder'; +import { ToggleProps } from './Toggle.props'; + +import { HelperText } from '@/components/HelperText'; +import { useIndeterminate } from '@/hooks'; +import { tet } from '@/tetrisly'; +import { MarginProps } from '@/types/MarginProps'; + +export const Toggle: FC = ({ + isChecked = false, + helperText, + label, + state, + isIndeterminate = false, + size = 'small', + custom, + onChange, + ...restProps +}) => { + const styles = useMemo(() => stylesBuilder(size, custom), [custom, size]); + const toggleId = useId(); + + const disabled = state === 'disabled'; + const indeterminate = !isChecked && isIndeterminate; + + const inputRef = useIndeterminate(indeterminate); + + const handleToggle: ChangeEventHandler = useCallback( + (e) => { + if (state !== 'disabled') { + onChange?.(e); + } + }, + [onChange, state], + ); + + return ( + + + + + + + {label ? ( + + {label} + + ) : null} + + {!!helperText && ( + + )} + + ); +}; diff --git a/src/components/Toggle/index.ts b/src/components/Toggle/index.ts new file mode 100644 index 00000000..75b0a4dc --- /dev/null +++ b/src/components/Toggle/index.ts @@ -0,0 +1,3 @@ +export { Toggle } from './Toggle.tsx'; +export type { ToggleProps } from './Toggle.props'; +export { toggleStyles } from './Toggle.styles'; diff --git a/src/components/Toggle/stylesBuilder.ts b/src/components/Toggle/stylesBuilder.ts new file mode 100644 index 00000000..9de6eb5c --- /dev/null +++ b/src/components/Toggle/stylesBuilder.ts @@ -0,0 +1,43 @@ +import { ToggleConfig, defaultConfig } from './Toggle.styles.ts'; + +import { HelperTextConfig } from '@/components/HelperText/HelperText.styles.ts'; +import { mergeConfigWithCustom } from '@/services'; +import { BaseProps } from '@/types/BaseProps.ts'; + +type ToggleStylesBuilder = { + container: BaseProps; + toggleOval: BaseProps; + slider: BaseProps; + input: BaseProps; + label: BaseProps; + labelContainer: BaseProps; + helperText: HelperTextConfig; +}; + +export const stylesBuilder = ( + size: 'small' | 'large', + custom?: ToggleConfig, +): ToggleStylesBuilder => { + const { + innerElements: { + toggle: { + input, + slider: { size: sliderSize, ...restSlider }, + toggleOval: { size: toggleOvalSize, ...restToggleOval }, + }, + labelContainer, + label, + helperText, + }, + ...container + } = mergeConfigWithCustom({ defaultConfig, custom }); + return { + container, + input, + slider: { ...sliderSize[size], ...restSlider }, + toggleOval: { ...toggleOvalSize[size], ...restToggleOval }, + labelContainer, + label, + helperText, + }; +}; diff --git a/src/docs-components/ToggleDocs.tsx b/src/docs-components/ToggleDocs.tsx new file mode 100644 index 00000000..d4b9b874 --- /dev/null +++ b/src/docs-components/ToggleDocs.tsx @@ -0,0 +1,154 @@ +import type { FC } from 'react'; + +import { SectionHeader } from './common/SectionHeader'; +import { States } from './common/States'; + +import { Toggle } from '@/components/Toggle'; +import { tet } from '@/tetrisly'; + +const getYesNo = (yes: boolean) => (yes ? 'Yes' : 'No'); + +const getLabels = (label: boolean, helperText: boolean) => [ + `Label: ${getYesNo(label)}`, + `Helper Text: ${getYesNo(helperText)}`, +]; + +export const ToggleDocs: FC = () => ( + + {['Unchecked', 'Checked', 'Indeterminate'].map((state) => ( + + + {state} + + + {[ + { label: false, helperText: false }, + { label: true, helperText: false }, + { label: true, helperText: true }, + ].map(({ label, helperText }) => ( + + + + + + + {label ? ( + + ) : ( + + )} + {label ? ( + + ) : ( + + )} + + + + + {label ? ( + + ) : ( + + )} + {label ? ( + + ) : ( + + )} + + + + + ))} + + ))} + +); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 04511efc..cb10d11d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export { useAction } from './useAction'; +export { useIndeterminate } from './useIndeterminate.ts'; diff --git a/src/components/Checkbox/hooks/useIndeterminate.ts b/src/hooks/useIndeterminate.ts similarity index 100% rename from src/components/Checkbox/hooks/useIndeterminate.ts rename to src/hooks/useIndeterminate.ts diff --git a/src/index.ts b/src/index.ts index e61dc8fa..f7258878 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export * from './components/Tag'; export * from './components/TagInput'; export * from './components/TextInput'; export * from './components/Toast'; +export * from './components/Toggle'; export * from './components/Tooltip'; export * from './tetrisly'; export * from './theme'; diff --git a/src/theme/theme.ts b/src/theme/theme.ts index e512203a..f40ad3da 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -1352,7 +1352,7 @@ const fixedTokens = { hoverWithoutButton: '&:hover:not(:has(button:hover), &:invalid, &[data-state="alert"])', alert: '&:invalid, &[data-state="alert"]', - indeterminate: '&:indeterminate', + indeterminate: '&:indeterminate, &[data-indeterminate="indeterminate"]', }, // TO DO: utility for xstyled to gather color of shadow and generate rgba based on opacity shadows: {