diff --git a/apps/www/src/content/docs/components/switch/index.mdx b/apps/www/src/content/docs/components/switch/index.mdx index 8110505ff..d3f75b98c 100644 --- a/apps/www/src/content/docs/components/switch/index.mdx +++ b/apps/www/src/content/docs/components/switch/index.mdx @@ -37,13 +37,3 @@ The Switch component comes in two sizes: large (default) and small. Use the Switch component in a controlled manner to manage its state externally. - -## Accessibility - -The Switch component follows WAI-ARIA guidelines for toggle buttons: - -- Uses proper ARIA attributes (`aria-checked`, `aria-required`, `aria-label`) -- Supports keyboard navigation (Space and Enter to toggle) -- Includes proper labeling and description support -- Changes cursor to 'not-allowed' when disabled -- Associates labels with the switch using htmlFor diff --git a/apps/www/src/content/docs/components/switch/props.ts b/apps/www/src/content/docs/components/switch/props.ts index c4cc9d2be..baa6000c5 100644 --- a/apps/www/src/content/docs/components/switch/props.ts +++ b/apps/www/src/content/docs/components/switch/props.ts @@ -6,7 +6,7 @@ export interface SwitchProps { defaultChecked?: boolean; /** Event handler called when the checked state changes. */ - onCheckedChange?: (checked: boolean) => void; + onCheckedChange?: (checked: boolean, event: Event) => void; /** When true, prevents the user from interacting with the switch. */ disabled?: boolean; @@ -22,4 +22,28 @@ export interface SwitchProps { /** A unique identifier for the switch. */ id?: string; + + /** Identifies the field when a form is submitted. */ + name?: string; + + /** Additional CSS class names. */ + className?: string; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * Accepts a `ReactElement` or a function that returns the element to render. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | (( + props: React.HTMLAttributes, + state: { + checked: boolean; + disabled: boolean; + readOnly: boolean; + required: boolean; + } + ) => React.ReactElement); } diff --git a/packages/raystack/components/switch/__tests__/switch.test.tsx b/packages/raystack/components/switch/__tests__/switch.test.tsx index 7b008402c..51980567a 100644 --- a/packages/raystack/components/switch/__tests__/switch.test.tsx +++ b/packages/raystack/components/switch/__tests__/switch.test.tsx @@ -42,7 +42,7 @@ describe('Switch', () => { describe('Sizes', () => { const sizes = ['small', 'large'] as const; sizes.forEach(size => { - it(`renders ${size} size by default`, () => { + it(`renders ${size} size`, () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveClass(styles[size]); @@ -60,14 +60,14 @@ describe('Switch', () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-checked', 'false'); - expect(switchElement).toHaveAttribute('data-state', 'unchecked'); + expect(switchElement).toHaveAttribute('data-unchecked'); }); it('renders as checked when checked prop is true', () => { render(); const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-checked', 'true'); - expect(switchElement).toHaveAttribute('data-state', 'checked'); + expect(switchElement).toHaveAttribute('data-checked'); }); it('renders with defaultChecked', () => { @@ -92,8 +92,7 @@ describe('Switch', () => { it('renders as disabled when disabled prop is true', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); + expect(switchElement).toHaveAttribute('data-disabled'); }); it('does not toggle when disabled', () => { @@ -110,20 +109,19 @@ describe('Switch', () => { it('can be disabled while checked', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveAttribute('aria-checked', 'true'); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); }); it('maintains disabled state with different sizes', () => { const { rerender } = render(); let switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveClass(styles.small); rerender(); switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-disabled'); expect(switchElement).toHaveClass(styles.large); }); }); @@ -137,7 +135,7 @@ describe('Switch', () => { fireEvent.click(switchElement); expect(handleChange).toHaveBeenCalledTimes(1); - expect(handleChange).toHaveBeenCalledWith(true); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); it('toggles from checked to unchecked', () => { @@ -147,7 +145,7 @@ describe('Switch', () => { const switchElement = screen.getByRole('switch'); fireEvent.click(switchElement); - expect(handleChange).toHaveBeenCalledWith(false); + expect(handleChange).toHaveBeenCalledWith(false, expect.anything()); }); it('supports focus events', () => { @@ -169,14 +167,10 @@ describe('Switch', () => { render(); const switchElement = screen.getByRole('switch'); - await switchElement.focus(); + switchElement.focus(); await user.keyboard('[Space]'); - expect(handleChange).toHaveBeenCalledWith(true); - await user.keyboard('[Enter]'); - - expect(handleChange).toHaveBeenCalledWith(false); - expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChange).toHaveBeenCalledWith(true, expect.anything()); }); }); @@ -221,14 +215,14 @@ describe('Switch', () => { it('supports required attribute', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-required', 'true'); + expect(switchElement).toHaveAttribute('data-required'); }); it('works with required and disabled', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-required', 'true'); - expect(switchElement).toBeDisabled(); + expect(switchElement).toHaveAttribute('data-required'); + expect(switchElement).toHaveAttribute('data-disabled'); }); }); @@ -273,43 +267,31 @@ describe('Switch', () => { const switchElement = screen.getByRole('switch'); expect(switchElement).toHaveAttribute('aria-describedby', 'description'); }); - - it('supports aria-invalid', () => { - render(); - const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('aria-invalid', 'true'); - }); }); - describe('HTML Attributes', () => { - it('supports name attribute', () => { - render(); - const hiddenInput = document.querySelector('input[name="notifications"]'); - expect(hiddenInput).toHaveAttribute('name', 'notifications'); - }); - + describe('Data Attributes', () => { it('supports data attributes', () => { render(); const switchElement = screen.getByTestId('custom-switch'); expect(switchElement).toHaveAttribute('data-theme', 'dark'); }); - it('has data-state attribute for unchecked state', () => { + it('has data-unchecked attribute for unchecked state', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-state', 'unchecked'); + expect(switchElement).toHaveAttribute('data-unchecked'); }); - it('has data-state attribute for checked state', () => { + it('has data-checked attribute for checked state', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-state', 'checked'); + expect(switchElement).toHaveAttribute('data-checked'); }); it('has data-disabled attribute when disabled', () => { render(); const switchElement = screen.getByRole('switch'); - expect(switchElement).toHaveAttribute('data-disabled', 'true'); + expect(switchElement).toHaveAttribute('data-disabled'); }); it('does not have data-disabled when enabled', () => { diff --git a/packages/raystack/components/switch/switch.module.css b/packages/raystack/components/switch/switch.module.css index d6cce5ba9..9b1827901 100644 --- a/packages/raystack/components/switch/switch.module.css +++ b/packages/raystack/components/switch/switch.module.css @@ -21,24 +21,24 @@ height: 16px; } -.switch:not([data-disabled="true"]):hover { +.switch:not([data-disabled]):hover { background: var(--rs-color-background-neutral-secondary-hover); } -.switch[data-state="checked"] { +.switch[data-checked] { background: var(--rs-color-background-accent-emphasis); } -.switch[data-state="checked"]:not([data-disabled="true"]):hover { +.switch[data-checked]:not([data-disabled]):hover { background: var(--rs-color-background-accent-emphasis-hover); } -.switch[data-disabled="true"] { +.switch[data-disabled] { cursor: not-allowed; background: var(--rs-color-background-neutral-primary); } -.switch[data-disabled="true"][data-state="checked"] { +.switch[data-disabled][data-checked] { background: var(--rs-color-background-accent-primary); } @@ -60,11 +60,11 @@ height: 12px; } -.switch[data-state="checked"] .thumb { +.switch[data-checked] .thumb { transform: translateX(16px); } /* Small switch thumb positioning */ -.switch.small[data-state="checked"] .thumb { +.switch.small[data-checked] .thumb { transform: translateX(12px); } diff --git a/packages/raystack/components/switch/switch.tsx b/packages/raystack/components/switch/switch.tsx index bc0a40606..dcdc9ad52 100644 --- a/packages/raystack/components/switch/switch.tsx +++ b/packages/raystack/components/switch/switch.tsx @@ -1,6 +1,6 @@ -import { VariantProps, cva, cx } from 'class-variance-authority'; -import { Switch as SwitchPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Switch as SwitchPrimitive } from '@base-ui/react/switch'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { forwardRef } from 'react'; import styles from './switch.module.css'; @@ -17,52 +17,19 @@ const switchVariants = cva(styles.switch, { }); export interface SwitchProps - extends ComponentPropsWithoutRef, + extends SwitchPrimitive.Root.Props, VariantProps {} -export const Switch = forwardRef< - ElementRef, - SwitchProps ->( - ( - { className, disabled, required, size, name, value, ...props }, - forwardedRef - ) => ( - <> - - - - {name && ( - - )} - +export const Switch = forwardRef( + ({ className, size, ...props }, forwardedRef) => ( + + + ) ); -interface ThumbProps - extends ComponentPropsWithoutRef {} - -const SwitchThumb = forwardRef< - ElementRef, - ThumbProps ->(({ className, ...props }, ref) => ( - -)); - Switch.displayName = 'Switch';