Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ae62a5f
add new `ToggleGroup`
noahchoii Jul 7, 2026
4131270
add new `Toggle`
noahchoii Jul 7, 2026
2b38489
remove aria-orientation from ToggleGroup.Root
noahchoii Jul 7, 2026
397edff
add toggle, toggle-group test cases
noahchoii Jul 7, 2026
5ac593b
add changesets
noahchoii Jul 7, 2026
016df5e
add snapshots
noahchoii Jul 7, 2026
556211f
add missing storybook args
noahchoii Jul 7, 2026
7ff23f5
remove unused pressed args
noahchoii Jul 8, 2026
c963a8d
apply icon size variation
noahchoii Jul 8, 2026
5b82a09
apply semantic colors
noahchoii Jul 8, 2026
c2f21cf
update snapshots
noahchoii Jul 8, 2026
0e1587f
modify toggle interaction scale
noahchoii Jul 8, 2026
7c6ee67
add enter pressed test case
noahchoii Jul 14, 2026
243c796
Merge branch 'main' into feature/vapor-185
noahchoii Jul 28, 2026
da03ad3
remove toggle-group border
noahchoii Jul 28, 2026
0ed433b
remove variant prop from toggle-group
noahchoii Jul 28, 2026
41fba50
update snapshots
noahchoii Jul 28, 2026
c531453
remove unused variant context
noahchoii Jul 28, 2026
c420249
remove separator
noahchoii Jul 28, 2026
27f3a40
update snapshots
noahchoii Jul 28, 2026
2f216a4
rename togglegroup
noahchoii Jul 29, 2026
05a70d5
rename toggle group variants type
noahchoii Jul 29, 2026
ff5495b
add pressed to logical-states
noahchoii Jul 29, 2026
ec12de5
lint
noahchoii Jul 29, 2026
9bd0750
Merge branch 'main' into feature/vapor-185
noahchoii Jul 29, 2026
02b241c
remove unnecessary index.parts
noahchoii Jul 29, 2026
f6cbf04
remove .Root
noahchoii Jul 29, 2026
fb08c88
replace clsx with cn
noahchoii Jul 30, 2026
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/famous-bugs-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vapor-ui/core': minor
---

add new `ToggleGroup` component
5 changes: 5 additions & 0 deletions .changeset/tricky-files-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vapor-ui/core': minor
---

