Skip to content

Commit

Permalink
Add ToggleButton component and hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert committed Oct 28, 2024
1 parent 26737f8 commit 6e04229
Show file tree
Hide file tree
Showing 14 changed files with 625 additions and 0 deletions.
29 changes: 29 additions & 0 deletions docs/data/api/toggle-button.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"props": {
"aria-label": { "type": { "name": "string" } },
"aria-labelledby": { "type": { "name": "string" } },
"className": { "type": { "name": "union", "description": "func<br>&#124;&nbsp;string" } },
"defaultPressed": { "type": { "name": "bool" }, "default": "false" },
"disabled": { "type": { "name": "bool" }, "default": "false" },
"onPressedChange": {
"type": { "name": "func" },
"signature": {
"type": "function(pressed: boolean, event: Event) => void",
"describedArgs": ["pressed", "event"]
}
},
"pressed": { "type": { "name": "bool" }, "default": "undefined" },
"render": { "type": { "name": "union", "description": "element<br>&#124;&nbsp;func" } }
},
"name": "ToggleButton",
"imports": ["import { ToggleButton } from '@base_ui/react/ToggleButton';"],
"classes": [],
"spread": true,
"themeDefaultProps": true,
"muiName": "ToggleButton",
"forwardsRefTo": "HTMLButtonElement",
"filename": "/packages/mui-base/src/ToggleButton/ToggleButton.tsx",
"inheritance": null,
"demos": "<ul><li><a href=\"/components/react-toggle-button/\">ToggleButton</a></li></ul>",
"cssComponent": false
}
26 changes: 26 additions & 0 deletions docs/data/components/toggle-button/ToggleButtonIntroduction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import { ToggleButton } from '@base_ui/react/ToggleButton';
import classes from './styles.module.css';

export default function ToggleButtonIntroduction() {
const [pressed, setPressed] = React.useState(false);
return (
<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
className={classes.button}
aria-label="Bookmark this article"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className={classes.icon}
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</ToggleButton>
);
}
26 changes: 26 additions & 0 deletions docs/data/components/toggle-button/ToggleButtonIntroduction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import { ToggleButton } from '@base_ui/react/ToggleButton';
import classes from './styles.module.css';

