From bf9277b7e37fd361e5c15900dfe3398bb39e413f Mon Sep 17 00:00:00 2001 From: ShreyasGit51283 Date: Thu, 27 Aug 2026 17:59:50 +0530 Subject: [PATCH] feat(components): add selected icon to ToggleButton A selected toggle button now shows check-circle, so selection reads at a glance rather than from the border and fill alone. When a button has a leading icon, selecting it swaps that icon for the selected one instead of showing both. The elevated appearance opts out: its raised surface already signals selection, so adding an icon there would be redundant. Co-authored-by: Cursor --- .changeset/toggle-button-selected-icon.md | 5 ++ .../__tests__/ToggleButton.spec.tsx | 67 +++++++++++++++++++ packages/components/src/ToggleButton.tsx | 35 +++++++++- .../stories/ToggleButtonGroup.stories.tsx | 59 ++++++++++++++++ .../ToggleButtonGroupElevated.stories.tsx | 8 +++ 5 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 .changeset/toggle-button-selected-icon.md diff --git a/.changeset/toggle-button-selected-icon.md b/.changeset/toggle-button-selected-icon.md new file mode 100644 index 000000000..82179d6e5 --- /dev/null +++ b/.changeset/toggle-button-selected-icon.md @@ -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. diff --git a/packages/components/__tests__/ToggleButton.spec.tsx b/packages/components/__tests__/ToggleButton.spec.tsx index fd22f55ba..a8f485594 100644 --- a/packages/components/__tests__/ToggleButton.spec.tsx +++ b/packages/components/__tests__/ToggleButton.spec.tsx @@ -19,4 +19,71 @@ describe('ToggleButton', () => { render(toggle); expect(screen.getByRole('button')).toHaveAttribute('data-lp-variant', 'default'); }); + + describe('selected icon', () => { + it('renders a check when selected', () => { + render(toggle); + expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toBeInTheDocument(); + }); + + it('renders no icon when not selected', () => { + render(toggle); + expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument(); + }); + + it('renders a leading icon while not selected', () => { + render(toggle); + + 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( + + toggle + , + ); + + 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( + + toggle + , + ); + expect(screen.getByRole('button').querySelector('[data-icon="flag"]')).toBeInTheDocument(); + }); + + it('renders no icon when the selected icon is null', () => { + render( + + toggle + , + ); + expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument(); + }); + + it('renders no icon when selected and elevated', () => { + render( + + toggle + , + ); + expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument(); + }); + + it('hides the icon from assistive technology', () => { + render(toggle); + expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toHaveAttribute( + 'aria-hidden', + 'true', + ); + }); + }); }); diff --git a/packages/components/src/ToggleButton.tsx b/packages/components/src/ToggleButton.tsx index 90bb14026..19e5144fc 100644 --- a/packages/components/src/ToggleButton.tsx +++ b/packages/components/src/ToggleButton.tsx @@ -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'; @@ -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; } @@ -25,11 +36,20 @@ const ToggleButtonContext = createContext { 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 ( { ? toggleButtonElevatedStyles({ ...renderProps, className }) : buttonStyles({ ...renderProps, size, variant, className }), )} - /> + > + {composeRenderProps(mergedProps.children, (children, { isSelected }) => { + const name = isSelected ? selectedIcon : icon; + + return ( + <> + {name ? : null} + {children} + + ); + })} + ); }; diff --git a/packages/components/stories/ToggleButtonGroup.stories.tsx b/packages/components/stories/ToggleButtonGroup.stories.tsx index 35328e565..5ec127483 100644 --- a/packages/components/stories/ToggleButtonGroup.stories.tsx +++ b/packages/components/stories/ToggleButtonGroup.stories.tsx @@ -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'; @@ -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: ( + <> + + First + + + Second + + + Third + + + ), + 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: ( + <> + + First + + + Second + + + Third + + + ), + defaultSelectedKeys: ['first'], + }, +}; diff --git a/packages/components/stories/ToggleButtonGroupElevated.stories.tsx b/packages/components/stories/ToggleButtonGroupElevated.stories.tsx index ed421793f..0f9b3327d 100644 --- a/packages/components/stories/ToggleButtonGroupElevated.stories.tsx +++ b/packages/components/stories/ToggleButtonGroupElevated.stories.tsx @@ -11,6 +11,14 @@ const meta: Meta = { subcomponents: { ToggleButton } as Record>, 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;