add new `Toggle` component
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions packages/core/src/components/sheet/sheet.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,26 +116,26 @@ export const popup = componentStyle({
// width/height directly for 1:1 direct manipulation (no transition).
export const resizeHandle = componentStyle({
position: 'absolute',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
touchAction: 'none',

selectors: {
// Vertical sheets (left/right) → col-resize, full height.
'&[data-side="left"], &[data-side="right"]': {
top: 0,
bottom: 0,
width: vars.size.dimension[300],
cursor: 'col-resize',
width: vars.size.dimension[300],
},
// Horizontal sheets (top/bottom) → row-resize, full width.
'&[data-side="top"], &[data-side="bottom"]': {
left: 0,
right: 0,
height: vars.size.dimension[300],
left: 0,
cursor: 'row-resize',
height: vars.size.dimension[300],
},
// Inner edge placement: handle sits on the side facing the viewport center.
// The grip bar's thin axis pins to the sheet's exposed edge (the drag boundary),
Expand All @@ -158,8 +158,8 @@ export const resizeHandle = componentStyle({
export const resizeHandleGrip = componentStyle([
interaction(),
{
backgroundColor: vars.color.foreground.hint[100],
borderRadius: vars.size.borderRadius[300],
backgroundColor: vars.color.foreground.hint[100],

selectors: {
'&[data-side="left"], &[data-side="right"]': {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/toggle-group/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './toggle-group';
19 changes: 19 additions & 0 deletions packages/core/src/components/toggle-group/toggle-group.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { RecipeVariants } from '@vanilla-extract/recipes';

import { componentRecipe } from '~/styles/mixins/layer-style.css';
import { vars } from '~/styles/themes.css';

export const root = componentRecipe({
base: {
display: 'inline-flex',
alignItems: 'center',
gap: vars.size.space['050'],
},

defaultVariants: { size: 'md' },
variants: {
size: { sm: {}, md: {}, lg: {}, xl: {} },
},
});
Comment thread
MaxLee-dev marked this conversation as resolved.

export type RootVariants = NonNullable<RecipeVariants<typeof root>>;
115 changes: 115 additions & 0 deletions packages/core/src/components/toggle-group/toggle-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { HeartIcon } from '@vapor-ui/icons';

import { ToggleGroup } from '.';
import { Grid } from '../grid';
import { Toggle } from '../toggle';
import { VStack } from '../v-stack';

export default {
title: 'ToggleGroup',
component: ToggleGroup,
argTypes: {
size: {
control: { type: 'inline-radio' },
options: ['sm', 'md', 'lg', 'xl'],
},
disabled: {
control: { type: 'boolean' },
},
},
} satisfies Meta<typeof ToggleGroup>;

type Story = StoryObj<typeof ToggleGroup>;

export const Default: Story = {
render: (args) => {
return (
<ToggleGroup {...args}>
<Toggle value="item1">
<HeartIcon />
</Toggle>
<Toggle value="item2">
<HeartIcon />
</Toggle>
<Toggle value="item3">
<HeartIcon />
</Toggle>

<Toggle value="item4">
<HeartIcon />
</Toggle>
<Toggle value="item5">
<HeartIcon />
</Toggle>
<Toggle value="item6">
<HeartIcon />
</Toggle>
</ToggleGroup>
);
},
};

export const TestBed: Story = {
render: (args) => {
return (
<Grid.Root templateColumns="repeat(2, 1fr)" templateRows="auto" $css={{ gap: '$100' }}>
<Grid.Item>
<VStack $css={{ gap: '$100', alignItems: 'flex-start' }}>
{renderCases({ size: 'sm' })}
{renderCases({ size: 'md' })}
{renderCases({ size: 'lg' })}
{renderCases({ size: 'xl' })}
</VStack>
</Grid.Item>

<Grid.Item>
<VStack $css={{ gap: '$100', alignItems: 'flex-start' }}>
{renderCases({ size: 'sm', disabled: true })}
{renderCases({ size: 'md', disabled: true })}
{renderCases({ size: 'lg', disabled: true })}
{renderCases({ size: 'xl', disabled: true })}
</VStack>
</Grid.Item>

<ToggleGroup {...args} value={['item3', 'item4']}>
<Toggle value="item1">
<HeartIcon />
</Toggle>
<Toggle value="item2" variant="accent">
<HeartIcon />
</Toggle>
<Toggle value="item3">
<HeartIcon />
</Toggle>

<Toggle value="item4" variant="accent">
<HeartIcon />
</Toggle>
<Toggle value="item5">
<HeartIcon />
</Toggle>
<Toggle value="item6" variant="accent">
<HeartIcon />
</Toggle>
</ToggleGroup>
</Grid.Root>
);
},
};

const renderCases = ({ size, disabled }: ToggleGroup.Props) => {
return (
<ToggleGroup size={size} value={['item3']} disabled={disabled}>
<Toggle value="item1">
<HeartIcon />
</Toggle>
<Toggle value="item2">
<HeartIcon />
</Toggle>
<Toggle value="item3">
<HeartIcon />
</Toggle>
</ToggleGroup>
);
};
195 changes: 195 additions & 0 deletions packages/core/src/components/toggle-group/toggle-group.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'vitest-axe';

import { ToggleGroup } from '.';
import { Toggle } from '../toggle';

const ToggleGroupTest = (props: ToggleGroup.Props) => (
<ToggleGroup {...props}>
<Toggle value="a" aria-label="a" />
<Toggle value="b" aria-label="b" />
<Toggle value="c" aria-label="c" />
</ToggleGroup>
);

describe('<ToggleGroup>', () => {
it('should have no a11y violations', async () => {
const rendered = render(<ToggleGroupTest />);
const result = await axe(rendered.container);

expect(result).toHaveNoViolations();
});

it('should press a toggle when clicked', async () => {
const rendered = render(<ToggleGroupTest />);
const toggleA = rendered.getByRole('button', { name: 'a' });

await userEvent.click(toggleA);

expect(toggleA).toHaveAttribute('aria-pressed', 'true');
});

it('should unpress the previously pressed toggle when another is clicked', async () => {
const rendered = render(<ToggleGroupTest />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

await userEvent.click(toggleA);
await userEvent.click(toggleB);

expect(toggleA).toHaveAttribute('aria-pressed', 'false');
expect(toggleB).toHaveAttribute('aria-pressed', 'true');
});

describe('prop: defaultValue', () => {
it('should mark the matching toggle as pressed', () => {
const rendered = render(<ToggleGroupTest defaultValue={['b']} />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

expect(toggleA).toHaveAttribute('aria-pressed', 'false');
expect(toggleB).toHaveAttribute('aria-pressed', 'true');
});
});

describe('prop: value', () => {
it('should reflect the controlled value', () => {
const rendered = render(<ToggleGroupTest value={['c']} />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleC = rendered.getByRole('button', { name: 'c' });

expect(toggleA).toHaveAttribute('aria-pressed', 'false');
expect(toggleC).toHaveAttribute('aria-pressed', 'true');
});

it('should not change pressed state without onValueChange update', async () => {
const rendered = render(<ToggleGroupTest value={[]} />);
const toggleA = rendered.getByRole('button', { name: 'a' });

await userEvent.click(toggleA);

expect(toggleA).toHaveAttribute('aria-pressed', 'false');
});
});

describe('prop: onValueChange', () => {
it('should call onValueChange with the new value array when clicked', async () => {
const onValueChange = vi.fn();
const rendered = render(<ToggleGroupTest onValueChange={onValueChange} />);
const toggleA = rendered.getByRole('button', { name: 'a' });

await userEvent.click(toggleA);

expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenCalledWith(['a'], expect.anything());
});

it('should call onValueChange with an empty array when the pressed toggle is clicked again', async () => {
const onValueChange = vi.fn();
const rendered = render(
<ToggleGroupTest defaultValue={['a']} onValueChange={onValueChange} />,
);
const toggleA = rendered.getByRole('button', { name: 'a' });

await userEvent.click(toggleA);

expect(toggleA).toHaveAttribute('aria-pressed', 'false');
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenCalledWith([], expect.anything());
});
});

describe('prop: multiple', () => {
it('should allow multiple toggles to be pressed at the same time', async () => {
const rendered = render(<ToggleGroupTest multiple />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

await userEvent.click(toggleA);
await userEvent.click(toggleB);

expect(toggleA).toHaveAttribute('aria-pressed', 'true');
expect(toggleB).toHaveAttribute('aria-pressed', 'true');
});

it('should call onValueChange with the new value array when multiple toggles are pressed', async () => {
const onValueChange = vi.fn();
const rendered = render(<ToggleGroupTest multiple onValueChange={onValueChange} />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

await userEvent.click(toggleA);
await userEvent.click(toggleB);

expect(onValueChange).toHaveBeenCalledTimes(2);
expect(onValueChange).toHaveBeenNthCalledWith(1, ['a'], expect.anything());
expect(onValueChange).toHaveBeenNthCalledWith(2, ['a', 'b'], expect.anything());
});
});

describe('prop: disabled', () => {
it('should disable all toggles', () => {
const rendered = render(<ToggleGroupTest disabled />);
const toggles = rendered.getAllByRole('button');

toggles.forEach((toggle) => {
expect(toggle).toBeDisabled();
});
});

it('should not call onValueChange when a toggle is clicked', async () => {
const onValueChange = vi.fn();
const rendered = render(<ToggleGroupTest disabled onValueChange={onValueChange} />);
const toggleA = rendered.getByRole('button', { name: 'a' });

await userEvent.click(toggleA);

expect(onValueChange).not.toHaveBeenCalled();
});
});

describe('keyboard navigation', () => {
it('should focus the next toggle when pressing ArrowRight', async () => {
const rendered = render(<ToggleGroupTest />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

toggleA.focus();
await userEvent.keyboard('[ArrowRight]');

expect(toggleB).toHaveFocus();
});

it('should focus the previous toggle when pressing ArrowLeft', async () => {
const rendered = render(<ToggleGroupTest />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });

toggleB.focus();
await userEvent.keyboard('[ArrowLeft]');

expect(toggleA).toHaveFocus();
});

it('should focus multiple toggles when pressing ArrowRight multiple times', async () => {
const onValueChange = vi.fn();
const rendered = render(<ToggleGroupTest multiple onValueChange={onValueChange} />);
const toggleA = rendered.getByRole('button', { name: 'a' });
const toggleB = rendered.getByRole('button', { name: 'b' });
const toggleC = rendered.getByRole('button', { name: 'c' });

toggleA.focus();
await userEvent.keyboard('[ArrowRight]');
await userEvent.keyboard('[Space]');
await userEvent.keyboard('[ArrowRight]');
await userEvent.keyboard('[Space]');

expect(toggleB).toHaveAttribute('aria-pressed', 'true');
expect(toggleC).toHaveAttribute('aria-pressed', 'true');
expect(onValueChange).toHaveBeenCalledTimes(2);
expect(onValueChange).toHaveBeenNthCalledWith(1, ['b'], expect.anything());
expect(onValueChange).toHaveBeenNthCalledWith(2, ['b', 'c'], expect.anything());
});
});
});
Loading
Loading