Skip to content

Commit df8a8e0

Browse files
committed
Add ToggleButton component and hooks
1 parent 891e7c2 commit df8a8e0

File tree

14 files changed

+625
-0
lines changed

14 files changed

+625
-0
lines changed

docs/data/api/toggle-button.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"props": {
3+
"aria-label": { "type": { "name": "string" } },
4+
"aria-labelledby": { "type": { "name": "string" } },
5+
"className": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;string" } },
6+
"defaultPressed": { "type": { "name": "bool" }, "default": "false" },
7+
"disabled": { "type": { "name": "bool" }, "default": "false" },
8+
"onPressedChange": {
9+
"type": { "name": "func" },
10+
"signature": {
11+
"type": "function(pressed: boolean, event: Event) => void",
12+
"describedArgs": ["pressed", "event"]
13+
}
14+
},
15+
"pressed": { "type": { "name": "bool" }, "default": "undefined" },
16+
"render": { "type": { "name": "union", "description": "element<br>&#124;&nbsp;func" } }
17+
},
18+
"name": "ToggleButton",
19+
"imports": ["import { ToggleButton } from '@base_ui/react/ToggleButton';"],
20+
"classes": [],
21+
"spread": true,
22+
"themeDefaultProps": true,
23+
"muiName": "ToggleButton",
24+
"forwardsRefTo": "HTMLButtonElement",
25+
"filename": "/packages/mui-base/src/ToggleButton/ToggleButton.tsx",
26+
"inheritance": null,
27+
"demos": "<ul><li><a href=\"/components/react-toggle-button/\">ToggleButton</a></li></ul>",
28+
"cssComponent": false
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
import * as React from 'react';
3+
import { ToggleButton } from '@base_ui/react/ToggleButton';
4+
import classes from './styles.module.css';
5+
6+
export default function ToggleButtonIntroduction() {
7+
const [pressed, setPressed] = React.useState(false);
8+
return (
9+
<ToggleButton
10+
pressed={pressed}
11+
onPressedChange={setPressed}
12+
className={classes.button}
13+
aria-label="Bookmark this article"
14+
>
15+
<svg
16+
xmlns="http://www.w3.org/2000/svg"
17+
width="24"
18+
height="24"
19+
viewBox="0 0 24 24"
20+
className={classes.icon}
21+
>
22+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
23+
</svg>
24+
</ToggleButton>
25+
);
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
import * as React from 'react';
3+
import { ToggleButton } from '@base_ui/react/ToggleButton';
4+
import classes from './styles.module.css';
5+
6+
export default function ToggleButtonIntroduction() {
7+
const [pressed, setPressed] = React.useState(false);
8+
return (
9+
<ToggleButton
10+
pressed={pressed}
11+
onPressedChange={setPressed}
12+
className={classes.button}
13+
aria-label="Bookmark this article"
14+
>
15+
<svg
16+
xmlns="http://www.w3.org/2000/svg"
17+
width="24"
18+
height="24"
19+
viewBox="0 0 24 24"
20+
className={classes.icon}
21+
>
22+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
23+
</svg>
24+
</ToggleButton>
25+
);
26+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<ToggleButton
2+
pressed={pressed}
3+
onPressedChange={setPressed}
4+
className={classes.button}
5+
aria-label="Bookmark this article"
6+
>
7+
<svg
8+
xmlns="http://www.w3.org/2000/svg"
9+
width="24"
10+
height="24"
11+
viewBox="0 0 24 24"
12+
className={classes.icon}
13+
>
14+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
15+
</svg>
16+
</ToggleButton>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
.button {
2+
--size: 2.5rem;
3+
--corner: 0.4rem;
4+
5+
display: flex;
6+
flex-flow: row nowrap;
7+
justify-content: center;
8+
align-items: center;
9+
height: var(--size);
10+
width: var(--size);
11+
border: 1px solid var(--gray-outline-2);
12+
border-radius: var(--corner);
13+
background-color: var(--gray-container-1);
14+
color: var(--gray-text-1);
15+
}
16+
17+
.button:hover {
18+
background-color: var(--gray-surface-1);
19+
color: var(--gray-text-2);
20+
}
21+
22+
.button:focus-visible {
23+
outline: 2px solid var(--gray-900);
24+
}
25+
26+
.icon {
27+
fill: none;
28+
stroke: currentColor;
29+
stroke-width: 2;
30+
stroke-linecap: round;
31+
stroke-linejoin: round;
32+
}
33+
34+
.button[data-pressed] .icon {
35+
color: var(--gray-text-2);
36+
fill: currentColor;
37+
stroke-width: 0;
38+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
productId: base-ui
3+
title: React ToggleButton component
4+
description: Toggle Buttons are a two-state button that can either be off (not pressed) or on (pressed).
5+
components: ToggleButton
6+
hooks: useToggleButton
7+
githubLabel: 'component: toggle button'
8+
waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/button/
9+
packageName: '@base_ui/react'
10+
---
11+
12+
# ToggleButton
13+
14+
<Description />
15+
16+
<ComponentLinkHeader design={false} />
17+
18+
<Demo demo="ToggleButtonIntroduction" defaultCodeOpen="false" bg="gradient" />
19+
20+
## Installation
21+
22+
<InstallationInstructions componentName="ToggleButton" />
23+
24+
## Anatomy
25+
26+
ToggleButton is a single component that renders a `<button>`:
27+
28+
```tsx
29+
<ToggleButton>Toggle bookmark</ToggleButton>
30+
```
31+
32+
## Controlled
33+
34+
Use the `pressed` and `onPressedChange` props to control the pressed state:
35+
36+
```jsx
37+
const [pressed, setPressed] = React.useState(false);
38+
39+
<ToggleButton pressed={pressed} onPressedChange={setPressed}>
40+
{/* children */}
41+
</ToggleButton>;
42+
```
43+
44+
## Uncontrolled
45+
46+
Use the `defaultPressed` prop to set the initial pressed state of the component when uncontrolled:
47+
48+
```jsx
49+
<ToggleButton defaultPressed={false}>{/* children */}</ToggleButton>
50+
```
51+
52+
## Accessibility
53+
54+
ToggleButtons must be given an accessible name with `aria-label` or `aria-labelledby`. It is important that the label does not change based on the pressed state to ensure a smooth screen-reader experience, since announcing the pressed state is already handled by `aria-pressed`.
55+
56+
## Styling
57+
58+
Use the `[data-pressed]` attribute to apply styles based on the pressed state:
59+
60+
```jsx
61+
<ToggleButton className="MyToggle">{/* children */}</ToggleButton>
62+
```
63+
64+
```css
65+
/* base state, not pressed */
66+
.MyToggle {
67+
background: white;
68+
color: black;
69+
}
70+
71+
/* pressed state */
72+
.MyToggle[data-pressed] {
73+
background: black;
74+
color: white;
75+
}
76+
```

docs/data/pages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const pages: readonly RouteMetadata[] = [
4242
{ pathname: '/components/react-switch', title: 'Switch' },
4343
{ pathname: '/components/react-tabs', title: 'Tabs' },
4444
{ pathname: '/components/react-text-input', title: 'Text Input' },
45+
{ pathname: '/components/react-toggle-button', title: 'Toggle Button' },
4546
{ pathname: '/components/react-tooltip', title: 'Tooltip' },
4647
],
4748
},
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"componentDescription": "",
3+
"propDescriptions": {
4+
"aria-label": { "description": "The label for the ToggleButton." },
5+
"aria-labelledby": {
6+
"description": "An id or space-separated list of ids of elements that label the ToggleButton."
7+
},
8+
"className": {
9+
"description": "Class names applied to the element or a function that returns them based on the component&#39;s state."
10+
},
11+
"defaultPressed": {
12+
"description": "The default pressed state. Use when the component is not controlled."
13+
},
14+
"disabled": { "description": "If <code>true</code>, the component is disabled." },
15+
"onPressedChange": {
16+
"description": "Callback fired when the pressed state is changed.",
17+
"typeDescriptions": {
18+
"pressed": "The new pressed state.",
19+
"event": "The event source of the callback."
20+
}
21+
},
22+
"pressed": { "description": "If <code>true</code>, the component is pressed." },
23+
"render": { "description": "A function to customize rendering of the component." }
24+
},
25+
"classDescriptions": {}
26+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
import * as React from 'react';
3+
import { ToggleButton } from '@base_ui/react/ToggleButton';
4+
5+
export default function ToggleButtonDemo() {
6+
const [pressed, setPressed] = React.useState(true);
7+
return (
8+
<div>
9+
<ToggleButton
10+
pressed={pressed}
11+
onPressedChange={setPressed}
12+
// className={classes.button}
13+
>
14+
<svg
15+
xmlns="http://www.w3.org/2000/svg"
16+
width="24"
17+
height="24"
18+
viewBox="0 0 24 24"
19+
fill="none"
20+
stroke="currentColor"
21+
strokeWidth={2}
22+
strokeLinecap="round"
23+
strokeLinejoin="round"
24+
>
25+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
26+
</svg>
27+
</ToggleButton>
28+
</div>
29+
);
30+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as React from 'react';
2+
import { expect } from 'chai';
3+
import { spy } from 'sinon';
4+
import { act } from '@mui/internal-test-utils';
5+
import { ToggleButton } from '@base_ui/react/ToggleButton';
6+
import { createRenderer, describeConformance } from '#test-utils';
7+
8+
describe('<ToggleButton />', () => {
9+
const { render } = createRenderer();
10+
11+
describeConformance(<ToggleButton />, () => ({
12+
refInstanceof: window.HTMLButtonElement,
13+
render,
14+
}));
15+
16+
describe('pressed state', () => {
17+
it('controlled', async () => {
18+
function App() {
19+
const [pressed, setPressed] = React.useState(false);
20+
return (
21+
<div>
22+
<input type="checkbox" checked={pressed} onChange={() => setPressed(!pressed)} />
23+
<ToggleButton pressed={pressed} />;
24+
</div>
25+
);
26+
}
27+
28+
const { getByRole } = await render(<App />);
29+
const checkbox = getByRole('checkbox');
30+
const button = getByRole('button');
31+
32+
expect(button).to.have.attribute('aria-pressed', 'false');
33+
await act(async () => {
34+
checkbox.click();
35+
});
36+
37+
expect(button).to.have.attribute('aria-pressed', 'true');
38+
39+
await act(async () => {
40+
checkbox.click();
41+
});
42+
43+
expect(button).to.have.attribute('aria-pressed', 'false');
44+
});
45+
46+
it('uncontrolled', async () => {
47+
const { getByRole } = await render(<ToggleButton defaultPressed={false} />);
48+
49+
const button = getByRole('button');
50+
51+
expect(button).to.have.attribute('aria-pressed', 'false');
52+
await act(async () => {
53+
button.click();
54+
});
55+
56+
expect(button).to.have.attribute('aria-pressed', 'true');
57+
58+
await act(async () => {
59+
button.click();
60+
});
61+
62+
expect(button).to.have.attribute('aria-pressed', 'false');
63+
});
64+
});
65+
66+
describe('prop: onPressedChange', () => {
67+
it('is called when the pressed state changes', async () => {
68+
const handlePressed = spy();
69+
const { getByRole } = await render(
70+
<ToggleButton defaultPressed={false} onPressedChange={handlePressed} />,
71+
);
72+
73+
const button = getByRole('button');
74+
75+
await act(async () => {
76+
button.click();
77+
});
78+
79+
expect(handlePressed.callCount).to.equal(1);
80+
expect(handlePressed.firstCall.args[0]).to.equal(true);
81+
});
82+
});
83+
84+
describe('prop: disabled', () => {
85+
it('disables the component', async () => {
86+
const handlePressed = spy();
87+
const { getByRole } = await render(<ToggleButton disabled onPressedChange={handlePressed} />);
88+
89+
const button = getByRole('button');
90+
expect(button).to.have.attribute('disabled');
91+
expect(button).to.have.attribute('aria-pressed', 'false');
92+
93+
await act(async () => {
94+
button.click();
95+
});
96+
97+
expect(handlePressed.callCount).to.equal(0);
98+
expect(button).to.have.attribute('aria-pressed', 'false');
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)