Skip to content
Open
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/wild-pandas-smile.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/ui-kit-system/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
118 changes: 118 additions & 0 deletions packages/ui-kit-system/src/segment-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<SegmentGroup.Root
value={density()}
onValueChange={(details) => setDensity(details.value ?? 'Cozy')}
aria-label="Thread density"
>
<SegmentGroup.Indicator />
<For each={DENSITIES}>
{(option) => (
<SegmentGroup.Item value={option}>
<SegmentGroup.ItemText>{option}</SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
)}
</For>
</SegmentGroup.Root>
<div>Density: {density()}</div>
</div>
)
},
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 (
<div>
<SegmentGroup.Root
value={scheme()}
onValueChange={(details) => setScheme(details.value ?? 'auto')}
aria-label="Appearance"
>
<SegmentGroup.Indicator />
<For each={SCHEMES}>
{(option) => (
<SegmentGroup.Item value={option.value}>
<SegmentGroup.ItemText>
<option.icon size={14} aria-hidden="true" />
{option.label}
</SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
)}
</For>
</SegmentGroup.Root>
<div>Scheme: {scheme()}</div>
</div>
)
},
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: () => (
<SegmentGroup.Root defaultValue="Appearance" orientation="vertical" aria-label="Settings section">
<SegmentGroup.Indicator />
<For each={['Appearance', 'Behaviour', 'Advanced']}>
{(section) => (
<SegmentGroup.Item value={section}>
<SegmentGroup.ItemText>{section}</SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
)}
</For>
</SegmentGroup.Root>
),
}

export const Disabled: Story = {
render: () => (
<SegmentGroup.Root defaultValue="Auto" aria-label="Colour scheme">
<SegmentGroup.Indicator />
<For each={['Auto', 'Light', 'Dark']}>
{(option) => (
<SegmentGroup.Item value={option} disabled={option === 'Dark'}>
<SegmentGroup.ItemText>{option}</SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
)}
</For>
</SegmentGroup.Root>
),
}
32 changes: 32 additions & 0 deletions packages/ui-kit-system/src/segment-group.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Ark.Root>) {
const [local, rest] = splitProps(props, ['class', 'orientation'])
return <Ark.Root orientation={local.orientation ?? 'horizontal'} {...rest} class={`${ROOT} ${local.class ?? ''}`} />
}

function Indicator(props: ComponentProps<typeof Ark.Indicator>) {
const [local, rest] = splitProps(props, ['class'])
return <Ark.Indicator {...rest} class={`${INDICATOR} ${local.class ?? ''}`} />
}

function Item(props: ComponentProps<typeof Ark.Item>) {
const [local, rest] = splitProps(props, ['class'])
return <Ark.Item {...rest} class={`${ITEM} ${local.class ?? ''}`} />
}

function ItemText(props: ComponentProps<typeof Ark.ItemText>) {
const [local, rest] = splitProps(props, ['class'])
return <Ark.ItemText {...rest} class={`${ITEM_TEXT} ${local.class ?? ''}`} />
}

export const SegmentGroup = Object.assign({}, Ark, {Root, Indicator, Item, ItemText})
2 changes: 1 addition & 1 deletion packages/ui-kit-system/src/switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Ark.Root>) {
Expand Down
94 changes: 94 additions & 0 deletions packages/ui-kit-system/test/segment-group.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(options.defaultValue ?? null)
render(() => (
<SegmentGroup.Root
value={value() ?? undefined}
onValueChange={(details) => setValue(details.value)}
aria-label="Colour scheme"
>
<SegmentGroup.Indicator />
<For each={SCHEMES}>
{(scheme) => (
<SegmentGroup.Item value={scheme} disabled={scheme === options.disabled}>
<SegmentGroup.ItemText>{scheme}</SegmentGroup.ItemText>
<SegmentGroup.ItemHiddenInput />
</SegmentGroup.Item>
)}
</For>
</SegmentGroup.Root>
))
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)
})
Loading