diff --git a/.changeset/wild-pandas-smile.md b/.changeset/wild-pandas-smile.md new file mode 100644 index 00000000..59749693 --- /dev/null +++ b/.changeset/wild-pandas-smile.md @@ -0,0 +1,5 @@ +--- +'@conciv/ui-kit-system': patch +--- + +Add the SegmentGroup primitive: an Ark segment group whose selected indicator slides between segments through zag's indicator position variables, plus a switch thumb that now actually travels. Both snap instead of sliding under reduced motion. diff --git a/packages/ui-kit-system/src/index.tsx b/packages/ui-kit-system/src/index.tsx index 35bb8f1d..b4481256 100644 --- a/packages/ui-kit-system/src/index.tsx +++ b/packages/ui-kit-system/src/index.tsx @@ -30,6 +30,7 @@ export {Toast, ToastGroup, createToaster, type ToasterInstance} from './toast.js export {Avatar} from './avatar.js' export {RelativeTime} from './relative-time.js' export {Tabs} from './tabs.js' +export {SegmentGroup} from './segment-group.js' export {Switch} from './switch.js' export {Swap} from './swap.js' export {Presence} from './presence.js' diff --git a/packages/ui-kit-system/src/segment-group.stories.tsx b/packages/ui-kit-system/src/segment-group.stories.tsx new file mode 100644 index 00000000..472d0a67 --- /dev/null +++ b/packages/ui-kit-system/src/segment-group.stories.tsx @@ -0,0 +1,118 @@ +import {createSignal, For} from 'solid-js' +import type {Meta, StoryObj} from 'storybook-solidjs-vite' +import {expect, within, userEvent, waitFor} from 'storybook/test' +import Monitor from 'lucide-solid/icons/monitor' +import Moon from 'lucide-solid/icons/moon' +import Sun from 'lucide-solid/icons/sun' +import {SegmentGroup} from './segment-group.js' + +const meta: Meta = {title: 'ui-kit-system/SegmentGroup'} +export default meta +type Story = StoryObj + +const DENSITIES = ['Comfortable', 'Cozy', 'Compact'] + +export const Default: Story = { + render: () => { + const [density, setDensity] = createSignal('Cozy') + return ( +
+ setDensity(details.value ?? 'Cozy')} + aria-label="Thread density" + > + + + {(option) => ( + + {option} + + + )} + + +
Density: {density()}
+
+ ) + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Density: Cozy')).toBeVisible() + await userEvent.click(canvas.getByText('Compact')) + await waitFor(() => expect(canvas.getByText('Density: Compact')).toBeVisible()) + }, +} + +const SCHEMES = [ + {value: 'auto', label: 'Auto', icon: Monitor}, + {value: 'light', label: 'Light', icon: Sun}, + {value: 'dark', label: 'Dark', icon: Moon}, +] + +export const Scheme: Story = { + render: () => { + const [scheme, setScheme] = createSignal('auto') + return ( +
+ setScheme(details.value ?? 'auto')} + aria-label="Appearance" + > + + + {(option) => ( + + + + + + )} + + +
Scheme: {scheme()}
+
+ ) + }, + play: async ({canvasElement}) => { + const canvas = within(canvasElement) + await expect(canvas.getByText('Scheme: auto')).toBeVisible() + await userEvent.click(canvas.getByText('Dark')) + await waitFor(() => expect(canvas.getByText('Scheme: dark')).toBeVisible()) + }, +} + +export const Vertical: Story = { + render: () => ( + + + + {(section) => ( + + {section} + + + )} + + + ), +} + +export const Disabled: Story = { + render: () => ( + + + + {(option) => ( + + {option} + + + )} + + + ), +} diff --git a/packages/ui-kit-system/src/segment-group.tsx b/packages/ui-kit-system/src/segment-group.tsx new file mode 100644 index 00000000..1e69ec83 --- /dev/null +++ b/packages/ui-kit-system/src/segment-group.tsx @@ -0,0 +1,32 @@ +import {splitProps, type ComponentProps} from 'solid-js' +import {SegmentGroup as Ark} from '@ark-ui/solid/segment-group' + +const ROOT = + 'relative inline-flex items-center gap-0.5 p-0.5 rounded-chat-surface-sm bg-chat-fill-soft [border:1px_solid_var(--chat-line)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch data-[disabled]:opacity-50' +const INDICATOR = + 'rounded-chat-surface-sm bg-chat-fill-strong [border:1px_solid_var(--chat-line-2)] shadow-chat-sm [width:var(--width)] [height:var(--height)] [left:var(--left)] [top:var(--top)] [--transition-duration:200ms] [--transition-timing-function:var(--chat-ease-expo)] motion-reduce:[--transition-duration:0.01ms]' +const ITEM = + 'relative inline-flex items-center justify-center gap-1.5 min-h-8 py-1 px-3 rounded-chat-surface-sm text-[0.8125rem] font-chat text-chat-text-3 whitespace-nowrap cursor-pointer select-none trans-color-bg hover:text-chat-text-2 data-[state=checked]:text-chat-text-hi data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem] data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed' +const ITEM_TEXT = 'inline-flex items-center gap-1.5' + +function Root(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class', 'orientation']) + return +} + +function Indicator(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +function Item(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +function ItemText(props: ComponentProps) { + const [local, rest] = splitProps(props, ['class']) + return +} + +export const SegmentGroup = Object.assign({}, Ark, {Root, Indicator, Item, ItemText}) diff --git a/packages/ui-kit-system/src/switch.tsx b/packages/ui-kit-system/src/switch.tsx index 015824b2..d4a44488 100644 --- a/packages/ui-kit-system/src/switch.tsx +++ b/packages/ui-kit-system/src/switch.tsx @@ -6,7 +6,7 @@ const ROOT = const CONTROL = 'relative inline-flex items-center w-8 h-4.5 rounded-chat-pill bg-chat-fill-strong [border:1px_solid_var(--chat-line)] trans-btn data-[state=checked]:bg-chat-accent data-[state=checked]:[border-color:var(--chat-accent)] data-[focus-visible]:[outline:0.125rem_solid_var(--chat-accent)] data-[focus-visible]:[outline-offset:0.125rem]' const THUMB = - 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 trans-btn data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent' + 'size-3.5 rounded-chat-pill bg-chat-text translate-x-0.5 [transition:translate_140ms_var(--chat-ease-expo),background-color_120ms_var(--chat-ease)] motion-reduce:[transition-property:background-color] data-[state=checked]:translate-x-3.5 data-[state=checked]:bg-chat-on-accent' const LABEL = 'text-[0.8125rem] font-chat text-chat-text-2' function Root(props: ComponentProps) { diff --git a/packages/ui-kit-system/test/segment-group.browser.test.tsx b/packages/ui-kit-system/test/segment-group.browser.test.tsx new file mode 100644 index 00000000..fea5cfbd --- /dev/null +++ b/packages/ui-kit-system/test/segment-group.browser.test.tsx @@ -0,0 +1,94 @@ +import 'virtual:uno.css' +import {render} from '@solidjs/testing-library' +import {page, userEvent} from 'vitest/browser' +import {createSignal, For} from 'solid-js' +import {expect, it} from 'vitest' +import {SegmentGroup} from '../src/segment-group.js' + +const SCHEMES = ['Auto', 'Light', 'Dark'] + +function mount(options: {defaultValue?: string; disabled?: string} = {}): () => string | null { + const [value, setValue] = createSignal(options.defaultValue ?? null) + render(() => ( + setValue(details.value)} + aria-label="Colour scheme" + > + + + {(scheme) => ( + + {scheme} + + + )} + + + )) + return value +} + +function segmentOf(name: string) { + return page.getByText(name, {exact: true}) +} + +function indicatorOf(): HTMLElement { + const indicator = document.querySelector('[data-scope="segment-group"][data-part="indicator"]') + if (!(indicator instanceof HTMLElement)) throw new Error('the segment group rendered no indicator') + return indicator +} + +it('exposes the segments as one radio group with an accessible name', async () => { + mount({defaultValue: 'Auto'}) + + await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible() + await expect.element(page.getByRole('radio', {name: 'Auto'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Dark'})).not.toBeChecked() +}) + +it('moves the selection to the segment the reader clicks', async () => { + const value = mount({defaultValue: 'Auto'}) + + await segmentOf('Dark').click() + + await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Auto'})).not.toBeChecked() + expect(value()).toBe('Dark') +}) + +it('walks the selection along the group with the arrow keys', async () => { + const value = mount({defaultValue: 'Auto'}) + + await userEvent.tab() + await expect.element(page.getByRole('radio', {name: 'Auto'})).toHaveFocus() + + await userEvent.keyboard('{ArrowRight}') + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + await expect.element(page.getByRole('radio', {name: 'Light'})).toHaveFocus() + expect(value()).toBe('Light') +}) + +it('refuses a disabled segment', async () => { + const value = mount({defaultValue: 'Auto', disabled: 'Dark'}) + + await expect.element(page.getByRole('radio', {name: 'Dark'})).toBeDisabled() + + await segmentOf('Light').click() + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + expect(value()).toBe('Light') +}) + +it('withholds the indicator until a segment is selected', async () => { + mount() + + await expect.element(page.getByRole('radiogroup', {name: 'Colour scheme'})).toBeVisible() + expect(indicatorOf().hidden).toBe(true) + + await segmentOf('Light').click() + + await expect.element(page.getByRole('radio', {name: 'Light'})).toBeChecked() + expect(indicatorOf().hidden).toBe(false) +})