Skip to content
Draft
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
162 changes: 162 additions & 0 deletions docs/pages/components/Switch.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
---
title: Switch
description: A toggle control that allows users to switch between two states (on/off).
source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/Switch/Switch.tsx
---

import { useState } from 'react'
import { Switch, FieldWrap } from '@deque/cauldron-react'

```js
import { Switch } from '@deque/cauldron-react'
```

<Note>
Input components _should_ be wrapped in a [FieldWrap](./FieldWrap). The
`Switch` component has been designed specifically to meet color contrast
requirements against a `FieldWrap` background.
</Note>

## Examples

### Default (Unchecked)

```jsx example
function SwitchExample() {
const [checked, setChecked] = useState(false);
return (
<FieldWrap>
<Switch
id="switch-default"
label="Notifications"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
</FieldWrap>
);
}
```

### Checked

```jsx example
<FieldWrap>
<Switch
id="switch-checked"
label="Notifications"
checked
onChange={() => {}}
/>
</FieldWrap>
```

### Disabled

```jsx example
<FieldWrap>
<Switch
id="switch-disabled"
label="Notifications (disabled)"
disabled
/>
</FieldWrap>
```

### Disabled Checked

```jsx example
<FieldWrap>
<Switch
id="switch-disabled-checked"
label="Notifications (disabled, on)"
checked
disabled
onChange={() => {}}
/>
</FieldWrap>
```

### With Description

```jsx example
<FieldWrap>
<Switch
id="switch-description"
label="Email Notifications"
labelDescription="Receive updates about your account activity."
/>
</FieldWrap>
```

### With Error

```jsx example
<FieldWrap>
<Switch
id="switch-error"
label="Terms of Service"
error="You must accept the terms to continue."
/>
</FieldWrap>
```

## Props

<ComponentProps
className={true}
refType="HTMLInputElement"
props={[
{
name: 'id',
type: 'string',
required: true,
description: 'The id to be set on the input element.'
},
{
name: 'label',
type: 'React.ReactNode',
required: true,
description: 'Label displayed next to the switch.'
},
{
name: 'checked',
type: 'boolean',
defaultValue: 'false',
description: 'Controlled checked state of the switch.'
},
{
name: 'disabled',
type: 'boolean',
defaultValue: 'false',
description: 'Disables the switch.'
},
{
name: 'labelDescription',
type: 'React.ReactNode',
description: 'Additional description text below the switch label.'
},
{
name: 'error',
type: 'React.ReactNode',
description: 'Error message displayed below the switch.'
},
{
name: 'switchRef',
type: 'React.RefObject<HTMLInputElement>',
description: 'Named ref forwarded to the input element. Using ref is preferred.'
}
]}
/>

## Accessibility

- Renders as `<input type="checkbox" role="switch">` for correct assistive technology semantics.
- The label is associated via `htmlFor` / `id`.
- Focus ring meets WCAG 2.1 visibility requirements using the design system focus color.
- Use the `error` or `labelDescription` props to associate additional context via `aria-describedby`.
- Use for immediate settings changes that do not require form submission. For settings that require a save action, prefer [Checkbox](./Checkbox).

## Related Components

- [Checkbox](./Checkbox)
- [FieldWrap](./FieldWrap)
198 changes: 198 additions & 0 deletions packages/react/src/components/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import Switch from './';

describe('Switch', () => {
test('renders with label', () => {
render(<Switch id="test" label="Toggle me" />);
expect(screen.getByLabelText('Toggle me')).toBeInTheDocument();
});

test('is unchecked by default', () => {
render(<Switch id="test" label="Toggle" />);
expect(screen.getByRole('switch')).not.toBeChecked();
});

test('renders checked state', () => {
render(<Switch id="test" label="Toggle" checked />);
expect(screen.getByRole('switch')).toBeChecked();
});

test('renders disabled state', () => {
render(<Switch id="test" label="Toggle" disabled />);
expect(screen.getByRole('switch')).toBeDisabled();
});

test('renders disabled checked state', () => {
render(<Switch id="test" label="Toggle" disabled checked />);
expect(screen.getByRole('switch')).toBeDisabled();
expect(screen.getByRole('switch')).toBeChecked();
});

test('passes className to wrapper div', () => {
const { container } = render(
<Switch id="test" label="Toggle" className="custom-class" />
);
expect(container.querySelector('.Switch.custom-class')).toBeInTheDocument();
});

test('has role switch', () => {
render(<Switch id="test" label="Toggle" />);
expect(screen.getByRole('switch')).toBeInTheDocument();
});

test('calls onChange when toggled', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<Switch id="test" label="Toggle" onChange={onChange} />);
await user.click(screen.getByRole('switch'));
expect(onChange).toHaveBeenCalledTimes(1);
});

