diff --git a/src/components/atoms/checkbox/Checkbox.stories.tsx b/src/components/atoms/checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000..2645bc0f --- /dev/null +++ b/src/components/atoms/checkbox/Checkbox.stories.tsx @@ -0,0 +1,235 @@ +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { Checkbox } from './Checkbox'; + +/** + * ## Description + * Checkbox is a native checkbox-based atom for single binary choices. It supports controlled and uncontrolled state, + * indeterminate selection, validation messaging, and strict accessible-name requirements without making label text clickable. + * + * ## Dependencies + * Uses the `Text` atom internally to render visible label text, descriptions, and validation copy. + * + * ## Usage Guide + * Use `label` for plain text labels and `labelHtml` only when sanitized inline formatting is required. The visual control is the only toggle hit area; + * label, description, and error text are descriptive only. Pair `checked` with `onChange` for controlled usage, and use `indeterminate` for mixed-state presentation. + */ +const meta: Meta = { + title: 'Atoms/Checkbox', + component: Checkbox, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; + +export default meta; + +type Story = StoryObj; + +/** + * Shows the default checkbox configuration using the component defaults. + */ +export const Default: Story = { + args: { + label: 'Accept terms', + onChange: action('checkbox-change') + } +}; + +/** + * Shows the checked visual state. + */ +export const Checked: Story = { + args: { + label: 'Receive updates', + defaultChecked: true, + onChange: action('checked-change') + } +}; + +/** + * Shows the non-interactive disabled state. + */ +export const Disabled: Story = { + render: () => ( +
+ + +
+ ) +}; + +/** + * Shows the custom read-only behavior while preserving focusability. + */ +export const ReadOnly: Story = { + render: () => ( +
+ + +
+ ) +}; + +/** + * Shows invalid semantics without additional helper copy. + */ +export const Invalid: Story = { + args: { + label: 'Required acknowledgement', + invalid: true, + onChange: action('invalid-change') + } +}; + +/** + * Shows validation messaging, which also implies invalid semantics. + */ +export const WithErrorMessage: Story = { + args: { + label: 'Terms acknowledgement', + errorMessage: 'You must accept the terms before continuing.', + onChange: action('error-message-change') + } +}; + +/** + * Shows helper description text associated through aria-describedby. + */ +export const WithDescription: Story = { + args: { + label: 'Email me product updates', + description: 'We only send release notes and important service announcements.', + onChange: action('description-change') + } +}; + +/** + * Shows the mixed-state indicator used for partial selection. + */ +export const Indeterminate: Story = { + args: { + label: 'Select all rows', + checked: false, + indeterminate: true, + onChange: action('indeterminate-change') + } +}; + +/** + * Shows the size axis while preserving the control hit area. + */ +export const Sizes: Story = { + render: () => ( +
+ + + +
+ ) +}; + +/** + * Shows the supported visual variants without mixing additional state axes. + */ +export const Variants: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +/** + * Shows controlled state management through checked and onChange. + */ +export const Controlled: Story = { + render: () => { + const [checked, setChecked] = useState(false); + + return ( + { + action('controlled-change')(nextChecked, event); + setChecked(nextChecked); + }} + /> + ); + } +}; + +/** + * Shows a checkbox that relies on an aria-label instead of visible text. + */ +export const WithoutVisibleLabel: Story = { + args: { + ariaLabel: 'Select release candidate', + onChange: action('aria-label-change') + } +}; + +/** + * Shows sanitized inline label HTML rendered through the Text atom. + */ +export const WithLabelHtml: Story = { + args: { + labelHtml: 'I agree to the privacy policy.', + description: 'Inline formatting is allowed, but interactive HTML is removed.', + onChange: action('label-html-change') + } +}; + +/** + * Shows the checkbox on a dark surface when the light and dark presentation differs. + */ +export const DarkSurface: Story = { + render: () => ( +
+ +
+ ) +}; diff --git a/src/components/atoms/checkbox/Checkbox.test.tsx b/src/components/atoms/checkbox/Checkbox.test.tsx new file mode 100644 index 00000000..6a597369 --- /dev/null +++ b/src/components/atoms/checkbox/Checkbox.test.tsx @@ -0,0 +1,375 @@ +import { act, render, renderHook, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ChangeEvent, type ComponentProps, type KeyboardEvent, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('lucide-react/dynamic.js', () => ({ + // biome-ignore lint/style/useNamingConvention: must match library export name + DynamicIcon: ({ + name, + size, + className, + ...props + }: { name: string; size?: number; className?: string } & ComponentProps<'svg'>) => ( + + ) +})); + +import { Checkbox } from './Checkbox'; +import { useCheckbox } from './useCheckbox'; + +const createChangeEvent = (checked: boolean) => + ({ currentTarget: { checked }, preventDefault: vi.fn() }) as unknown as ChangeEvent; + +const createKeyboardEvent = (key: string) => + ({ key, preventDefault: vi.fn() }) as unknown as KeyboardEvent; + +describe('useCheckbox — logic', () => { + it('returns unchecked state by default', () => { + const { result } = renderHook(() => useCheckbox({ label: 'Accept terms' })); + + expect(result.current.checked).toBe(false); + expect(result.current.controlState).toBe('unchecked'); + expect(result.current.isInvalid).toBe(false); + }); + + it('updates uncontrolled state and emits the next checked value', () => { + const handleChange = vi.fn(); + const { result } = renderHook(() => useCheckbox({ label: 'Accept terms', onChange: handleChange })); + + act(() => { + result.current.inputProps.onChange?.(createChangeEvent(true)); + }); + + expect(result.current.checked).toBe(true); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange.mock.calls[0]?.[0]).toBe(true); + }); + + it('keeps checked controlled by the checked prop', () => { + const { result } = renderHook(() => useCheckbox({ label: 'Accept terms', checked: true, defaultChecked: false })); + + expect(result.current.checked).toBe(true); + expect(result.current.controlState).toBe('checked'); + }); + + it('sanitizes labelHtml with the strict inline profile', () => { + const { result } = renderHook(() => + useCheckbox({ + labelHtml: + 'Safe link ' + }) + ); + + expect(result.current.labelHtml).toBe('Safe link button '); + }); + + it('throws when no accessible name is available at runtime', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => renderHook(() => useCheckbox({} as never))).toThrow( + 'Checkbox requires an accessible name. Provide label, labelHtml, ariaLabel, aria-label, ariaLabelledBy, or aria-labelledby.' + ); + + consoleError.mockRestore(); + }); + + it('throws when label and labelHtml are both provided', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => + renderHook(() => useCheckbox({ label: 'Visible label', labelHtml: 'Visible label' } as never)) + ).toThrow('Checkbox accepts either label or labelHtml, but not both.'); + + consoleError.mockRestore(); + }); + + it.each([ + '', + ' ' + ])('throws when sanitized labelHtml loses all meaningful text and no fallback name exists for %s', (labelHtml) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => renderHook(() => useCheckbox({ labelHtml } as never))).toThrow( + 'Checkbox labelHtml must retain meaningful text after sanitization or be paired with ariaLabel/aria-labelledby.' + ); + + consoleError.mockRestore(); + }); + + it('marks the checkbox invalid when errorMessage is present and merges described-by ids in stable order', () => { + const { result } = renderHook(() => + useCheckbox({ + id: 'consent', + label: 'Email me updates', + description: 'Helper copy', + errorMessage: 'Required field', + ariaDescribedBy: 'external-description' + }) + ); + + expect(result.current.isInvalid).toBe(true); + expect(result.current.inputProps['aria-describedby']).toBe( + 'external-description consent-description consent-error' + ); + expect(result.current.inputProps['aria-invalid']).toBe(true); + }); + + it('merges visible and external labelled-by ids when both are provided', () => { + const { result } = renderHook(() => + useCheckbox({ id: 'consent', label: 'Accept terms', ariaLabelledBy: 'external-label' }) + ); + + expect(result.current.inputProps['aria-labelledby']).toBe('consent-label external-label'); + }); + + it('prevents read-only Space toggles in the keyboard handler', () => { + const event = createKeyboardEvent(' '); + const { result } = renderHook(() => useCheckbox({ label: 'Read only', readOnly: true })); + + act(() => { + result.current.inputProps.onKeyDown?.(event); + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('keeps peer-driven visual-state selectors wired on the input and control classes', () => { + const { result } = renderHook(() => useCheckbox({ label: 'Accept terms' })); + + expect(result.current.inputProps.className).toContain('peer'); + expect(result.current.controlClassName).toContain('peer-hover:bg-surface-raised-light'); + expect(result.current.controlClassName).toContain('peer-active:scale-[0.98]'); + expect(result.current.controlClassName).toContain('peer-focus-visible:shadow-glow-focus-light'); + }); + + it.each([ + ['default', 'peer-hover:bg-brand-light'], + ['primary', 'peer-hover:bg-brand-light'], + ['danger', 'peer-hover:bg-error-light'] + ] as const)('preserves the selected fill hover classes for %s variants', (variant, hoverClassName) => { + const { result } = renderHook(() => useCheckbox({ label: 'Accept terms', checked: true, variant })); + + expect(result.current.controlClassName).toContain(hoverClassName); + expect(result.current.controlClassName).not.toContain('peer-hover:bg-surface-raised-light'); + }); +}); + +describe('Checkbox — component behavior', () => { + it('renders a checkbox with an accessible name from the visible label', () => { + render(); + + expect(screen.getByRole('checkbox', { name: 'Accept terms' })).toBeInTheDocument(); + expect(screen.getByText('Accept terms').tagName).toBe('SPAN'); + }); + + it('renders sanitized labelHtml through Text without leaving interactive HTML behind', () => { + render( + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'I agree to the privacy policy details.' }); + + expect(checkbox).toBeInTheDocument(); + expect(screen.getByText('privacy policy').tagName).toBe('STRONG'); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('toggles when the control hit area is activated', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Receive updates' }); + await user.click(checkbox); + + expect(checkbox).toBeChecked(); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange.mock.calls[0]?.[0]).toBe(true); + expect(handleChange.mock.calls[0]?.[1]).toHaveProperty('type', 'change'); + expect(handleChange.mock.calls[0]?.[1]).toHaveProperty('target', checkbox); + }); + + it('does not toggle when the visible label text is clicked', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Email notifications' }); + await user.click(screen.getByText('Email notifications')); + + expect(checkbox).not.toBeChecked(); + }); + + it('does not toggle when labelHtml text is clicked', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Review the release notes.' }); + await user.click(screen.getByText('release notes')); + + expect(checkbox).not.toBeChecked(); + }); + + it('does not toggle when description text is clicked', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Product updates' }); + await user.click(screen.getByText('We only send release notes and security advisories.')); + + expect(checkbox).not.toBeChecked(); + }); + + it('does not toggle when error text is clicked', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' }); + await user.click(screen.getByText('You must accept the terms before continuing.')); + + expect(checkbox).not.toBeChecked(); + }); + + it('toggles with Space when focused', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Keyboard toggle' }); + checkbox.focus(); + await user.keyboard('[Space]'); + + expect(checkbox).toBeChecked(); + }); + + it('keeps readOnly focusable but blocks pointer and Space toggles', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Read-only checkbox' }); + + await user.click(checkbox); + expect(checkbox).not.toBeChecked(); + + checkbox.focus(); + await user.keyboard('[Space]'); + + expect(checkbox).toHaveFocus(); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAttribute('aria-readonly', 'true'); + expect(handleChange).not.toHaveBeenCalled(); + }); + + it('keeps indeterminate synchronized in aria and DOM state', () => { + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Select all rows' }); + + expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); + expect((checkbox as HTMLInputElement).indeterminate).toBe(true); + }); + + it('re-syncs indeterminate after native activation clears the DOM property', async () => { + const user = userEvent.setup(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Select all rows' }); + expect((checkbox as HTMLInputElement).indeterminate).toBe(true); + + await user.click(checkbox); + + expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); + expect((checkbox as HTMLInputElement).indeterminate).toBe(true); + }); + + it('maps ariaLabelledBy and ariaDescribedBy aliases to native input attributes', () => { + render( + <> + External checkbox label + External checkbox description + + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'External checkbox label' }); + + expect(checkbox).toHaveAttribute('aria-labelledby', 'external-label'); + expect(checkbox).toHaveAttribute('aria-describedby', 'external-description consent-description consent-error'); + }); + + it('makes errorMessage imply aria-invalid', () => { + render(); + + expect(screen.getByRole('checkbox', { name: 'Accept terms' })).toHaveAttribute('aria-invalid', 'true'); + }); + + it('forwards native form props and preserves native checked submission semantics', () => { + render( + <> +
+ + + ); + + const checkbox = screen.getByRole('checkbox', { name: 'Email me updates' }); + const form = document.getElementById('preferences-form') as HTMLFormElement; + const formData = new FormData(form); + + expect(checkbox).toHaveAttribute('name', 'marketingEmails'); + expect(checkbox).toHaveAttribute('value', 'yes'); + expect(checkbox).toHaveAttribute('form', 'preferences-form'); + expect(checkbox).toBeRequired(); + expect(formData.get('marketingEmails')).toBe('yes'); + }); + + it('respects controlled state from the parent', async () => { + const user = userEvent.setup(); + + const ControlledCheckbox = () => { + const [checked, setChecked] = useState(false); + + return ; + }; + + render(); + + await user.click(screen.getByRole('checkbox', { name: 'Disabled' })); + + expect(screen.getByRole('checkbox', { name: 'Enabled' })).toBeChecked(); + }); + + it('does not toggle or call onChange when disabled', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + + render(); + + const checkbox = screen.getByRole('checkbox', { name: 'Disabled checkbox' }); + await user.click(checkbox); + + expect(checkbox).toBeDisabled(); + expect(checkbox).not.toBeChecked(); + expect(handleChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/atoms/checkbox/Checkbox.tsx b/src/components/atoms/checkbox/Checkbox.tsx new file mode 100644 index 00000000..9ca4f50d --- /dev/null +++ b/src/components/atoms/checkbox/Checkbox.tsx @@ -0,0 +1,90 @@ +import type { FC } from 'react'; +import { Icon } from '../icon'; +import { Text } from '../text'; +import type { CheckboxProps } from './types'; +import { useCheckbox } from './useCheckbox'; + +export const Checkbox: FC = (props) => { + const { + description, + descriptionClassName, + descriptionId, + errorClassName, + errorId, + errorMessage, + hasDescription, + hasErrorMessage, + hitAreaClassName, + controlClassName, + controlState, + indicatorClassName, + inputProps, + labelClassName, + labelHtml, + labelId, + labelText, + rootClassName + } = useCheckbox(props); + + return ( +
+
+ + + + {controlState === 'indeterminate' ? ( + + ) : ( + + + + )} + + + + + {labelText && ( + + {labelText} + + )} + {labelHtml && ( + + {labelHtml} + + )} + {hasDescription && ( + + {description} + + )} + +
+ + {hasErrorMessage && ( +
+ + + {errorMessage} + +
+ )} +
+ ); +}; diff --git a/src/components/atoms/checkbox/index.ts b/src/components/atoms/checkbox/index.ts new file mode 100644 index 00000000..12518b23 --- /dev/null +++ b/src/components/atoms/checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './Checkbox'; +export * from './types'; diff --git a/src/components/atoms/checkbox/types.ts b/src/components/atoms/checkbox/types.ts new file mode 100644 index 00000000..211b0117 --- /dev/null +++ b/src/components/atoms/checkbox/types.ts @@ -0,0 +1,331 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ChangeEvent, ComponentProps, ReactNode } from 'react'; + +export const checkboxRoot = cva('inline-flex items-start gap-2 text-text-light dark:text-text-dark', { + variants: { + disabled: { + true: 'opacity-40', + false: '' + } + }, + defaultVariants: { + disabled: false + } +}); + +export const checkboxHitArea = cva('relative inline-flex shrink-0 items-center justify-center', { + variants: { + disabled: { + true: 'pointer-events-none cursor-not-allowed', + false: 'cursor-pointer' + }, + readOnly: { + true: 'cursor-default', + false: '' + } + }, + compoundVariants: [ + { + disabled: false, + readOnly: true, + class: 'pointer-events-auto' + } + ], + defaultVariants: { + disabled: false, + readOnly: false + } +}); + +export const checkboxInput = cva( + 'peer absolute top-1/2 right-0 z-10 m-0 h-11 w-11 -translate-y-1/2 appearance-none rounded-xs opacity-0', + { + variants: { + disabled: { + true: 'cursor-not-allowed', + false: 'cursor-pointer' + }, + readOnly: { + true: 'cursor-default', + false: '' + } + }, + defaultVariants: { + disabled: false, + readOnly: false + } + } +); + +export const checkboxControl = cva( + [ + 'pointer-events-none relative inline-flex items-center justify-center border', + 'transition-[background-color,border-color,box-shadow,scale,color] duration-200 ease-out', + 'peer-hover:bg-surface-raised-light dark:peer-hover:bg-surface-raised-dark', + 'peer-active:scale-[0.98]', + 'peer-focus-visible:shadow-glow-focus-light dark:peer-focus-visible:shadow-glow-focus-dark' + ], + { + variants: { + size: { + sm: 'h-4 w-4 rounded-xs', + md: 'h-5 w-5 rounded-xs', + lg: 'h-6 w-6 rounded-xs' + }, + variant: { + default: '', + primary: '', + secondary: '', + danger: '' + }, + state: { + unchecked: 'border-border-strong-light bg-surface-light dark:border-border-strong-dark dark:bg-surface-dark', + checked: '', + indeterminate: '' + }, + invalid: { + true: '', + false: '' + } + }, + compoundVariants: [ + { + variant: 'default', + state: ['checked', 'indeterminate'], + invalid: false, + class: + 'border-brand-light bg-brand-light peer-hover:border-brand-light peer-hover:bg-brand-light dark:border-brand-dark dark:bg-brand-dark dark:peer-hover:border-brand-dark dark:peer-hover:bg-brand-dark' + }, + { + variant: 'primary', + state: ['checked', 'indeterminate'], + invalid: false, + class: + 'border-brand-light bg-brand-light shadow-glow-btn-primary-light peer-hover:border-brand-light peer-hover:bg-brand-light dark:border-brand-dark dark:bg-brand-dark dark:shadow-glow-btn-primary dark:peer-hover:border-brand-dark dark:peer-hover:bg-brand-dark' + }, + { + variant: 'secondary', + state: ['checked', 'indeterminate'], + invalid: false, + class: + 'border-border-strong-light bg-surface-raised-light text-brand-light dark:border-border-strong-dark dark:bg-surface-raised-dark dark:text-brand-dark-light' + }, + { + variant: 'danger', + state: ['checked', 'indeterminate'], + invalid: false, + class: + 'border-error-light bg-error-light peer-hover:border-error-light peer-hover:bg-error-light dark:border-error dark:bg-error dark:peer-hover:border-error dark:peer-hover:bg-error' + }, + { + invalid: true, + state: 'unchecked', + class: + 'border-error-light bg-surface-light shadow-glow-input-error-light dark:border-error dark:bg-surface-dark dark:shadow-glow-input-error' + }, + { + invalid: true, + state: ['checked', 'indeterminate'], + class: + 'border-error-light bg-error-light peer-hover:border-error-light peer-hover:bg-error-light dark:border-error dark:bg-error dark:peer-hover:border-error dark:peer-hover:bg-error' + } + ], + defaultVariants: { + size: 'md', + variant: 'default', + state: 'unchecked', + invalid: false + } + } +); + +export const checkboxIndicator = cva('pointer-events-none text-white transition-opacity duration-200 ease-out', { + variants: { + size: { + sm: 'h-2.5 w-2.5', + md: 'h-3 w-3', + lg: 'h-3.5 w-3.5' + }, + variant: { + default: 'text-white', + primary: 'text-white', + secondary: 'text-brand-light dark:text-brand-dark-light', + danger: 'text-white' + }, + invalid: { + true: 'text-white', + false: '' + } + }, + defaultVariants: { + size: 'md', + variant: 'default', + invalid: false + } +}); + +export const checkboxLabel = cva('select-none font-medium text-text-light dark:text-text-dark', { + variants: { + size: { + sm: 'fs-small', + md: 'fs-base', + lg: 'fs-h6' + }, + disabled: { + true: '', + false: '' + } + }, + defaultVariants: { + size: 'md', + disabled: false + } +}); + +export const checkboxDescription = cva('text-text-secondary-light dark:text-text-secondary-dark', { + variants: { + size: { + sm: 'fs-small', + md: 'fs-small', + lg: 'fs-base' + } + }, + defaultVariants: { + size: 'md' + } +}); + +export const checkboxError = cva('text-error-light dark:text-error', { + variants: { + size: { + sm: 'fs-small', + md: 'fs-small', + lg: 'fs-base' + } + }, + defaultVariants: { + size: 'md' + } +}); + +type CheckboxControlVariants = VariantProps; +type CheckboxRootVariants = VariantProps; + +type NativeCheckboxProps = Omit< + ComponentProps<'input'>, + | 'checked' + | 'children' + | 'className' + | 'defaultChecked' + | 'disabled' + | 'onChange' + | 'readOnly' + | 'size' + | 'type' + | 'aria-describedby' + | 'aria-label' + | 'aria-labelledby' +>; + +export type CheckboxSize = NonNullable; +export type CheckboxVariant = NonNullable; +export type CheckboxVisualState = NonNullable; + +type CheckboxVisibleLabelProps = + | { + /** @control text */ + label: string; + labelHtml?: never; + } + | { + label?: never; + /** @control text */ + labelHtml: string; + }; + +type CheckboxProgrammaticNameProps = + | { + label?: never; + labelHtml?: never; + /** @control text */ + ariaLabel: string; + 'aria-label'?: string; + ariaLabelledBy?: string | string[]; + 'aria-labelledby'?: string; + } + | { + label?: never; + labelHtml?: never; + ariaLabel?: string; + /** @control text */ + 'aria-label': string; + ariaLabelledBy?: string | string[]; + 'aria-labelledby'?: string; + } + | { + label?: never; + labelHtml?: never; + ariaLabel?: string; + 'aria-label'?: string; + /** @control text */ + ariaLabelledBy: string | string[]; + 'aria-labelledby'?: string; + } + | { + label?: never; + labelHtml?: never; + ariaLabel?: string; + 'aria-label'?: string; + ariaLabelledBy?: string | string[]; + /** @control text */ + 'aria-labelledby': string; + }; + +type CheckboxNameProps = CheckboxVisibleLabelProps | CheckboxProgrammaticNameProps; + +type CheckboxCommonProps = { + /** @control text */ + className?: string; + /** @control text */ + inputClassName?: string; + /** @control text */ + controlClassName?: string; + /** @control object */ + description?: ReactNode; + /** @control object */ + errorMessage?: ReactNode; + /** @control text */ + ariaLabel?: string; + /** @control text */ + 'aria-label'?: string; + /** @control text */ + ariaLabelledBy?: string | string[]; + /** @control text */ + 'aria-labelledby'?: string; + /** @control text */ + ariaDescribedBy?: string | string[]; + /** @control text */ + 'aria-describedby'?: string; + /** @control select */ + variant?: CheckboxVariant; + /** @control select */ + size?: CheckboxSize; + /** @control boolean */ + checked?: boolean; + /** @control boolean */ + defaultChecked?: boolean; + /** @control boolean */ + indeterminate?: boolean; + /** @control boolean */ + disabled?: boolean; + /** @control boolean */ + readOnly?: boolean; + /** @control boolean */ + invalid?: boolean; + /** @control action */ + onChange?: (checked: boolean, event: ChangeEvent) => void; +}; + +export type CheckboxProps = NativeCheckboxProps & CheckboxCommonProps & CheckboxNameProps; + +export type CheckboxRootState = NonNullable; diff --git a/src/components/atoms/checkbox/useCheckbox.ts b/src/components/atoms/checkbox/useCheckbox.ts new file mode 100644 index 00000000..1f4d2636 --- /dev/null +++ b/src/components/atoms/checkbox/useCheckbox.ts @@ -0,0 +1,226 @@ +import { getSanitizedTextContent, sanitizeInlineHtml } from '@utils/sanitizeHtml'; +import type { ChangeEvent, ComponentProps, KeyboardEvent, MouseEvent, ReactNode } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import type { CheckboxProps, CheckboxVisualState } from './types'; +import { + checkboxControl, + checkboxDescription, + checkboxError, + checkboxHitArea, + checkboxIndicator, + checkboxInput, + checkboxLabel, + checkboxRoot +} from './types'; + +type UseCheckboxReturn = { + checked: boolean; + controlClassName: string; + controlState: CheckboxVisualState; + description?: ReactNode; + descriptionClassName: string; + descriptionId?: string; + errorClassName: string; + errorId?: string; + errorMessage?: ReactNode; + hasDescription: boolean; + hasErrorMessage: boolean; + indicatorClassName: string; + inputProps: ComponentProps<'input'>; + isInvalid: boolean; + labelClassName: string; + labelHtml?: string; + labelId?: string; + labelText?: string; + rootClassName: string; + hitAreaClassName: string; +}; + +const formatAriaIds = (ids?: string | string[]) => { + if (Array.isArray(ids)) { + return ids.join(' '); + } + + return ids; +}; + +const hasRenderableContent = (value: ReactNode | undefined) => { + if (typeof value === 'string') { + return value.trim().length > 0; + } + + return value !== undefined && value !== null && value !== false; +}; + +const normalizePlainText = (value?: string) => value?.replace(/\s+/g, ' ').trim() ?? ''; + +const isSpaceKey = (key: KeyboardEvent['key']) => key === ' ' || key === 'Spacebar'; + +export const useCheckbox = ({ + className, + inputClassName, + controlClassName, + label, + labelHtml, + description, + errorMessage, + ariaLabel, + 'aria-label': nativeAriaLabel, + ariaLabelledBy, + 'aria-labelledby': nativeAriaLabelledBy, + ariaDescribedBy, + 'aria-describedby': nativeAriaDescribedBy, + checked: checkedProp, + defaultChecked = false, + indeterminate = false, + disabled = false, + readOnly = false, + invalid = false, + onChange, + onClick, + onFocus, + onBlur, + onKeyDown, + id, + variant = 'default', + size = 'md', + 'aria-invalid': nativeAriaInvalid, + ...inputProps +}: CheckboxProps): UseCheckboxReturn => { + if (label && labelHtml) { + throw new Error('Checkbox accepts either label or labelHtml, but not both.'); + } + + const generatedId = useId().replace(/:/g, ''); + const resolvedId = id ?? `checkbox-${generatedId}`; + const inputRef = useRef(null); + const [internalChecked, setInternalChecked] = useState(defaultChecked); + const [, setReadOnlyInteractionCount] = useState(0); + const isControlled = checkedProp !== undefined; + const checked = checkedProp ?? internalChecked; + + const plainLabelText = normalizePlainText(label); + const sanitizedLabelHtml = labelHtml ? sanitizeInlineHtml(labelHtml) : undefined; + const sanitizedLabelText = sanitizedLabelHtml ? getSanitizedTextContent(sanitizedLabelHtml) : ''; + const externalAriaLabel = ariaLabel ?? nativeAriaLabel; + const externalLabelledBy = formatAriaIds(ariaLabelledBy ?? nativeAriaLabelledBy); + + if (labelHtml && !sanitizedLabelText && !externalAriaLabel && !externalLabelledBy) { + throw new Error( + 'Checkbox labelHtml must retain meaningful text after sanitization or be paired with ariaLabel/aria-labelledby.' + ); + } + + if (!plainLabelText && !sanitizedLabelText && !externalAriaLabel && !externalLabelledBy) { + throw new Error( + 'Checkbox requires an accessible name. Provide label, labelHtml, ariaLabel, aria-label, ariaLabelledBy, or aria-labelledby.' + ); + } + + const shouldRenderPlainLabel = plainLabelText.length > 0; + const shouldRenderHtmlLabel = sanitizedLabelText.length > 0 && Boolean(sanitizedLabelHtml); + const hasDescription = hasRenderableContent(description); + const hasErrorMessage = hasRenderableContent(errorMessage); + const isInvalid = invalid || hasErrorMessage; + const descriptionId = hasDescription ? `${resolvedId}-description` : undefined; + const errorId = hasErrorMessage ? `${resolvedId}-error` : undefined; + const labelId = shouldRenderPlainLabel || shouldRenderHtmlLabel ? `${resolvedId}-label` : undefined; + const labelledBy = [labelId, externalLabelledBy].filter(Boolean).join(' ') || undefined; + const describedBy = + [formatAriaIds(ariaDescribedBy ?? nativeAriaDescribedBy), descriptionId, errorId].filter(Boolean).join(' ') || + undefined; + + useEffect(() => { + if (inputRef.current) { + inputRef.current.indeterminate = indeterminate; + } + }, [checked, indeterminate]); + + const handleBlockedToggle = (event: MouseEvent | KeyboardEvent) => { + if (readOnly) { + event.preventDefault(); + queueMicrotask(() => { + if (inputRef.current) { + inputRef.current.checked = checked; + inputRef.current.indeterminate = indeterminate; + } + }); + setReadOnlyInteractionCount((count) => count + 1); + } + }; + + const handleClick = (event: MouseEvent) => { + handleBlockedToggle(event); + onClick?.(event); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (readOnly && isSpaceKey(event.key)) { + handleBlockedToggle(event); + } + + onKeyDown?.(event); + }; + + const handleChange = (event: ChangeEvent) => { + if (disabled || readOnly) { + event.preventDefault(); + event.currentTarget.checked = checked; + return; + } + + const nextChecked = event.currentTarget.checked; + + if (!isControlled) { + setInternalChecked(nextChecked); + } + + onChange?.(nextChecked, event); + }; + + const controlState: CheckboxVisualState = indeterminate ? 'indeterminate' : checked ? 'checked' : 'unchecked'; + + return { + checked, + description, + controlClassName: cn(checkboxControl({ size, variant, state: controlState, invalid: isInvalid }), controlClassName), + controlState, + descriptionClassName: checkboxDescription({ size }), + descriptionId, + errorClassName: checkboxError({ size }), + errorId, + errorMessage, + hasDescription, + hasErrorMessage, + hitAreaClassName: checkboxHitArea({ disabled, readOnly }), + indicatorClassName: checkboxIndicator({ size, variant, invalid: isInvalid }), + inputProps: { + ...inputProps, + ref: inputRef, + id: resolvedId, + type: 'checkbox', + checked, + disabled, + readOnly, + 'aria-checked': indeterminate ? 'mixed' : checked, + 'aria-describedby': describedBy, + 'aria-invalid': isInvalid ? true : nativeAriaInvalid, + 'aria-label': externalAriaLabel, + 'aria-labelledby': labelledBy, + 'aria-readonly': readOnly ? true : undefined, + className: cn(checkboxInput({ disabled, readOnly }), inputClassName), + onBlur, + onChange: handleChange, + onClick: handleClick, + onFocus, + onKeyDown: handleKeyDown + }, + isInvalid, + labelClassName: checkboxLabel({ size, disabled }), + labelHtml: shouldRenderHtmlLabel ? sanitizedLabelHtml : undefined, + labelId, + labelText: shouldRenderPlainLabel ? plainLabelText : undefined, + rootClassName: cn(checkboxRoot({ disabled }), className) + }; +}; diff --git a/src/index.ts b/src/index.ts index 95e16845..9c1ecf35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { Avatar } from './components/atoms/avatar'; export { Badge } from './components/atoms/badge'; export { Button } from './components/atoms/button'; export { Calendar } from './components/atoms/calendar'; +export { Checkbox } from './components/atoms/checkbox'; export { Chip } from './components/atoms/chip'; export { Divider } from './components/atoms/divider'; export { Dropdown } from './components/atoms/dropdown'; diff --git a/src/utils/sanitizeHtml.test.ts b/src/utils/sanitizeHtml.test.ts new file mode 100644 index 00000000..f31b52bd --- /dev/null +++ b/src/utils/sanitizeHtml.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { getSanitizedTextContent, sanitizeHtml, sanitizeInlineHtml } from './sanitizeHtml'; + +describe('sanitizeHtml', () => { + it('preserves current general sanitization behavior for rich text', () => { + expect( + sanitizeHtml( + [ + 'Safe', + '', + 'link', + '', + '', + '', + '' + ].join('') + ) + ).toBe('Safelink
'); + }); +}); + +describe('sanitizeInlineHtml', () => { + it('keeps only strict inline non-interactive markup for checkbox labels', () => { + expect( + sanitizeInlineHtml( + [ + 'Safe', + 'link', + '', + 'preview', + 'focus', + '
block
', + '' + ].join(' ') + ) + ).toBe('Safe link button focus block '); + }); + + it('extracts normalized decoded text content from sanitized markup', () => { + expect(getSanitizedTextContent('Safe
content')).toBe('Safe content'); + expect(getSanitizedTextContent(' ')).toBe(''); + }); +}); diff --git a/src/utils/sanitizeHtml.ts b/src/utils/sanitizeHtml.ts index 9788ba76..3c3dcd24 100644 --- a/src/utils/sanitizeHtml.ts +++ b/src/utils/sanitizeHtml.ts @@ -2,6 +2,14 @@ const UNSAFE_URL_PATTERN = /^(javascript|data):/i; const UNSAFE_ELEMENTS = new Set(['embed', 'iframe', 'object']); const UNSAFE_ATTRIBUTES = new Set(['srcdoc']); const URL_ATTRIBUTES = new Set(['action', 'formaction', 'href', 'poster', 'src', 'xlink:href']); +const INLINE_ALLOWED_ELEMENTS = new Set(['span', 'strong', 'b', 'em', 'i', 'u', 'br']); +const INLINE_REMOVED_ELEMENTS = new Set(['embed', 'iframe', 'object', 'script', 'style', 'svg']); + +export type SanitizeHtmlProfile = 'default' | 'inline'; + +export type SanitizeHtmlOptions = { + profile?: SanitizeHtmlProfile; +}; const removeControlAndWhitespace = (value: string) => [...value] @@ -11,9 +19,21 @@ const removeControlAndWhitespace = (value: string) => }) .join(''); -export function sanitizeHtml(html: string): string { - const document = new DOMParser().parseFromString(html, 'text/html'); +const unwrapElement = (element: Element) => { + const parent = element.parentNode; + + if (!parent) { + return; + } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + + parent.removeChild(element); +}; + +const sanitizeDefaultHtml = (document: Document) => { document.querySelectorAll('script').forEach((script) => script.remove()); document.querySelectorAll('*').forEach((element) => { @@ -36,6 +56,48 @@ export function sanitizeHtml(html: string): string { } }); }); +}; + +const sanitizeInlineOnlyHtml = (document: Document) => { + const elements = [...document.body.querySelectorAll('*')].reverse(); + + elements.forEach((element) => { + const tagName = element.tagName.toLowerCase(); + + if (INLINE_REMOVED_ELEMENTS.has(tagName)) { + element.remove(); + return; + } + + if (!INLINE_ALLOWED_ELEMENTS.has(tagName)) { + unwrapElement(element); + return; + } + + [...element.attributes].forEach((attribute) => { + element.removeAttribute(attribute.name); + }); + }); +}; + +export function sanitizeHtml(html: string, options: SanitizeHtmlOptions = {}): string { + const document = new DOMParser().parseFromString(html, 'text/html'); + + if (options.profile === 'inline') { + sanitizeInlineOnlyHtml(document); + } else { + sanitizeDefaultHtml(document); + } return document.body.innerHTML; } + +export function sanitizeInlineHtml(html: string): string { + return sanitizeHtml(html, { profile: 'inline' }); +} + +export function getSanitizedTextContent(html: string): string { + const document = new DOMParser().parseFromString(html.replace(//gi, ' '), 'text/html'); + + return (document.body.textContent ?? '').replace(/\s+/g, ' ').trim(); +}