@proteos/ui 0.31.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/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { MultiSelect, type MultiSelectOption } from './multi-select'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof MultiSelect> = {
|
|
7
|
+
title: 'Components/MultiSelect',
|
|
8
|
+
component: MultiSelect,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[440px]">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof MultiSelect>
|
|
21
|
+
|
|
22
|
+
/* ===== example data ======================================================= */
|
|
23
|
+
|
|
24
|
+
// Plain text options — the chip and listbox row both render the string.
|
|
25
|
+
const plainCarriers: MultiSelectOption<string>[] = [
|
|
26
|
+
{ id: 'blue-ridge', label: 'Blue Ridge' },
|
|
27
|
+
{ id: 'north-star', label: 'North Star' },
|
|
28
|
+
{ id: 'sunline', label: 'Sunline' },
|
|
29
|
+
{ id: 'cascade', label: 'Cascade' },
|
|
30
|
+
{ id: 'atlantic', label: 'Atlantic' },
|
|
31
|
+
{ id: 'pioneer', label: 'Pioneer' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
// Options with a small decoration alongside the label — shows that
|
|
35
|
+
// `content` can be anything, not just a Badge.
|
|
36
|
+
const carriersWithMeta: MultiSelectOption<string>[] = [
|
|
37
|
+
{
|
|
38
|
+
id: 'blue-ridge',
|
|
39
|
+
label: 'Blue Ridge',
|
|
40
|
+
content: (
|
|
41
|
+
<span className="inline-flex items-center gap-2">
|
|
42
|
+
<span>Blue Ridge</span>
|
|
43
|
+
<span className="font-mono text-[11px] text-ink-4">FTL · 42 trucks</span>
|
|
44
|
+
</span>
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'north-star',
|
|
49
|
+
label: 'North Star',
|
|
50
|
+
content: (
|
|
51
|
+
<span className="inline-flex items-center gap-2">
|
|
52
|
+
<span>North Star</span>
|
|
53
|
+
<span className="font-mono text-[11px] text-ink-4">LTL · 118 trucks</span>
|
|
54
|
+
</span>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'sunline',
|
|
59
|
+
label: 'Sunline',
|
|
60
|
+
content: (
|
|
61
|
+
<span className="inline-flex items-center gap-2">
|
|
62
|
+
<span>Sunline</span>
|
|
63
|
+
<span className="font-mono text-[11px] text-ink-4">FTL · 24 trucks</span>
|
|
64
|
+
</span>
|
|
65
|
+
),
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/* ===== stories ============================================================ */
|
|
70
|
+
|
|
71
|
+
/** Plain text options — the default. Each chip shows the option's label. */
|
|
72
|
+
export const PlainText: Story = {
|
|
73
|
+
render: () => {
|
|
74
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge', 'sunline']))
|
|
75
|
+
return (
|
|
76
|
+
<MultiSelect
|
|
77
|
+
label="Carriers"
|
|
78
|
+
placeholder="Pick carriers"
|
|
79
|
+
description="Click to add, × to remove."
|
|
80
|
+
options={plainCarriers}
|
|
81
|
+
selectedKeys={selected}
|
|
82
|
+
onSelectionChange={setSelected}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `hideSelectedOptions` — already-selected items disappear from the
|
|
89
|
+
* dropdown. Removal happens only via the chip × in the trigger. */
|
|
90
|
+
export const HideSelected: Story = {
|
|
91
|
+
render: () => {
|
|
92
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge']))
|
|
93
|
+
return (
|
|
94
|
+
<MultiSelect
|
|
95
|
+
label="Carriers"
|
|
96
|
+
placeholder="Pick carriers"
|
|
97
|
+
description="Selected carriers are hidden from the menu — remove them via × above."
|
|
98
|
+
options={plainCarriers}
|
|
99
|
+
selectedKeys={selected}
|
|
100
|
+
onSelectionChange={setSelected}
|
|
101
|
+
hideSelectedOptions
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Mix a label with small meta on the right — shows that `content` is just
|
|
108
|
+
* ReactNode, no constraints on structure. */
|
|
109
|
+
export const WithInlineMeta: Story = {
|
|
110
|
+
render: () => {
|
|
111
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(['blue-ridge']))
|
|
112
|
+
return (
|
|
113
|
+
<MultiSelect
|
|
114
|
+
label="Carriers"
|
|
115
|
+
placeholder="Pick carriers"
|
|
116
|
+
options={carriersWithMeta}
|
|
117
|
+
selectedKeys={selected}
|
|
118
|
+
onSelectionChange={setSelected}
|
|
119
|
+
/>
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Empty state — no selection yet, trigger shows only the placeholder. */
|
|
125
|
+
export const Empty: Story = {
|
|
126
|
+
render: () => {
|
|
127
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
128
|
+
return (
|
|
129
|
+
<MultiSelect
|
|
130
|
+
label="Carriers"
|
|
131
|
+
placeholder="Pick one or more carriers"
|
|
132
|
+
options={plainCarriers}
|
|
133
|
+
selectedKeys={selected}
|
|
134
|
+
onSelectionChange={setSelected}
|
|
135
|
+
/>
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const Invalid: Story = {
|
|
141
|
+
render: () => {
|
|
142
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
143
|
+
return (
|
|
144
|
+
<MultiSelect
|
|
145
|
+
label="Carriers"
|
|
146
|
+
placeholder="Pick carriers"
|
|
147
|
+
options={plainCarriers}
|
|
148
|
+
selectedKeys={selected}
|
|
149
|
+
onSelectionChange={setSelected}
|
|
150
|
+
isInvalid
|
|
151
|
+
errorMessage="Pick at least one carrier to dispatch."
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const Disabled: Story = {
|
|
158
|
+
render: () => (
|
|
159
|
+
<MultiSelect
|
|
160
|
+
label="Carriers"
|
|
161
|
+
placeholder="Pick carriers"
|
|
162
|
+
options={plainCarriers}
|
|
163
|
+
selectedKeys={new Set(['blue-ridge', 'sunline'])}
|
|
164
|
+
onSelectionChange={() => {}}
|
|
165
|
+
isDisabled
|
|
166
|
+
/>
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const AllStates: Story = {
|
|
171
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
172
|
+
render: () => {
|
|
173
|
+
const [a, setA] = useState<Set<string>>(new Set())
|
|
174
|
+
const [b, setB] = useState<Set<string>>(new Set(['blue-ridge', 'sunline']))
|
|
175
|
+
return (
|
|
176
|
+
<div className="grid gap-5 w-[440px]">
|
|
177
|
+
<MultiSelect
|
|
178
|
+
label="Rest"
|
|
179
|
+
placeholder="Pick carriers"
|
|
180
|
+
options={plainCarriers}
|
|
181
|
+
selectedKeys={a}
|
|
182
|
+
onSelectionChange={setA}
|
|
183
|
+
/>
|
|
184
|
+
<MultiSelect
|
|
185
|
+
label="With text chips"
|
|
186
|
+
placeholder="Pick carriers"
|
|
187
|
+
options={plainCarriers}
|
|
188
|
+
selectedKeys={b}
|
|
189
|
+
onSelectionChange={setB}
|
|
190
|
+
/>
|
|
191
|
+
<MultiSelect
|
|
192
|
+
label="Invalid"
|
|
193
|
+
placeholder="Pick carriers"
|
|
194
|
+
options={plainCarriers}
|
|
195
|
+
selectedKeys={new Set<string>()}
|
|
196
|
+
onSelectionChange={() => {}}
|
|
197
|
+
isInvalid
|
|
198
|
+
errorMessage="Pick at least one."
|
|
199
|
+
/>
|
|
200
|
+
<MultiSelect
|
|
201
|
+
label="Disabled"
|
|
202
|
+
placeholder="Pick carriers"
|
|
203
|
+
options={plainCarriers}
|
|
204
|
+
selectedKeys={new Set(['blue-ridge'])}
|
|
205
|
+
onSelectionChange={() => {}}
|
|
206
|
+
isDisabled
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
},
|
|
211
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { MultiSelect, type MultiSelectOption } from './multi-select'
|
|
7
|
+
|
|
8
|
+
const carriers: MultiSelectOption<string>[] = [
|
|
9
|
+
{ id: 'blue-ridge', label: 'Blue Ridge' },
|
|
10
|
+
{ id: 'north-star', label: 'North Star' },
|
|
11
|
+
{ id: 'sunline', label: 'Sunline' },
|
|
12
|
+
{ id: 'cascade', label: 'Cascade' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function Harness({
|
|
16
|
+
onSelectionChange,
|
|
17
|
+
initial = new Set<string>(),
|
|
18
|
+
}: {
|
|
19
|
+
onSelectionChange?: (keys: Set<string>) => void
|
|
20
|
+
initial?: Set<string>
|
|
21
|
+
}) {
|
|
22
|
+
const [selected, setSelected] = useState<Set<string>>(initial)
|
|
23
|
+
return (
|
|
24
|
+
<MultiSelect<string>
|
|
25
|
+
label="Carriers"
|
|
26
|
+
placeholder="Pick carriers"
|
|
27
|
+
options={carriers}
|
|
28
|
+
selectedKeys={selected}
|
|
29
|
+
onSelectionChange={(keys) => {
|
|
30
|
+
setSelected(keys)
|
|
31
|
+
onSelectionChange?.(keys)
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('MultiSelect', () => {
|
|
38
|
+
it('renders the trigger with a placeholder when nothing is selected', () => {
|
|
39
|
+
render(<Harness />)
|
|
40
|
+
expect(screen.getByText('Pick carriers')).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('opens the listbox on trigger click', async () => {
|
|
45
|
+
const user = userEvent.setup()
|
|
46
|
+
render(<Harness />)
|
|
47
|
+
|
|
48
|
+
await user.click(screen.getByRole('combobox'))
|
|
49
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
50
|
+
expect(screen.getAllByRole('option')).toHaveLength(carriers.length)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('selects multiple options and renders them as chips', async () => {
|
|
54
|
+
const user = userEvent.setup()
|
|
55
|
+
const onChange = vi.fn()
|
|
56
|
+
render(<Harness onSelectionChange={onChange} />)
|
|
57
|
+
|
|
58
|
+
await user.click(screen.getByRole('combobox'))
|
|
59
|
+
await user.click(screen.getByRole('option', { name: 'Blue Ridge' }))
|
|
60
|
+
await user.click(screen.getByRole('option', { name: 'Sunline' }))
|
|
61
|
+
|
|
62
|
+
// Both selections make it back to the consumer. Checking membership
|
|
63
|
+
// explicitly avoids Set-equality quirks from structural matchers.
|
|
64
|
+
const lastCall = onChange.mock.calls.at(-1)?.[0] as Set<string> | undefined
|
|
65
|
+
expect(lastCall?.has('blue-ridge')).toBe(true)
|
|
66
|
+
expect(lastCall?.has('sunline')).toBe(true)
|
|
67
|
+
expect(lastCall?.size).toBe(2)
|
|
68
|
+
|
|
69
|
+
// Two chips = two × remove buttons. RAC's popover aria-hides the
|
|
70
|
+
// trigger's ancestors while open, so we include hidden matches to see
|
|
71
|
+
// the chips inside the trigger div.
|
|
72
|
+
expect(screen.getAllByRole('button', { name: 'Remove', hidden: true })).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('removes a selection via the chip × button', async () => {
|
|
76
|
+
const user = userEvent.setup()
|
|
77
|
+
const onChange = vi.fn()
|
|
78
|
+
render(<Harness onSelectionChange={onChange} initial={new Set(['blue-ridge', 'north-star'])} />)
|
|
79
|
+
|
|
80
|
+
// Two chips, two × buttons. Click the first to remove Blue Ridge.
|
|
81
|
+
const [firstRemove, ...rest] = screen.getAllByRole('button', { name: 'Remove' })
|
|
82
|
+
expect(rest).toHaveLength(1)
|
|
83
|
+
if (!firstRemove) throw new Error('expected a remove button')
|
|
84
|
+
await user.click(firstRemove)
|
|
85
|
+
|
|
86
|
+
expect(onChange).toHaveBeenCalledWith(new Set(['north-star']))
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('opens via keyboard (ArrowDown / Enter / Space)', async () => {
|
|
90
|
+
const user = userEvent.setup()
|
|
91
|
+
render(<Harness />)
|
|
92
|
+
|
|
93
|
+
await user.tab()
|
|
94
|
+
expect(screen.getByRole('combobox')).toHaveFocus()
|
|
95
|
+
await user.keyboard('{ArrowDown}')
|
|
96
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not open when disabled', async () => {
|
|
100
|
+
const user = userEvent.setup()
|
|
101
|
+
render(
|
|
102
|
+
<MultiSelect
|
|
103
|
+
label="Carriers"
|
|
104
|
+
options={carriers}
|
|
105
|
+
selectedKeys={new Set<string>()}
|
|
106
|
+
onSelectionChange={() => {}}
|
|
107
|
+
isDisabled
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await user.click(screen.getByRole('combobox'))
|
|
112
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('hides selected options from the dropdown when hideSelectedOptions is set', async () => {
|
|
116
|
+
const user = userEvent.setup()
|
|
117
|
+
render(
|
|
118
|
+
<MultiSelect<string>
|
|
119
|
+
label="Carriers"
|
|
120
|
+
options={carriers}
|
|
121
|
+
selectedKeys={new Set<string>(['blue-ridge'])}
|
|
122
|
+
onSelectionChange={() => {}}
|
|
123
|
+
hideSelectedOptions
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
await user.click(screen.getByRole('combobox'))
|
|
128
|
+
await screen.findByRole('listbox')
|
|
129
|
+
|
|
130
|
+
expect(screen.queryByRole('option', { name: 'Blue Ridge' })).not.toBeInTheDocument()
|
|
131
|
+
expect(screen.getAllByRole('option')).toHaveLength(carriers.length - 1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('renders description and error message slots', () => {
|
|
135
|
+
render(
|
|
136
|
+
<MultiSelect
|
|
137
|
+
label="Carriers"
|
|
138
|
+
description="Pick one or more."
|
|
139
|
+
errorMessage="Required."
|
|
140
|
+
isInvalid
|
|
141
|
+
options={carriers}
|
|
142
|
+
selectedKeys={new Set<string>()}
|
|
143
|
+
onSelectionChange={() => {}}
|
|
144
|
+
/>,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText('Pick one or more.')).toBeInTheDocument()
|
|
148
|
+
expect(screen.getByText('Required.')).toBeInTheDocument()
|
|
149
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { type KeyboardEvent, type ReactNode, useRef, useState } from 'react'
|
|
2
|
+
import { type Key, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
|
|
3
|
+
import { cn } from '../../lib/cn'
|
|
4
|
+
import {
|
|
5
|
+
type FieldAppearance,
|
|
6
|
+
FieldCaret,
|
|
7
|
+
type FieldSize,
|
|
8
|
+
fieldChipPadding,
|
|
9
|
+
fieldControlStyles,
|
|
10
|
+
fieldErrorStyles,
|
|
11
|
+
fieldHintStyles,
|
|
12
|
+
fieldLabelStyles,
|
|
13
|
+
fieldMinHeight,
|
|
14
|
+
fieldTextStyles,
|
|
15
|
+
} from '../field/field'
|
|
16
|
+
|
|
17
|
+
/* =========================================================================
|
|
18
|
+
MultiSelect — pick many from a list. Trigger shows selected items as
|
|
19
|
+
removable chips; popover hosts a multi-select ListBox.
|
|
20
|
+
|
|
21
|
+
Each option accepts a `content: ReactNode` so consumers can render plain
|
|
22
|
+
text, a Badge, an icon + label, or anything else — and the same content
|
|
23
|
+
is reused inside the selected chip.
|
|
24
|
+
========================================================================= */
|
|
25
|
+
|
|
26
|
+
export type MultiSelectOption<T extends Key = Key> = {
|
|
27
|
+
id: T
|
|
28
|
+
/** Plain-text label — used as the option's accessible name and fallback
|
|
29
|
+
* when `content` isn't provided. */
|
|
30
|
+
label: string
|
|
31
|
+
/** Optional rich render. Applied to BOTH the listbox row and the selected
|
|
32
|
+
* chip (wrapped by the chip's × button). Falls back to `label`. */
|
|
33
|
+
content?: ReactNode
|
|
34
|
+
isDisabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type MultiSelectProps<T extends Key = Key> = {
|
|
38
|
+
label?: ReactNode
|
|
39
|
+
description?: ReactNode
|
|
40
|
+
errorMessage?: ReactNode
|
|
41
|
+
placeholder?: string
|
|
42
|
+
options: ReadonlyArray<MultiSelectOption<T>>
|
|
43
|
+
/** Controlled selection. Pass a `Set<T>` or any `Iterable<T>`. */
|
|
44
|
+
selectedKeys: Iterable<T>
|
|
45
|
+
onSelectionChange: (keys: Set<T>) => void
|
|
46
|
+
isDisabled?: boolean
|
|
47
|
+
isInvalid?: boolean
|
|
48
|
+
/** Hide already-selected options from the dropdown. Removal then only
|
|
49
|
+
* happens via the chip × in the trigger. Default `false` (selected
|
|
50
|
+
* options stay visible and can be toggled off from the listbox). */
|
|
51
|
+
hideSelectedOptions?: boolean
|
|
52
|
+
/** Render a search input at the top of the popover that filters options
|
|
53
|
+
* by label (case-insensitive substring). Default `false`. */
|
|
54
|
+
isSearchable?: boolean
|
|
55
|
+
/** Fires as the search text changes — lets consumers drive a server-side
|
|
56
|
+
* directory search alongside the local filter. */
|
|
57
|
+
onSearchChange?: (needle: string) => void
|
|
58
|
+
/** Field size. Defaults to `md`. */
|
|
59
|
+
size?: FieldSize
|
|
60
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the box. */
|
|
61
|
+
appearance?: FieldAppearance
|
|
62
|
+
className?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function MultiSelect<T extends Key>({
|
|
66
|
+
label,
|
|
67
|
+
description,
|
|
68
|
+
errorMessage,
|
|
69
|
+
placeholder = 'Select…',
|
|
70
|
+
options,
|
|
71
|
+
selectedKeys,
|
|
72
|
+
onSelectionChange,
|
|
73
|
+
isDisabled = false,
|
|
74
|
+
isInvalid = false,
|
|
75
|
+
hideSelectedOptions = false,
|
|
76
|
+
isSearchable = false,
|
|
77
|
+
onSearchChange,
|
|
78
|
+
size = 'md',
|
|
79
|
+
appearance,
|
|
80
|
+
className,
|
|
81
|
+
}: MultiSelectProps<T>) {
|
|
82
|
+
const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
|
|
83
|
+
const focusWithin =
|
|
84
|
+
appearance === 'cell'
|
|
85
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
86
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
87
|
+
// Cell mode sits under the table row so the border reads clean instead of
|
|
88
|
+
// merging with the row gridline.
|
|
89
|
+
const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
|
|
90
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
91
|
+
const [search, setSearch] = useState('')
|
|
92
|
+
const triggerRef = useRef<HTMLDivElement>(null)
|
|
93
|
+
|
|
94
|
+
const selectedSet = new Set(selectedKeys)
|
|
95
|
+
const selectedOptions = options.filter((o) => selectedSet.has(o.id))
|
|
96
|
+
const needle = search.trim().toLowerCase()
|
|
97
|
+
const listboxOptions = (
|
|
98
|
+
hideSelectedOptions ? options.filter((o) => !selectedSet.has(o.id)) : options
|
|
99
|
+
).filter((o) => needle === '' || o.label.toLowerCase().includes(needle))
|
|
100
|
+
|
|
101
|
+
const updateSearch = (next: string) => {
|
|
102
|
+
setSearch(next)
|
|
103
|
+
onSearchChange?.(next)
|
|
104
|
+
}
|
|
105
|
+
const openPopover = (open: boolean) => {
|
|
106
|
+
setIsOpen(open)
|
|
107
|
+
if (!open && search !== '') updateSearch('')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const remove = (id: T) => {
|
|
111
|
+
const next = new Set(selectedSet)
|
|
112
|
+
next.delete(id)
|
|
113
|
+
onSelectionChange(next)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const onTriggerKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
117
|
+
if (isDisabled) return
|
|
118
|
+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
119
|
+
e.preventDefault()
|
|
120
|
+
openPopover(true)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={cn('grid grid-cols-1 gap-1.5', className)}>
|
|
126
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
127
|
+
|
|
128
|
+
<div
|
|
129
|
+
ref={triggerRef}
|
|
130
|
+
role="combobox"
|
|
131
|
+
aria-expanded={isOpen}
|
|
132
|
+
aria-haspopup="listbox"
|
|
133
|
+
aria-disabled={isDisabled || undefined}
|
|
134
|
+
aria-invalid={isInvalid || undefined}
|
|
135
|
+
tabIndex={isDisabled ? -1 : 0}
|
|
136
|
+
onClick={() => !isDisabled && openPopover(!isOpen)}
|
|
137
|
+
onKeyDown={onTriggerKeyDown}
|
|
138
|
+
data-invalid={isInvalid || undefined}
|
|
139
|
+
data-disabled={isDisabled || undefined}
|
|
140
|
+
className={cn(
|
|
141
|
+
styles.box(),
|
|
142
|
+
'flex flex-wrap items-center gap-1.5 cursor-pointer',
|
|
143
|
+
minHeight,
|
|
144
|
+
fieldChipPadding[size],
|
|
145
|
+
'pr-2',
|
|
146
|
+
// Let focus-within light up the ring on both the trigger and any
|
|
147
|
+
// chip's remove button without any extra JS.
|
|
148
|
+
focusWithin,
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
{selectedOptions.length === 0 ? (
|
|
152
|
+
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
153
|
+
// overall height so it matches the Input/Select trigger at the same
|
|
154
|
+
// size.
|
|
155
|
+
<span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
|
|
156
|
+
{placeholder}
|
|
157
|
+
</span>
|
|
158
|
+
) : (
|
|
159
|
+
selectedOptions.map((o) => (
|
|
160
|
+
<Chip key={o.id} onRemove={() => remove(o.id)} isDisabled={isDisabled}>
|
|
161
|
+
{o.content ?? o.label}
|
|
162
|
+
</Chip>
|
|
163
|
+
))
|
|
164
|
+
)}
|
|
165
|
+
<span className="ml-auto pr-1">
|
|
166
|
+
<FieldCaret />
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{description ? <p className={fieldHintStyles}>{description}</p> : null}
|
|
171
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
172
|
+
<p className={fieldErrorStyles}>{errorMessage}</p>
|
|
173
|
+
) : null}
|
|
174
|
+
|
|
175
|
+
<Popover
|
|
176
|
+
isOpen={isOpen}
|
|
177
|
+
onOpenChange={openPopover}
|
|
178
|
+
triggerRef={triggerRef}
|
|
179
|
+
offset={6}
|
|
180
|
+
placement="bottom start"
|
|
181
|
+
className={cn(
|
|
182
|
+
'w-[var(--trigger-width)]',
|
|
183
|
+
'rounded-md border border-rule bg-surface p-1',
|
|
184
|
+
'shadow-[var(--shadow-raised)]',
|
|
185
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
186
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{isSearchable ? (
|
|
190
|
+
<div className="border-b border-rule-2 px-1 pb-1 mb-1">
|
|
191
|
+
<input
|
|
192
|
+
// biome-ignore lint/a11y/noAutofocus: the popover just opened by explicit user action — focus belongs in its search box.
|
|
193
|
+
autoFocus
|
|
194
|
+
value={search}
|
|
195
|
+
onChange={(e) => updateSearch(e.target.value)}
|
|
196
|
+
placeholder="Search…"
|
|
197
|
+
aria-label="Search options"
|
|
198
|
+
className={cn(
|
|
199
|
+
'w-full bg-transparent outline-none rounded-[6px] px-2 py-1.5',
|
|
200
|
+
'font-sans text-[13px] text-ink placeholder:text-ink-4',
|
|
201
|
+
)}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
) : null}
|
|
205
|
+
<ListBox
|
|
206
|
+
aria-label={typeof label === 'string' ? label : 'Options'}
|
|
207
|
+
selectionMode="multiple"
|
|
208
|
+
selectedKeys={selectedSet}
|
|
209
|
+
onSelectionChange={(keys) => {
|
|
210
|
+
if (keys === 'all') {
|
|
211
|
+
onSelectionChange(new Set(options.map((o) => o.id)))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
onSelectionChange(keys as Set<T>)
|
|
215
|
+
}}
|
|
216
|
+
className="outline-none max-h-72 overflow-auto"
|
|
217
|
+
renderEmptyState={() => (
|
|
218
|
+
<div className={cn('px-3 py-2', fieldHintStyles)}>
|
|
219
|
+
{needle !== '' ? 'No matches' : 'All options selected'}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
{listboxOptions.map((option) => (
|
|
224
|
+
<ListBoxItem
|
|
225
|
+
key={option.id}
|
|
226
|
+
id={option.id}
|
|
227
|
+
textValue={option.label}
|
|
228
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
229
|
+
className={cn(
|
|
230
|
+
'group flex items-center gap-2.5',
|
|
231
|
+
'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
|
|
232
|
+
'cursor-pointer outline-none select-none',
|
|
233
|
+
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
234
|
+
'data-[focused]:bg-surface-2 data-[focused]:text-ink',
|
|
235
|
+
'data-[selected]:bg-surface-3 data-[selected]:text-ink',
|
|
236
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
<Checkmark />
|
|
240
|
+
<span className="flex-1 truncate">{option.content ?? option.label}</span>
|
|
241
|
+
</ListBoxItem>
|
|
242
|
+
))}
|
|
243
|
+
</ListBox>
|
|
244
|
+
</Popover>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* =========================================================================
|
|
250
|
+
Internal: selected chip with × remove button.
|
|
251
|
+
Minimal chrome (sand bg, hairline border, rounded-6) so that rich content
|
|
252
|
+
like a <Badge/> still reads cleanly inside it.
|
|
253
|
+
========================================================================= */
|
|
254
|
+
|
|
255
|
+
function Chip({
|
|
256
|
+
children,
|
|
257
|
+
onRemove,
|
|
258
|
+
isDisabled,
|
|
259
|
+
}: {
|
|
260
|
+
children: ReactNode
|
|
261
|
+
onRemove: () => void
|
|
262
|
+
isDisabled: boolean
|
|
263
|
+
}) {
|
|
264
|
+
return (
|
|
265
|
+
<span
|
|
266
|
+
className={cn(
|
|
267
|
+
'inline-flex items-center gap-1.5',
|
|
268
|
+
'pl-2 pr-1 py-0.5 rounded-[6px]',
|
|
269
|
+
'bg-surface-2 border border-rule',
|
|
270
|
+
'font-sans text-[13px] text-ink',
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
<span className="inline-flex items-center">{children}</span>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
aria-label="Remove"
|
|
277
|
+
disabled={isDisabled}
|
|
278
|
+
onClick={(e) => {
|
|
279
|
+
// Keep the click from bubbling to the trigger div (which would
|
|
280
|
+
// toggle the popover open/closed).
|
|
281
|
+
e.stopPropagation()
|
|
282
|
+
onRemove()
|
|
283
|
+
}}
|
|
284
|
+
className={cn(
|
|
285
|
+
'inline-flex items-center justify-center shrink-0',
|
|
286
|
+
'size-4 rounded-[3px]',
|
|
287
|
+
'text-ink-3 hover:text-ink hover:bg-surface-3 cursor-pointer',
|
|
288
|
+
'outline-none focus-visible:shadow-[var(--shadow-focus)]',
|
|
289
|
+
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
290
|
+
)}
|
|
291
|
+
>
|
|
292
|
+
<svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
|
|
293
|
+
<path
|
|
294
|
+
d="M2 2l6 6M8 2l-6 6"
|
|
295
|
+
stroke="currentColor"
|
|
296
|
+
strokeWidth="1.4"
|
|
297
|
+
strokeLinecap="round"
|
|
298
|
+
/>
|
|
299
|
+
</svg>
|
|
300
|
+
</button>
|
|
301
|
+
</span>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* =========================================================================
|
|
306
|
+
Internal: empty box → indigo tick when the row is selected.
|
|
307
|
+
========================================================================= */
|
|
308
|
+
|
|
309
|
+
function Checkmark() {
|
|
310
|
+
return (
|
|
311
|
+
<span
|
|
312
|
+
aria-hidden
|
|
313
|
+
className={cn(
|
|
314
|
+
'relative inline-flex items-center justify-center shrink-0',
|
|
315
|
+
'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
|
|
316
|
+
'transition-[background-color,border-color] duration-150',
|
|
317
|
+
'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
<svg
|
|
321
|
+
viewBox="0 0 12 12"
|
|
322
|
+
className={cn(
|
|
323
|
+
'size-3 text-surface transition-opacity duration-150',
|
|
324
|
+
'opacity-0 group-data-[selected]:opacity-100',
|
|
325
|
+
)}
|
|
326
|
+
aria-hidden
|
|
327
|
+
>
|
|
328
|
+
<path
|
|
329
|
+
d="M2.5 6.25 5 8.75 9.5 3.75"
|
|
330
|
+
stroke="currentColor"
|
|
331
|
+
strokeWidth="1.6"
|
|
332
|
+
strokeLinecap="round"
|
|
333
|
+
strokeLinejoin="round"
|
|
334
|
+
fill="none"
|
|
335
|
+
/>
|
|
336
|
+
</svg>
|
|
337
|
+
</span>
|
|
338
|
+
)
|
|
339
|
+
}
|