export default function ToggleButtonIntroduction() {
const [pressed, setPressed] = React.useState(false);
return (
<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
className={classes.button}
aria-label="Bookmark this article"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className={classes.icon}
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</ToggleButton>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
className={classes.button}
aria-label="Bookmark this article"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className={classes.icon}
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</ToggleButton>
38 changes: 38 additions & 0 deletions docs/data/components/toggle-button/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.button {
--size: 2.5rem;
--corner: 0.4rem;

display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
height: var(--size);
width: var(--size);
border: 1px solid var(--gray-outline-2);
border-radius: var(--corner);
background-color: var(--gray-container-1);
color: var(--gray-text-1);
}

.button:hover {
background-color: var(--gray-surface-1);
color: var(--gray-text-2);
}

.button:focus-visible {
outline: 2px solid var(--gray-900);
}

.icon {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}

.button[data-pressed] .icon {
color: var(--gray-text-2);
fill: currentColor;
stroke-width: 0;
}
76 changes: 76 additions & 0 deletions docs/data/components/toggle-button/toggle-button.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
productId: base-ui
title: React ToggleButton component
description: Toggle Buttons are a two-state button that can either be off (not pressed) or on (pressed).
components: ToggleButton
hooks: useToggleButton
githubLabel: 'component: toggle button'
waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/button/
packageName: '@base_ui/react'
---

# ToggleButton

<Description />

<ComponentLinkHeader design={false} />

<Demo demo="ToggleButtonIntroduction" defaultCodeOpen="false" bg="gradient" />

## Installation

<InstallationInstructions componentName="ToggleButton" />

## Anatomy

ToggleButton is a single component that renders a `<button>`:

```tsx
<ToggleButton>Toggle bookmark</ToggleButton>
```

## Controlled

Use the `pressed` and `onPressedChange` props to control the pressed state:

```jsx
const [pressed, setPressed] = React.useState(false);

<ToggleButton pressed={pressed} onPressedChange={setPressed}>
{/* children */}
</ToggleButton>;
```

## Uncontrolled

Use the `defaultPressed` prop to set the initial pressed state of the component when uncontrolled:

```jsx
<ToggleButton defaultPressed={false}>{/* children */}</ToggleButton>
```

## Accessibility

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`.

## Styling

Use the `[data-pressed]` attribute to apply styles based on the pressed state:

```jsx
<ToggleButton className="MyToggle">{/* children */}</ToggleButton>
```

```css
/* base state, not pressed */
.MyToggle {
background: white;
color: black;
}

/* pressed state */
.MyToggle[data-pressed] {
background: black;
color: white;
}
```
1 change: 1 addition & 0 deletions docs/data/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const pages: readonly RouteMetadata[] = [
{ pathname: '/components/react-slider', title: 'Slider' },
{ pathname: '/components/react-switch', title: 'Switch' },
{ pathname: '/components/react-tabs', title: 'Tabs' },
{ pathname: '/components/react-toggle-button', title: 'Toggle Button' },
{ pathname: '/components/react-tooltip', title: 'Tooltip' },
],
},
Expand Down
26 changes: 26 additions & 0 deletions docs/data/translations/api-docs/toggle-button/toggle-button.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"componentDescription": "",
"propDescriptions": {
"aria-label": { "description": "The label for the ToggleButton." },
"aria-labelledby": {
"description": "An id or space-separated list of ids of elements that label the ToggleButton."
},
"className": {
"description": "Class names applied to the element or a function that returns them based on the component&#39;s state."
},
"defaultPressed": {
"description": "The default pressed state. Use when the component is not controlled."
},
"disabled": { "description": "If <code>true</code>, the component is disabled." },
"onPressedChange": {
"description": "Callback fired when the pressed state is changed.",
"typeDescriptions": {
"pressed": "The new pressed state.",
"event": "The event source of the callback."
}
},
"pressed": { "description": "If <code>true</code>, the component is pressed." },
"render": { "description": "A function to customize rendering of the component." }
},
"classDescriptions": {}
}
30 changes: 30 additions & 0 deletions docs/src/app/experiments/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import { ToggleButton } from '@base_ui/react/ToggleButton';

export default function ToggleButtonDemo() {
const [pressed, setPressed] = React.useState(true);
return (
<div>
<ToggleButton
pressed={pressed}
onPressedChange={setPressed}
// className={classes.button}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</ToggleButton>
</div>
);
}
101 changes: 101 additions & 0 deletions packages/mui-base/src/ToggleButton/ToggleButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import * as React from 'react';
import { expect } from 'chai';
import { spy } from 'sinon';
import { act } from '@mui/internal-test-utils';
import { ToggleButton } from '@base_ui/react/ToggleButton';
import { createRenderer, describeConformance } from '#test-utils';

describe('<ToggleButton />', () => {
const { render } = createRenderer();

describeConformance(<ToggleButton />, () => ({
refInstanceof: window.HTMLButtonElement,
render,
}));

describe('pressed state', () => {
it('controlled', async () => {
function App() {
const [pressed, setPressed] = React.useState(false);
return (
<div>
<input type="checkbox" checked={pressed} onChange={() => setPressed(!pressed)} />
<ToggleButton pressed={pressed} />;
</div>
);
}

const { getByRole } = await render(<App />);
const checkbox = getByRole('checkbox');
const button = getByRole('button');

expect(button).to.have.attribute('aria-pressed', 'false');
await act(async () => {
checkbox.click();
});

expect(button).to.have.attribute('aria-pressed', 'true');

await act(async () => {
checkbox.click();
});

expect(button).to.have.attribute('aria-pressed', 'false');
});

it('uncontrolled', async () => {
const { getByRole } = await render(<ToggleButton defaultPressed={false} />);

const button = getByRole('button');

expect(button).to.have.attribute('aria-pressed', 'false');
await act(async () => {
button.click();
});

expect(button).to.have.attribute('aria-pressed', 'true');

await act(async () => {
button.click();
});

expect(button).to.have.attribute('aria-pressed', 'false');
});
});

describe('prop: onPressedChange', () => {
it('is called when the pressed state changes', async () => {
const handlePressed = spy();
const { getByRole } = await render(
<ToggleButton defaultPressed={false} onPressedChange={handlePressed} />,
);

const button = getByRole('button');

await act(async () => {
button.click();
});

expect(handlePressed.callCount).to.equal(1);
expect(handlePressed.firstCall.args[0]).to.equal(true);
});
});

describe('prop: disabled', () => {
it('disables the component', async () => {
const handlePressed = spy();
const { getByRole } = await render(<ToggleButton disabled onPressedChange={handlePressed} />);

const button = getByRole('button');
expect(button).to.have.attribute('disabled');
expect(button).to.have.attribute('aria-pressed', 'false');

await act(async () => {
button.click();
});

expect(handlePressed.callCount).to.equal(0);
expect(button).to.have.attribute('aria-pressed', 'false');
});
});
});
Loading

0 comments on commit 6e04229

Please sign in to comment.