@proteos/ui 0.56.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-IKAX76MB.js → chunk-NSE4VW3Z.js} +45 -21
- package/dist/chunk-NSE4VW3Z.js.map +1 -0
- package/dist/{chunk-MWAXK66H.cjs → chunk-NWGG5WFL.cjs} +45 -21
- package/dist/chunk-NWGG5WFL.cjs.map +1 -0
- package/dist/index.cjs +336 -351
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -12
- package/dist/index.d.ts +41 -12
- package/dist/index.js +83 -98
- package/dist/index.js.map +1 -1
- package/dist/{pdf-view-MH75RVQP.js → pdf-view-RQ2OZYHJ.js} +3 -3
- package/dist/{pdf-view-MH75RVQP.js.map → pdf-view-RQ2OZYHJ.js.map} +1 -1
- package/dist/{pdf-view-K4MMVHVY.cjs → pdf-view-RRMAUK3L.cjs} +7 -7
- package/dist/{pdf-view-K4MMVHVY.cjs.map → pdf-view-RRMAUK3L.cjs.map} +1 -1
- package/package.json +2 -2
- package/src/components/avatar/avatar.stories.tsx +13 -0
- package/src/components/avatar/avatar.tsx +16 -9
- package/src/components/badge-multi-select/badge-multi-select.tsx +2 -2
- package/src/components/button/button.stories.tsx +202 -97
- package/src/components/button/button.test.tsx +40 -7
- package/src/components/button/button.tsx +71 -18
- package/src/components/combobox/combobox.tsx +2 -2
- package/src/components/currency-input/currency-input.tsx +2 -2
- package/src/components/date-picker/calendar-internals.tsx +1 -1
- package/src/components/date-picker/date-picker.tsx +3 -3
- package/src/components/date-time-picker/date-time-picker.tsx +3 -3
- package/src/components/icon-picker/icon-picker.tsx +2 -2
- package/src/components/image-lightbox/image-lightbox.tsx +2 -2
- package/src/components/menu/menu.tsx +2 -2
- package/src/components/modal/modal.tsx +4 -4
- package/src/components/multi-select/multi-select.tsx +2 -2
- package/src/components/popover/popover.tsx +2 -2
- package/src/components/record-filter/nested-group.tsx +2 -2
- package/src/components/record-filter/record-filter-builder-panel.tsx +11 -23
- package/src/components/record-filter/record-filter-chips.tsx +3 -10
- package/src/components/segmented-control/segmented-control.stories.tsx +134 -8
- package/src/components/segmented-control/segmented-control.test.tsx +81 -8
- package/src/components/segmented-control/segmented-control.tsx +38 -19
- package/src/components/select/select.tsx +2 -2
- package/src/components/time-field/time-field.tsx +1 -1
- package/src/components/toast/toast.tsx +2 -2
- package/src/components/tooltip/tooltip.tsx +2 -2
- package/src/styles.css +58 -0
- package/dist/chunk-IKAX76MB.js.map +0 -1
- package/dist/chunk-MWAXK66H.cjs.map +0 -1
|
@@ -157,8 +157,8 @@ export function CurrencyInput({
|
|
|
157
157
|
className={cn(
|
|
158
158
|
'min-w-[9rem] rounded-md border border-rule bg-surface p-1',
|
|
159
159
|
'shadow-[var(--shadow-raised)]',
|
|
160
|
-
'entering:animate-
|
|
161
|
-
'exiting:animate-
|
|
160
|
+
'entering:animate-enter-slide-down',
|
|
161
|
+
'exiting:animate-exit-slide-up',
|
|
162
162
|
)}
|
|
163
163
|
>
|
|
164
164
|
<ListBox className="max-h-72 overflow-auto outline-none">
|
|
@@ -124,7 +124,7 @@ export function CalendarSurface() {
|
|
|
124
124
|
'data-[outside-month]:text-ink-4',
|
|
125
125
|
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
126
126
|
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
127
|
-
'data-[selected]:bg-accent data-[selected]:text-
|
|
127
|
+
'data-[selected]:bg-accent data-[selected]:text-tile-fg',
|
|
128
128
|
'data-[selected]:data-[hovered]:bg-accent-2',
|
|
129
129
|
'data-[disabled]:opacity-30 data-[disabled]:cursor-not-allowed',
|
|
130
130
|
'data-[unavailable]:line-through data-[unavailable]:text-ink-4',
|
|
@@ -86,7 +86,7 @@ export function DatePicker<T extends DateValue>({
|
|
|
86
86
|
'data-[placeholder]:text-ink-4',
|
|
87
87
|
'data-[type=literal]:text-ink-4',
|
|
88
88
|
// segment focus treatment: indigo fill, paper text
|
|
89
|
-
'data-[focused]:bg-accent data-[focused]:text-
|
|
89
|
+
'data-[focused]:bg-accent data-[focused]:text-tile-fg data-[focused]:rounded-[3px]',
|
|
90
90
|
'data-[disabled]:cursor-not-allowed',
|
|
91
91
|
)}
|
|
92
92
|
/>
|
|
@@ -127,8 +127,8 @@ export function DatePicker<T extends DateValue>({
|
|
|
127
127
|
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
128
128
|
'rounded-md border border-rule bg-surface p-4',
|
|
129
129
|
'shadow-[var(--shadow-raised)]',
|
|
130
|
-
'entering:animate-
|
|
131
|
-
'exiting:animate-
|
|
130
|
+
'entering:animate-enter-slide-down',
|
|
131
|
+
'exiting:animate-exit-slide-up',
|
|
132
132
|
)}
|
|
133
133
|
>
|
|
134
134
|
<Dialog className="outline-none">
|
|
@@ -43,7 +43,7 @@ const segmentStyles = cn(
|
|
|
43
43
|
'data-[placeholder]:text-ink-4',
|
|
44
44
|
'data-[type=literal]:text-ink-4',
|
|
45
45
|
// segment focus treatment: indigo fill, paper text
|
|
46
|
-
'data-[focused]:bg-accent data-[focused]:text-
|
|
46
|
+
'data-[focused]:bg-accent data-[focused]:text-tile-fg data-[focused]:rounded-[3px]',
|
|
47
47
|
'data-[disabled]:cursor-not-allowed',
|
|
48
48
|
)
|
|
49
49
|
|
|
@@ -185,8 +185,8 @@ export function DateTimePicker<T extends DateValue>({
|
|
|
185
185
|
'w-[var(--trigger-width)] min-w-[18rem] max-w-[500px]',
|
|
186
186
|
'rounded-md border border-rule bg-surface p-4',
|
|
187
187
|
'shadow-[var(--shadow-raised)]',
|
|
188
|
-
'entering:animate-
|
|
189
|
-
'exiting:animate-
|
|
188
|
+
'entering:animate-enter-slide-down',
|
|
189
|
+
'exiting:animate-exit-slide-up',
|
|
190
190
|
)}
|
|
191
191
|
>
|
|
192
192
|
<Dialog className="outline-none">
|
|
@@ -227,8 +227,8 @@ export function IconPicker({
|
|
|
227
227
|
className={cn(
|
|
228
228
|
'w-[316px] rounded-md border border-rule bg-surface p-2',
|
|
229
229
|
'shadow-[var(--shadow-raised)]',
|
|
230
|
-
'entering:animate-
|
|
231
|
-
'exiting:animate-
|
|
230
|
+
'entering:animate-enter-slide-down',
|
|
231
|
+
'exiting:animate-exit-slide-up',
|
|
232
232
|
)}
|
|
233
233
|
>
|
|
234
234
|
<div className="flex items-center gap-1.5 pb-2">
|
|
@@ -23,8 +23,8 @@ export const imageLightboxStyles = tv({
|
|
|
23
23
|
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
|
24
24
|
// Deeper scrim than Modal's 40% — the image needs the page fully dimmed.
|
|
25
25
|
'bg-[color-mix(in_oklab,var(--color-ink)_72%,transparent)]',
|
|
26
|
-
'entering:animate-
|
|
27
|
-
'exiting:animate-
|
|
26
|
+
'entering:animate-enter-fade',
|
|
27
|
+
'exiting:animate-exit-fade',
|
|
28
28
|
],
|
|
29
29
|
})
|
|
30
30
|
|
|
@@ -61,8 +61,8 @@ export function MenuPopover({ className, offset = 4, placement = 'bottom start',
|
|
|
61
61
|
cn(
|
|
62
62
|
'min-w-[200px] p-1 bg-surface border border-rule rounded-md',
|
|
63
63
|
'shadow-[var(--shadow-float)] outline-none z-[1200]',
|
|
64
|
-
'entering:animate-
|
|
65
|
-
'exiting:animate-
|
|
64
|
+
'entering:animate-enter-zoom',
|
|
65
|
+
'exiting:animate-exit-zoom',
|
|
66
66
|
typeof className === 'function' ? className(renderProps) : className,
|
|
67
67
|
)
|
|
68
68
|
}
|
|
@@ -67,8 +67,8 @@ export function ModalContent({
|
|
|
67
67
|
'fixed inset-0 z-50 flex items-center justify-center p-4',
|
|
68
68
|
// Warm ink scrim — stays in the Proteos palette (no pure black).
|
|
69
69
|
'bg-[color-mix(in_oklab,var(--color-ink)_40%,transparent)]',
|
|
70
|
-
'entering:animate-
|
|
71
|
-
'exiting:animate-
|
|
70
|
+
'entering:animate-enter-fade',
|
|
71
|
+
'exiting:animate-exit-fade',
|
|
72
72
|
typeof className === 'function' ? className(renderProps) : className,
|
|
73
73
|
)
|
|
74
74
|
}
|
|
@@ -82,8 +82,8 @@ export function ModalContent({
|
|
|
82
82
|
'flex flex-col max-h-[calc(100dvh-2rem)]',
|
|
83
83
|
'bg-surface border border-rule rounded-lg',
|
|
84
84
|
'shadow-[var(--shadow-float)]',
|
|
85
|
-
'entering:animate-
|
|
86
|
-
'exiting:animate-
|
|
85
|
+
'entering:animate-enter-zoom',
|
|
86
|
+
'exiting:animate-exit-zoom',
|
|
87
87
|
modalClassName,
|
|
88
88
|
)}
|
|
89
89
|
>
|
|
@@ -185,8 +185,8 @@ export function MultiSelect<T extends Key>({
|
|
|
185
185
|
// (defaultOpen) — no browser outline around the whole panel.
|
|
186
186
|
'outline-none rounded-md border border-rule bg-surface p-1',
|
|
187
187
|
'shadow-[var(--shadow-raised)]',
|
|
188
|
-
'entering:animate-
|
|
189
|
-
'exiting:animate-
|
|
188
|
+
'entering:animate-enter-slide-down',
|
|
189
|
+
'exiting:animate-exit-slide-up',
|
|
190
190
|
)}
|
|
191
191
|
>
|
|
192
192
|
{isSearchable ? (
|
|
@@ -64,8 +64,8 @@ export function PopoverContent({
|
|
|
64
64
|
cn(
|
|
65
65
|
'rounded-md border border-rule bg-surface',
|
|
66
66
|
'shadow-[var(--shadow-float)]',
|
|
67
|
-
'entering:animate-
|
|
68
|
-
'exiting:animate-
|
|
67
|
+
'entering:animate-enter-slide-down',
|
|
68
|
+
'exiting:animate-exit-slide-up',
|
|
69
69
|
panelClassName,
|
|
70
70
|
typeof className === 'function' ? className(renderProps) : className,
|
|
71
71
|
)
|
|
@@ -84,8 +84,8 @@ export function NestedGroup({
|
|
|
84
84
|
<SegmentedControl<LogicalOperator>
|
|
85
85
|
aria-label="Group joiner"
|
|
86
86
|
options={[
|
|
87
|
-
{ id: 'and', label: '
|
|
88
|
-
{ id: 'or', label: '
|
|
87
|
+
{ id: 'and', label: 'and' },
|
|
88
|
+
{ id: 'or', label: 'or' },
|
|
89
89
|
]}
|
|
90
90
|
value={group.logicalOperator}
|
|
91
91
|
onChange={(next) => onChange({ ...group, logicalOperator: next })}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cn } from '../../lib/cn'
|
|
2
|
+
import { Button } from '../button/button'
|
|
2
3
|
import { SegmentedControl } from '../segmented-control/segmented-control'
|
|
3
4
|
import { type FilterValueInputRenderer, RenderValueInputProvider } from './filter-value-input'
|
|
4
5
|
import { RecordFilterEditor } from './record-filter-editor'
|
|
@@ -86,8 +87,8 @@ export function RecordFilterBuilderPanel({
|
|
|
86
87
|
<SegmentedControl<LogicalOperator>
|
|
87
88
|
aria-label="Joiner"
|
|
88
89
|
options={[
|
|
89
|
-
{ id: 'and', label: '
|
|
90
|
-
{ id: 'or', label: '
|
|
90
|
+
{ id: 'and', label: 'and' },
|
|
91
|
+
{ id: 'or', label: 'or' },
|
|
91
92
|
]}
|
|
92
93
|
value={filter.logicalOperator}
|
|
93
94
|
onChange={setJoiner}
|
|
@@ -190,16 +191,11 @@ export function RecordFilterBuilderPanel({
|
|
|
190
191
|
</button>
|
|
191
192
|
<div className="flex items-center gap-1.5">
|
|
192
193
|
{onSaveView ? (
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-sm',
|
|
199
|
-
'font-sans text-[13px] text-ink-2 hover:bg-surface-2 hover:text-ink',
|
|
200
|
-
'disabled:opacity-40 disabled:pointer-events-none',
|
|
201
|
-
'outline-none cursor-pointer',
|
|
202
|
-
)}
|
|
194
|
+
<Button
|
|
195
|
+
variant="ghost"
|
|
196
|
+
size="sm"
|
|
197
|
+
onPress={onSaveView}
|
|
198
|
+
isDisabled={totalChildren === 0}
|
|
203
199
|
>
|
|
204
200
|
<svg
|
|
205
201
|
aria-hidden
|
|
@@ -215,19 +211,11 @@ export function RecordFilterBuilderPanel({
|
|
|
215
211
|
<path d="M5 2v3h4V2" />
|
|
216
212
|
</svg>
|
|
217
213
|
Save as view
|
|
218
|
-
</
|
|
214
|
+
</Button>
|
|
219
215
|
) : null}
|
|
220
|
-
<
|
|
221
|
-
type="button"
|
|
222
|
-
onClick={onClose}
|
|
223
|
-
className={cn(
|
|
224
|
-
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-sm',
|
|
225
|
-
'bg-ink text-surface font-mono text-[12px]',
|
|
226
|
-
'hover:bg-accent-ink outline-none cursor-pointer',
|
|
227
|
-
)}
|
|
228
|
-
>
|
|
216
|
+
<Button variant="cta" size="sm" onPress={onClose}>
|
|
229
217
|
Done
|
|
230
|
-
</
|
|
218
|
+
</Button>
|
|
231
219
|
</div>
|
|
232
220
|
</div>
|
|
233
221
|
</div>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { cn } from '../../lib/cn'
|
|
3
|
+
import { Button } from '../button/button'
|
|
3
4
|
import { PopoverContent } from '../popover/popover'
|
|
4
5
|
import { AttributePicker } from './attribute-picker'
|
|
5
6
|
import { ChipValue, type ChipValueItemRenderer } from './chip-value'
|
|
@@ -489,17 +490,9 @@ function ChipMiniEditor({
|
|
|
489
490
|
onChange={onChangeValue}
|
|
490
491
|
/>
|
|
491
492
|
</div>
|
|
492
|
-
<
|
|
493
|
-
type="submit"
|
|
494
|
-
className={cn(
|
|
495
|
-
'shrink-0 inline-flex items-center px-2.5 py-1.5 rounded-md',
|
|
496
|
-
'bg-ink text-surface font-mono text-[12px]',
|
|
497
|
-
'hover:bg-accent-ink cursor-pointer outline-none',
|
|
498
|
-
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
499
|
-
)}
|
|
500
|
-
>
|
|
493
|
+
<Button type="submit" variant="cta" size="sm" className="shrink-0">
|
|
501
494
|
Save
|
|
502
|
-
</
|
|
495
|
+
</Button>
|
|
503
496
|
</form>
|
|
504
497
|
)
|
|
505
498
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { useState } from 'react'
|
|
3
3
|
|
|
4
|
-
import { SegmentedControl } from './segmented-control'
|
|
4
|
+
import { SegmentedControl, segmentedControlStyles } from './segmented-control'
|
|
5
5
|
|
|
6
6
|
const meta: Meta<typeof SegmentedControl> = {
|
|
7
7
|
title: 'Components/SegmentedControl',
|
|
@@ -19,8 +19,8 @@ export const AndOrJoiner: Story = {
|
|
|
19
19
|
<SegmentedControl
|
|
20
20
|
aria-label="Joiner"
|
|
21
21
|
options={[
|
|
22
|
-
{ id: 'and', label: '
|
|
23
|
-
{ id: 'or', label: '
|
|
22
|
+
{ id: 'and', label: 'and' },
|
|
23
|
+
{ id: 'or', label: 'or' },
|
|
24
24
|
]}
|
|
25
25
|
value={value}
|
|
26
26
|
onChange={setValue}
|
|
@@ -49,15 +49,15 @@ export const BooleanTrueFalse: Story = {
|
|
|
49
49
|
|
|
50
50
|
export const ThreeSegments: Story = {
|
|
51
51
|
render: () => {
|
|
52
|
-
const [value, setValue] = useState<'
|
|
52
|
+
const [value, setValue] = useState<'list' | 'board' | 'timeline'>('list')
|
|
53
53
|
return (
|
|
54
54
|
<SegmentedControl
|
|
55
55
|
size="md"
|
|
56
|
-
aria-label="
|
|
56
|
+
aria-label="View"
|
|
57
57
|
options={[
|
|
58
|
-
{ id: '
|
|
59
|
-
{ id: '
|
|
60
|
-
{ id: '
|
|
58
|
+
{ id: 'list', label: 'List' },
|
|
59
|
+
{ id: 'board', label: 'Board' },
|
|
60
|
+
{ id: 'timeline', label: 'Timeline' },
|
|
61
61
|
]}
|
|
62
62
|
value={value}
|
|
63
63
|
onChange={setValue}
|
|
@@ -65,3 +65,129 @@ export const ThreeSegments: Story = {
|
|
|
65
65
|
)
|
|
66
66
|
},
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
const label = 'font-mono text-[11px] uppercase tracking-[0.08em] text-ink-4'
|
|
70
|
+
|
|
71
|
+
export const Sizes: Story = {
|
|
72
|
+
render: () => {
|
|
73
|
+
const [value, setValue] = useState<'list' | 'board'>('list')
|
|
74
|
+
const options = [
|
|
75
|
+
{ id: 'list' as const, label: 'List' },
|
|
76
|
+
{ id: 'board' as const, label: 'Board' },
|
|
77
|
+
]
|
|
78
|
+
return (
|
|
79
|
+
<div className="grid grid-cols-[80px_1fr] items-center gap-x-6 gap-y-4">
|
|
80
|
+
<div className={label}>sm</div>
|
|
81
|
+
<SegmentedControl aria-label="View (sm)" size="sm" options={options} value={value} onChange={setValue} />
|
|
82
|
+
<div className={label}>md</div>
|
|
83
|
+
<SegmentedControl aria-label="View (md)" size="md" options={options} value={value} onChange={setValue} />
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const Glyph = ({ d }: { d: string }) => (
|
|
90
|
+
<svg viewBox="0 0 16 16" fill="none" className="size-3" aria-hidden>
|
|
91
|
+
<path d={d} stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
92
|
+
</svg>
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
export const WithIcons: Story = {
|
|
96
|
+
render: () => {
|
|
97
|
+
const [value, setValue] = useState<'base' | 'sm'>('base')
|
|
98
|
+
return (
|
|
99
|
+
<SegmentedControl
|
|
100
|
+
aria-label="Viewport"
|
|
101
|
+
options={[
|
|
102
|
+
{ id: 'base', label: <Glyph d="M2 4h12v8H2zM6 14h4" />, 'aria-label': 'Desktop' },
|
|
103
|
+
{ id: 'sm', label: <Glyph d="M5 2h6v12H5zM7 12h2" />, 'aria-label': 'Phone' },
|
|
104
|
+
]}
|
|
105
|
+
value={value}
|
|
106
|
+
onChange={setValue}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const Wrap: Story = {
|
|
113
|
+
parameters: { layout: 'padded' },
|
|
114
|
+
render: () => {
|
|
115
|
+
const [value, setValue] = useState<string>('1/2')
|
|
116
|
+
const options = ['auto', '1/4', '1/3', '1/2', '2/3', '3/4', 'full', 'fit', 'fill', 'grow', 'shrink', 'none'].map(
|
|
117
|
+
(id) => ({ id, label: id }),
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
<div className="w-[240px]">
|
|
121
|
+
<SegmentedControl aria-label="Column span" wrap options={options} value={value} onChange={setValue} />
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const DisabledOption: Story = {
|
|
128
|
+
render: () => {
|
|
129
|
+
const [value, setValue] = useState<'draft' | 'live' | 'archived'>('draft')
|
|
130
|
+
return (
|
|
131
|
+
<SegmentedControl
|
|
132
|
+
size="md"
|
|
133
|
+
aria-label="Status"
|
|
134
|
+
options={[
|
|
135
|
+
{ id: 'draft', label: 'Draft' },
|
|
136
|
+
{ id: 'live', label: 'Live' },
|
|
137
|
+
{ id: 'archived', label: 'Archived', isDisabled: true },
|
|
138
|
+
]}
|
|
139
|
+
value={value}
|
|
140
|
+
onChange={setValue}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const Unselected: Story = {
|
|
147
|
+
render: () => {
|
|
148
|
+
const [value, setValue] = useState<'yes' | 'no' | null>(null)
|
|
149
|
+
return (
|
|
150
|
+
<SegmentedControl
|
|
151
|
+
aria-label="Answer"
|
|
152
|
+
options={[
|
|
153
|
+
{ id: 'yes', label: 'Yes' },
|
|
154
|
+
{ id: 'no', label: 'No' },
|
|
155
|
+
]}
|
|
156
|
+
value={value}
|
|
157
|
+
onChange={setValue}
|
|
158
|
+
/>
|
|
159
|
+
)
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Pinned states on static spans (RAC owns the live data-* attributes). */
|
|
164
|
+
export const States: Story = {
|
|
165
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
166
|
+
render: () => {
|
|
167
|
+
const styles = segmentedControlStyles({ size: 'md' })
|
|
168
|
+
const pinned = [
|
|
169
|
+
{ title: 'rest', attrs: {} },
|
|
170
|
+
{ title: 'hover', attrs: { 'data-hovered': '' } },
|
|
171
|
+
{ title: 'press', attrs: { 'data-pressed': '' } },
|
|
172
|
+
{ title: 'selected', attrs: { 'data-selected': '' } },
|
|
173
|
+
{ title: 'selected + focus', attrs: { 'data-selected': '', 'data-focus-visible': '' } },
|
|
174
|
+
{ title: 'disabled', attrs: { 'data-disabled': '' } },
|
|
175
|
+
]
|
|
176
|
+
return (
|
|
177
|
+
<div className="grid grid-cols-[140px_1fr] items-center gap-x-6 gap-y-3">
|
|
178
|
+
{pinned.map((state) => (
|
|
179
|
+
<div key={state.title} className="contents">
|
|
180
|
+
<div className={label}>{state.title}</div>
|
|
181
|
+
<div className={styles.group()}>
|
|
182
|
+
<span className={styles.segment()}>List</span>
|
|
183
|
+
<span {...state.attrs} className={styles.segment()}>
|
|
184
|
+
Board
|
|
185
|
+
</span>
|
|
186
|
+
<span className={styles.segment()}>Timeline</span>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
},
|
|
193
|
+
}
|
|
@@ -5,14 +5,22 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
5
5
|
|
|
6
6
|
import { SegmentedControl } from './segmented-control'
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
type Joiner = 'and' | 'or'
|
|
9
|
+
|
|
10
|
+
function Harness({
|
|
11
|
+
onChange,
|
|
12
|
+
initial = 'and',
|
|
13
|
+
}: {
|
|
14
|
+
onChange?: (v: Joiner) => void
|
|
15
|
+
initial?: Joiner | null
|
|
16
|
+
}) {
|
|
17
|
+
const [value, setValue] = useState<Joiner | null>(initial)
|
|
10
18
|
return (
|
|
11
19
|
<SegmentedControl
|
|
12
20
|
aria-label="Joiner"
|
|
13
21
|
options={[
|
|
14
|
-
{ id: 'and', label: '
|
|
15
|
-
{ id: 'or', label: '
|
|
22
|
+
{ id: 'and', label: 'and' },
|
|
23
|
+
{ id: 'or', label: 'or' },
|
|
16
24
|
]}
|
|
17
25
|
value={value}
|
|
18
26
|
onChange={(next) => {
|
|
@@ -32,16 +40,81 @@ describe('SegmentedControl', () => {
|
|
|
32
40
|
|
|
33
41
|
it('reflects the controlled value', () => {
|
|
34
42
|
render(<Harness />)
|
|
35
|
-
expect(screen.getByRole('radio', { name: '
|
|
36
|
-
expect(screen.getByRole('radio', { name: '
|
|
43
|
+
expect(screen.getByRole('radio', { name: 'and' })).toBeChecked()
|
|
44
|
+
expect(screen.getByRole('radio', { name: 'or' })).not.toBeChecked()
|
|
37
45
|
})
|
|
38
46
|
|
|
39
47
|
it('changes value on click', async () => {
|
|
40
48
|
const user = userEvent.setup()
|
|
41
49
|
const onChange = vi.fn()
|
|
42
50
|
render(<Harness onChange={onChange} />)
|
|
43
|
-
await user.click(screen.getByRole('radio', { name: '
|
|
51
|
+
await user.click(screen.getByRole('radio', { name: 'or' }))
|
|
52
|
+
expect(onChange).toHaveBeenLastCalledWith('or')
|
|
53
|
+
expect(screen.getByRole('radio', { name: 'or' })).toBeChecked()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('moves selection with the arrow keys', async () => {
|
|
57
|
+
const user = userEvent.setup()
|
|
58
|
+
const onChange = vi.fn()
|
|
59
|
+
render(<Harness onChange={onChange} />)
|
|
60
|
+
await user.tab()
|
|
61
|
+
expect(screen.getByRole('radio', { name: 'and' })).toHaveFocus()
|
|
62
|
+
await user.keyboard('{ArrowRight}')
|
|
44
63
|
expect(onChange).toHaveBeenLastCalledWith('or')
|
|
45
|
-
expect(screen.getByRole('radio', { name: '
|
|
64
|
+
expect(screen.getByRole('radio', { name: 'or' })).toBeChecked()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders no selected segment for a null value', () => {
|
|
68
|
+
render(<Harness initial={null} />)
|
|
69
|
+
for (const radio of screen.getAllByRole('radio')) {
|
|
70
|
+
expect(radio).not.toBeChecked()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not select a disabled option', async () => {
|
|
75
|
+
const user = userEvent.setup()
|
|
76
|
+
const onChange = vi.fn()
|
|
77
|
+
function Disabled() {
|
|
78
|
+
const [value, setValue] = useState<'a' | 'b'>('a')
|
|
79
|
+
return (
|
|
80
|
+
<SegmentedControl
|
|
81
|
+
aria-label="Mode"
|
|
82
|
+
options={[
|
|
83
|
+
{ id: 'a', label: 'A' },
|
|
84
|
+
{ id: 'b', label: 'B', isDisabled: true },
|
|
85
|
+
]}
|
|
86
|
+
value={value}
|
|
87
|
+
onChange={(next) => {
|
|
88
|
+
setValue(next)
|
|
89
|
+
onChange(next)
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
render(<Disabled />)
|
|
95
|
+
await user.click(screen.getByRole('radio', { name: 'B' }))
|
|
96
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
97
|
+
expect(screen.getByRole('radio', { name: 'A' })).toBeChecked()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('names icon-only segments through aria-label', () => {
|
|
101
|
+
function Icons() {
|
|
102
|
+
const [value, setValue] = useState<'list' | 'board'>('list')
|
|
103
|
+
return (
|
|
104
|
+
<SegmentedControl
|
|
105
|
+
aria-label="View"
|
|
106
|
+
wrap
|
|
107
|
+
options={[
|
|
108
|
+
{ id: 'list', label: <svg aria-hidden />, 'aria-label': 'List' },
|
|
109
|
+
{ id: 'board', label: <svg aria-hidden />, 'aria-label': 'Board' },
|
|
110
|
+
]}
|
|
111
|
+
value={value}
|
|
112
|
+
onChange={setValue}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
render(<Icons />)
|
|
117
|
+
expect(screen.getByRole('radio', { name: 'List' })).toBeChecked()
|
|
118
|
+
expect(screen.getByRole('radio', { name: 'Board' })).toBeInTheDocument()
|
|
46
119
|
})
|
|
47
120
|
})
|
|
@@ -9,11 +9,16 @@ import { tv, type VariantProps } from 'tailwind-variants'
|
|
|
9
9
|
import { cn } from '../../lib/cn'
|
|
10
10
|
|
|
11
11
|
/* =========================================================================
|
|
12
|
-
SegmentedControl — small horizontal exclusive-choice group
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
SegmentedControl — small horizontal exclusive-choice group: a recessed
|
|
13
|
+
`surface-2` track with the selected segment lifted as a white pill. Wraps
|
|
14
|
+
RAC RadioGroup so keyboard nav and focus management are correct (arrow keys
|
|
15
|
+
move between segments, one tab stop). Used for compact toggles like view
|
|
16
|
+
switchers, AND/OR joiners and true/false booleans where a full RadioGroup
|
|
16
17
|
feels heavy.
|
|
18
|
+
|
|
19
|
+
Radii are concentric: 9px (chip) track − 2px padding − 1px hairline = 6px
|
|
20
|
+
(sm) pill. Labels are mode names, so they follow buttons: Inter 500,
|
|
21
|
+
sentence case — not the uppercase mono reserved for eyebrows.
|
|
17
22
|
========================================================================= */
|
|
18
23
|
|
|
19
24
|
export const segmentedControlStyles = tv({
|
|
@@ -22,28 +27,35 @@ export const segmentedControlStyles = tv({
|
|
|
22
27
|
// stretching parent (e.g. a `flex flex-col` card body, whose default
|
|
23
28
|
// align-items:stretch would otherwise pull it to full width). Consumers
|
|
24
29
|
// can still opt into full width by passing `w-full` via className.
|
|
25
|
-
group: 'inline-flex w-fit
|
|
30
|
+
group: 'inline-flex w-fit gap-px p-0.5 rounded-chip bg-surface-2 border border-rule',
|
|
26
31
|
segment: [
|
|
27
|
-
'inline-flex items-center justify-center cursor-pointer',
|
|
28
|
-
'font-
|
|
29
|
-
'transition-[background-color,color] duration-150',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
'inline-flex items-center justify-center gap-1 cursor-pointer select-none',
|
|
33
|
+
'font-sans font-medium tracking-[-0.005em] rounded-sm outline-none text-ink-3',
|
|
34
|
+
'transition-[background-color,color,box-shadow] duration-150',
|
|
35
|
+
// Unselected: hover/press tint into the track colour. `selected` is
|
|
36
|
+
// declared after `hovered` in styles.css, so the pill wins over these.
|
|
37
|
+
'hovered:text-ink hovered:bg-[color-mix(in_oklab,var(--color-ink)_6%,var(--color-surface-2))]',
|
|
38
|
+
'pressed:bg-[color-mix(in_oklab,var(--color-ink)_10%,var(--color-surface-2))]',
|
|
39
|
+
// Selected: lifted white pill, static.
|
|
40
|
+
'selected:bg-surface selected:text-ink selected:shadow-[var(--shadow-rest)]',
|
|
32
41
|
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
42
|
+
'selected:data-[focus-visible]:shadow-[var(--shadow-focus),var(--shadow-rest)]',
|
|
33
43
|
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
34
44
|
],
|
|
35
45
|
},
|
|
36
46
|
variants: {
|
|
37
47
|
size: {
|
|
38
|
-
sm: { segment: 'px-2 py-0.5 text-[
|
|
39
|
-
md: { segment: 'px-2.5 py-1 text-[
|
|
48
|
+
sm: { segment: 'px-2 py-0.5 text-[11.5px] min-h-[22px]' },
|
|
49
|
+
md: { segment: 'px-2.5 py-1 text-[12.5px] min-h-[26px]' },
|
|
40
50
|
},
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
// Dense flow-wrapping register (designer inspector): the group becomes a
|
|
52
|
+
// wrapping flex row and every segment grows to share the line.
|
|
53
|
+
wrap: {
|
|
54
|
+
true: { group: 'flex flex-wrap w-auto', segment: 'flex-1 min-w-7 px-1.5' },
|
|
55
|
+
false: {},
|
|
44
56
|
},
|
|
45
57
|
},
|
|
46
|
-
defaultVariants: { size: 'sm',
|
|
58
|
+
defaultVariants: { size: 'sm', wrap: false },
|
|
47
59
|
})
|
|
48
60
|
|
|
49
61
|
export type SegmentedControlVariants = VariantProps<typeof segmentedControlStyles>
|
|
@@ -52,6 +64,8 @@ export type SegmentedControlOption<T extends Key = Key> = {
|
|
|
52
64
|
id: T
|
|
53
65
|
label: ReactNode
|
|
54
66
|
isDisabled?: boolean
|
|
67
|
+
/** Accessible name for icon-only segments. */
|
|
68
|
+
'aria-label'?: string
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export type SegmentedControlProps<T extends Key = Key> = Omit<
|
|
@@ -59,9 +73,12 @@ export type SegmentedControlProps<T extends Key = Key> = Omit<
|
|
|
59
73
|
'children' | 'value' | 'onChange'
|
|
60
74
|
> & {
|
|
61
75
|
options: ReadonlyArray<SegmentedControlOption<T>>
|
|
62
|
-
|
|
76
|
+
/** `null` renders no selected segment. */
|
|
77
|
+
value: T | null
|
|
63
78
|
onChange: (value: T) => void
|
|
64
79
|
size?: SegmentedControlVariants['size']
|
|
80
|
+
/** Flow-wrap long option lists; segments share the line equally. */
|
|
81
|
+
wrap?: boolean
|
|
65
82
|
/** Focus the selected segment on mount. RAC's RadioGroup doesn't surface
|
|
66
83
|
* `autoFocus` itself — it's a per-Radio prop — so we apply it to the
|
|
67
84
|
* Radio whose value matches `value`. */
|
|
@@ -75,17 +92,18 @@ export function SegmentedControl<T extends Key>({
|
|
|
75
92
|
value,
|
|
76
93
|
onChange,
|
|
77
94
|
size,
|
|
95
|
+
wrap = false,
|
|
78
96
|
autoFocus = false,
|
|
79
97
|
className,
|
|
80
98
|
orientation = 'horizontal',
|
|
81
99
|
...props
|
|
82
100
|
}: SegmentedControlProps<T>) {
|
|
83
|
-
const styles = segmentedControlStyles({ size })
|
|
101
|
+
const styles = segmentedControlStyles({ size, wrap })
|
|
84
102
|
return (
|
|
85
103
|
<AriaRadioGroup
|
|
86
104
|
{...props}
|
|
87
105
|
orientation={orientation}
|
|
88
|
-
value={String(value)}
|
|
106
|
+
value={value === null ? null : String(value)}
|
|
89
107
|
onChange={(next) => onChange(next as T)}
|
|
90
108
|
className={cn(styles.group(), className)}
|
|
91
109
|
>
|
|
@@ -94,6 +112,7 @@ export function SegmentedControl<T extends Key>({
|
|
|
94
112
|
key={String(option.id)}
|
|
95
113
|
value={String(option.id)}
|
|
96
114
|
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
115
|
+
{...(option['aria-label'] !== undefined ? { 'aria-label': option['aria-label'] } : {})}
|
|
97
116
|
{...(autoFocus && option.id === value ? { autoFocus: true } : {})}
|
|
98
117
|
className={styles.segment()}
|
|
99
118
|
>
|