Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions apps/www/src/content/docs/components/switch/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Demo data={controlDemo} />

## 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
26 changes: 25 additions & 1 deletion apps/www/src/content/docs/components/switch/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLElement>,
state: {
checked: boolean;
disabled: boolean;
readOnly: boolean;
required: boolean;
}
) => React.ReactElement);
}
58 changes: 20 additions & 38 deletions packages/raystack/components/switch/__tests__/switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Switch size={size} />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveClass(styles[size]);
Expand All @@ -60,14 +60,14 @@ describe('Switch', () => {
render(<Switch />);
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(<Switch checked={true} />);
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', () => {
Expand All @@ -92,8 +92,7 @@ describe('Switch', () => {
it('renders as disabled when disabled prop is true', () => {
render(<Switch disabled />);
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', () => {
Expand All @@ -110,20 +109,19 @@ describe('Switch', () => {
it('can be disabled while checked', () => {
render(<Switch disabled checked />);
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(<Switch disabled size='small' />);
let switchElement = screen.getByRole('switch');
expect(switchElement).toBeDisabled();
expect(switchElement).toHaveAttribute('data-disabled');
expect(switchElement).toHaveClass(styles.small);

rerender(<Switch disabled size='large' />);
switchElement = screen.getByRole('switch');
expect(switchElement).toBeDisabled();
expect(switchElement).toHaveAttribute('data-disabled');
expect(switchElement).toHaveClass(styles.large);
});
});
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -169,14 +167,10 @@ describe('Switch', () => {
render(<Switch onCheckedChange={handleChange} />);

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());
});
});

Expand Down Expand Up @@ -221,14 +215,14 @@ describe('Switch', () => {
it('supports required attribute', () => {
render(<Switch required />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('aria-required', 'true');
expect(switchElement).toHaveAttribute('data-required');
});

it('works with required and disabled', () => {
render(<Switch required disabled />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('aria-required', 'true');
expect(switchElement).toBeDisabled();
expect(switchElement).toHaveAttribute('data-required');
expect(switchElement).toHaveAttribute('data-disabled');
});
});

Expand Down Expand Up @@ -273,43 +267,31 @@ describe('Switch', () => {
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('aria-describedby', 'description');
});

it('supports aria-invalid', () => {
render(<Switch aria-invalid='true' />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('aria-invalid', 'true');
});
});

describe('HTML Attributes', () => {
it('supports name attribute', () => {
render(<Switch name='notifications' />);
const hiddenInput = document.querySelector('input[name="notifications"]');
expect(hiddenInput).toHaveAttribute('name', 'notifications');
});

describe('Data Attributes', () => {
it('supports data attributes', () => {
render(<Switch data-testid='custom-switch' data-theme='dark' />);
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(<Switch />);
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(<Switch checked />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('data-state', 'checked');
expect(switchElement).toHaveAttribute('data-checked');
});

it('has data-disabled attribute when disabled', () => {
render(<Switch disabled />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toHaveAttribute('data-disabled', 'true');
expect(switchElement).toHaveAttribute('data-disabled');
});

it('does not have data-disabled when enabled', () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/raystack/components/switch/switch.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}
59 changes: 13 additions & 46 deletions packages/raystack/components/switch/switch.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -17,52 +17,19 @@ const switchVariants = cva(styles.switch, {
});

export interface SwitchProps
extends ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
extends SwitchPrimitive.Root.Props,
VariantProps<typeof switchVariants> {}

export const Switch = forwardRef<
ElementRef<typeof SwitchPrimitive.Root>,
SwitchProps
>(
(
{ className, disabled, required, size, name, value, ...props },
forwardedRef
) => (
<>
<SwitchPrimitive.Root
{...props}
ref={forwardedRef}
disabled={disabled}
required={required}
className={switchVariants({ size, className })}
data-disabled={disabled}
>
<SwitchThumb />
</SwitchPrimitive.Root>
{name && (
<input
type='hidden'
name={name}
value={value || 'on'}
disabled={disabled}
/>
)}
</>
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
({ className, size, ...props }, forwardedRef) => (
<SwitchPrimitive.Root
{...props}
ref={forwardedRef}
className={switchVariants({ size, className })}
>
<SwitchPrimitive.Thumb className={styles.thumb} />
</SwitchPrimitive.Root>
)
);

interface ThumbProps
extends ComponentPropsWithoutRef<typeof SwitchPrimitive.Thumb> {}

const SwitchThumb = forwardRef<
ElementRef<typeof SwitchPrimitive.Thumb>,
ThumbProps
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Thumb
ref={ref}
className={cx(styles.thumb, className)}
{...props}
/>
));

Switch.displayName = 'Switch';