test('does not call onChange when disabled', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<Switch id="test" label="Toggle" disabled onChange={onChange} />);
await user.click(screen.getByRole('switch'));
expect(onChange).not.toHaveBeenCalled();
});

test('toggles checked state on click', async () => {
const user = userEvent.setup();
render(<Switch id="test" label="Toggle" />);
const switchEl = screen.getByRole('switch');
expect(switchEl).not.toBeChecked();
await user.click(switchEl);
expect(switchEl).toBeChecked();
await user.click(switchEl);
expect(switchEl).not.toBeChecked();
});

test('calls onFocus when focused', async () => {
const user = userEvent.setup();
const onFocus = jest.fn();
render(<Switch id="test" label="Toggle" onFocus={onFocus} />);
await user.tab();
expect(onFocus).toHaveBeenCalledTimes(1);
});

test('calls onBlur when blurred', async () => {
const user = userEvent.setup();
const onBlur = jest.fn();
render(<Switch id="test" label="Toggle" onBlur={onBlur} />);
await user.tab();
await user.tab();
expect(onBlur).toHaveBeenCalledTimes(1);
});

test('toggles via Space key', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<Switch id="test" label="Toggle" onChange={onChange} />);
await user.tab();
await user.keyboard(' ');
expect(onChange).toHaveBeenCalledTimes(1);
});

test('syncs with controlled checked prop', () => {
const { rerender } = render(
<Switch id="test" label="Toggle" checked={false} onChange={jest.fn()} />
);
expect(screen.getByRole('switch')).not.toBeChecked();
rerender(
<Switch id="test" label="Toggle" checked={true} onChange={jest.fn()} />
);
expect(screen.getByRole('switch')).toBeChecked();
});

test('renders error message', () => {
render(<Switch id="test" label="Toggle" error="This field is required" />);
expect(screen.getByText('This field is required')).toBeInTheDocument();
});

test('renders label description', () => {
render(
<Switch id="test" label="Toggle" labelDescription="Additional info" />
);
expect(screen.getByText('Additional info')).toBeInTheDocument();
});

test('associates error with input via aria-describedby', () => {
render(<Switch id="test" label="Toggle" error="Error message" />);
const input = screen.getByRole('switch');
const errorEl = screen.getByText('Error message');
expect(input.getAttribute('aria-describedby')).toContain(errorEl.id);
});

test('associates label description with input via aria-describedby', () => {
render(
<Switch id="test" label="Toggle" labelDescription="Description text" />
);
const input = screen.getByRole('switch');
const descEl = screen.getByText('Description text');
expect(input.getAttribute('aria-describedby')).toContain(descEl.id);
});

test('forwards ref to input element', () => {
const ref = React.createRef<HTMLInputElement>();
render(<Switch id="test" label="Toggle" ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});

test('forwards switchRef to input element', () => {
const switchRef = React.createRef<HTMLInputElement>();
render(<Switch id="test" label="Toggle" switchRef={switchRef} />);
expect(switchRef.current).toBeInstanceOf(HTMLInputElement);
});

test('renders without errors when no optional props are provided', () => {
const { container } = render(<Switch id="test" label="Toggle" />);
expect(container.querySelector('.Switch')).toBeInTheDocument();
});

test('should have no axe violations when unchecked', async () => {
const { container } = render(<Switch id="test" label="Toggle" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no axe violations when checked', async () => {
const { container } = render(
<Switch id="test" label="Toggle" checked onChange={jest.fn()} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no axe violations when disabled', async () => {
const { container } = render(<Switch id="test" label="Toggle" disabled />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no axe violations when disabled and checked', async () => {
const { container } = render(
<Switch id="test" label="Toggle" disabled checked onChange={jest.fn()} />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no axe violations with error', async () => {
const { container } = render(
<Switch id="test" label="Toggle" error="This field is required" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no axe violations with label description', async () => {
const { container } = render(
<Switch id="test" label="Toggle" labelDescription="Additional info" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Loading
Loading