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
5 changes: 5 additions & 0 deletions .changeset/toggle-button-selected-icon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@launchpad-ui/components': minor
---

Add `icon` and `selectedIcon` props to `ToggleButton`. A selected button now shows `check-circle` by default, and selecting a button with a leading `icon` swaps that icon for the selected one rather than showing both. Pass `selectedIcon={null}` to opt out. `appearance="elevated"` shows no selected icon, since its raised surface already signals selection.
67 changes: 67 additions & 0 deletions packages/components/__tests__/ToggleButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,71 @@ describe('ToggleButton', () => {
render(<ToggleButton appearance="elevated">toggle</ToggleButton>);
expect(screen.getByRole('button')).toHaveAttribute('data-lp-variant', 'default');
});

describe('selected icon', () => {
it('renders a check when selected', () => {
render(<ToggleButton isSelected>toggle</ToggleButton>);
expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toBeInTheDocument();
});

it('renders no icon when not selected', () => {
render(<ToggleButton>toggle</ToggleButton>);
expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
});

it('renders a leading icon while not selected', () => {
render(<ToggleButton icon="flask">toggle</ToggleButton>);

const button = screen.getByRole('button');
expect(button.querySelector('[data-icon="flask"]')).toBeInTheDocument();
expect(button.querySelector('[data-icon="check-circle"]')).not.toBeInTheDocument();
});

it('swaps the leading icon for the check when selected', () => {
render(
<ToggleButton icon="flask" isSelected>
toggle
</ToggleButton>,
);

const button = screen.getByRole('button');
expect(button.querySelector('[data-icon="check-circle"]')).toBeInTheDocument();
expect(button.querySelector('[data-icon="flask"]')).not.toBeInTheDocument();
});

it('supports a custom selected icon', () => {
render(
<ToggleButton isSelected selectedIcon="flag">
toggle
</ToggleButton>,
);
expect(screen.getByRole('button').querySelector('[data-icon="flag"]')).toBeInTheDocument();
});

it('renders no icon when the selected icon is null', () => {
render(
<ToggleButton isSelected selectedIcon={null}>
toggle
</ToggleButton>,
);
expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
});

it('renders no icon when selected and elevated', () => {
render(
<ToggleButton appearance="elevated" isSelected>
toggle
</ToggleButton>,
);
expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
});

it('hides the icon from assistive technology', () => {
render(<ToggleButton isSelected>toggle</ToggleButton>);
expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toHaveAttribute(
'aria-hidden',
'true',
);
});
});
});
35 changes: 33 additions & 2 deletions packages/components/src/ToggleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-comp
import { ToggleButton as AriaToggleButton } from 'react-aria-components/ToggleButton';
import { cva } from 'class-variance-authority';

import type { IconProps } from '@launchpad-ui/icons';
import { Icon } from '@launchpad-ui/icons';

import type { ButtonVariants } from './Button';
import { buttonStyles } from './Button';
import { useLPContextProps } from './utils';
Expand All @@ -17,6 +20,14 @@ const toggleButtonElevatedStyles = cva(elevatedStyles.elevated);
interface ToggleButtonProps extends AriaToggleButtonProps, ButtonVariants {
/** Visual appearance of the toggle button. Use `"elevated"` inside a `ToggleButtonGroup` with `appearance="elevated"`. */
appearance?: 'default' | 'elevated';
/** Leading icon shown while the button is not selected. Replaced by `selectedIcon` on selection. */
icon?: IconProps['name'];
/**
* Leading icon shown while the button is selected. Defaults to `"check-circle"`, and to `null` when
* `appearance="elevated"` — the elevated appearance already signals selection through its raised surface.
* Pass `null` to render no icon when selected.
*/
selectedIcon?: IconProps['name'] | null;
ref?: Ref<HTMLButtonElement>;
}

Expand All @@ -25,11 +36,20 @@ const ToggleButtonContext = createContext<ContextValue<ToggleButtonProps, HTMLBu
/**
* A toggle button allows a user to toggle a selection on or off, for example switching between two states or modes.
*
* Selecting a button swaps its leading icon for `selectedIcon`, so a selected button shows a single icon rather
* than both. The elevated appearance opts out of this and shows no selected icon.
*
* https://react-spectrum.adobe.com/react-aria/ToggleButton.html
*/
const ToggleButton = ({ ref, ...props }: ToggleButtonProps) => {
const [mergedProps, mergedRef] = useLPContextProps(props, ref, ToggleButtonContext);
const { appearance = 'default', size = 'medium', variant = 'default' } = mergedProps;
const {
appearance = 'default',
size = 'medium',
variant = 'default',
icon,
selectedIcon = appearance === 'elevated' ? null : 'check-circle',
} = mergedProps;

return (
<AriaToggleButton
Expand All @@ -41,7 +61,18 @@ const ToggleButton = ({ ref, ...props }: ToggleButtonProps) => {
? toggleButtonElevatedStyles({ ...renderProps, className })
: buttonStyles({ ...renderProps, size, variant, className }),
)}
/>
>
{composeRenderProps(mergedProps.children, (children, { isSelected }) => {
const name = isSelected ? selectedIcon : icon;

return (
<>
{name ? <Icon name={name} size="small" aria-hidden /> : null}
{children}
</>
);
})}
</AriaToggleButton>
);
};

Expand Down
59 changes: 59 additions & 0 deletions packages/components/stories/ToggleButtonGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';

import { ToggleButton } from '../src/ToggleButton';
import { ToggleButtonGroup } from '../src/ToggleButtonGroup';
Expand Down Expand Up @@ -74,3 +75,61 @@ export const Icons: Story = {
orientation: 'vertical',
},
};

export const SelectedIconSwap: Story = {
parameters: {
docs: {
description: {
story:
'A selected button shows `check-circle`. When a button also has a leading `icon`, selecting it swaps that icon for the selected one instead of showing both. Click through the options to see the swap.',
},
},
},
args: {
children: (
<>
<ToggleButton id="first" icon="flask">
First
</ToggleButton>
<ToggleButton id="second" icon="flag">
Second
</ToggleButton>
<ToggleButton id="third" icon="toggle-on">
Third
</ToggleButton>
</>
),
defaultSelectedKeys: ['first'],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await userEvent.click(canvas.getByRole('radio', { name: 'Second' }));
},
};

export const WithoutSelectedIcon: Story = {
parameters: {
docs: {
description: {
story: 'Pass `selectedIcon={null}` to keep a selected button icon-free.',
},
},
},
args: {
children: (
<>
<ToggleButton id="first" selectedIcon={null}>
First
</ToggleButton>
<ToggleButton id="second" selectedIcon={null}>
Second
</ToggleButton>
<ToggleButton id="third" selectedIcon={null}>
Third
</ToggleButton>
</>
),
defaultSelectedKeys: ['first'],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ const meta: Meta<typeof ToggleButtonGroup> = {
subcomponents: { ToggleButton } as Record<string, ComponentType<unknown>>,
title: 'Components/Buttons/ToggleButton/ToggleButtonGroupElevated',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Unlike the default appearance, an elevated toggle button shows no icon when selected — the raised surface already makes the selection legible. Pass `selectedIcon` explicitly to opt back in.',
},
},
},
};

export default meta;
Expand Down
Loading