@proteos/ui 0.54.0 → 0.55.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/index.cjs +639 -312
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -7
- package/dist/index.d.ts +79 -7
- package/dist/index.js +637 -315
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/badge/badge.stories.tsx +37 -1
- package/src/components/badge/badge.test.tsx +39 -2
- package/src/components/badge/badge.tsx +65 -1
- package/src/components/badge/index.ts +2 -2
- package/src/components/badge-group/badge-group.stories.tsx +75 -0
- package/src/components/badge-group/badge-group.test.tsx +121 -0
- package/src/components/badge-group/badge-group.tsx +208 -0
- package/src/components/badge-group/index.ts +7 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
- package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
- package/src/components/card/card.tsx +1 -1
- package/src/components/data-table/data-table.stories.tsx +53 -2
- package/src/components/data-table/data-table.tsx +45 -25
- package/src/components/field/listbox-check.tsx +44 -0
- package/src/components/field/use-chip-row-wrap.ts +45 -0
- package/src/components/multi-select/multi-select.tsx +8 -39
- package/src/components/table/table.test.tsx +88 -9
- package/src/components/table/table.tsx +139 -13
- package/src/components/tag-input/tag-input.test.tsx +67 -1
- package/src/components/tag-input/tag-input.tsx +98 -64
- package/src/index.ts +1 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react'
|
|
2
2
|
import userEvent from '@testing-library/user-event'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
|
|
6
6
|
import { BadgeMultiSelect, type BadgeMultiSelectOption } from './badge-multi-select'
|
|
7
7
|
|
|
@@ -153,3 +153,112 @@ describe('BadgeMultiSelect', () => {
|
|
|
153
153
|
expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true')
|
|
154
154
|
})
|
|
155
155
|
})
|
|
156
|
+
|
|
157
|
+
/** jsdom has no layout: pretend every child beyond the second sits on a
|
|
158
|
+
* second line, 30px lower, so the chip row reads as wrapped. */
|
|
159
|
+
function mockWrappedChips() {
|
|
160
|
+
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
|
161
|
+
const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0
|
|
162
|
+
const top = index > 1 ? 30 : 0
|
|
163
|
+
return {
|
|
164
|
+
top,
|
|
165
|
+
bottom: top + 23,
|
|
166
|
+
height: 23,
|
|
167
|
+
left: 0,
|
|
168
|
+
right: 40,
|
|
169
|
+
width: 40,
|
|
170
|
+
x: 0,
|
|
171
|
+
y: top,
|
|
172
|
+
toJSON: () => ({}),
|
|
173
|
+
} as DOMRect
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe('BadgeMultiSelect — inline cell editor', () => {
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
vi.restoreAllMocks()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('opens the listbox on mount when defaultOpen is set', async () => {
|
|
183
|
+
render(
|
|
184
|
+
<BadgeMultiSelect<string>
|
|
185
|
+
label="Status"
|
|
186
|
+
options={statuses}
|
|
187
|
+
selectedKeys={new Set<string>()}
|
|
188
|
+
onSelectionChange={() => {}}
|
|
189
|
+
appearance="cell"
|
|
190
|
+
autoFocus
|
|
191
|
+
defaultOpen
|
|
192
|
+
/>,
|
|
193
|
+
)
|
|
194
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
195
|
+
// The open popover aria-hides the rest of the page (modal), so the trigger
|
|
196
|
+
// is only reachable with `hidden`.
|
|
197
|
+
expect(screen.getByRole('combobox', { hidden: true })).toHaveAttribute('aria-expanded', 'true')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('stays closed on mount when disabled, even with defaultOpen', () => {
|
|
201
|
+
render(
|
|
202
|
+
<BadgeMultiSelect<string>
|
|
203
|
+
label="Status"
|
|
204
|
+
options={statuses}
|
|
205
|
+
selectedKeys={new Set<string>()}
|
|
206
|
+
onSelectionChange={() => {}}
|
|
207
|
+
appearance="cell"
|
|
208
|
+
defaultOpen
|
|
209
|
+
isDisabled
|
|
210
|
+
/>,
|
|
211
|
+
)
|
|
212
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('stays one row tall while the chips fit on a line', () => {
|
|
216
|
+
render(
|
|
217
|
+
<BadgeMultiSelect<string>
|
|
218
|
+
label="Status"
|
|
219
|
+
options={statuses}
|
|
220
|
+
selectedKeys={new Set(['in-transit', 'quoted', 'reconciled'])}
|
|
221
|
+
onSelectionChange={() => {}}
|
|
222
|
+
appearance="cell"
|
|
223
|
+
/>,
|
|
224
|
+
)
|
|
225
|
+
const trigger = screen.getByRole('combobox')
|
|
226
|
+
expect(trigger.className).toContain('h-[29px]')
|
|
227
|
+
expect(trigger.querySelector('.overflow-y-auto')).toBeNull()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('grows to two rows with a scroll area once the chips wrap', () => {
|
|
231
|
+
mockWrappedChips()
|
|
232
|
+
render(
|
|
233
|
+
<BadgeMultiSelect<string>
|
|
234
|
+
label="Status"
|
|
235
|
+
options={statuses}
|
|
236
|
+
selectedKeys={new Set(['in-transit', 'quoted', 'reconciled'])}
|
|
237
|
+
onSelectionChange={() => {}}
|
|
238
|
+
appearance="cell"
|
|
239
|
+
/>,
|
|
240
|
+
)
|
|
241
|
+
const trigger = screen.getByRole('combobox')
|
|
242
|
+
expect(trigger.className).toContain('h-[64px]')
|
|
243
|
+
expect(trigger.querySelector('.overflow-y-auto')).not.toBeNull()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('closes the list on a pointer outside trigger and list', async () => {
|
|
247
|
+
const user = userEvent.setup()
|
|
248
|
+
render(
|
|
249
|
+
<div>
|
|
250
|
+
<BadgeMultiSelect<string>
|
|
251
|
+
label="Status"
|
|
252
|
+
options={statuses}
|
|
253
|
+
selectedKeys={new Set<string>()}
|
|
254
|
+
onSelectionChange={() => {}}
|
|
255
|
+
defaultOpen
|
|
256
|
+
/>
|
|
257
|
+
<p>elsewhere</p>
|
|
258
|
+
</div>,
|
|
259
|
+
)
|
|
260
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
261
|
+
await user.click(screen.getByText('elsewhere'))
|
|
262
|
+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { type Key, Label, ListBox, ListBoxItem, Popover } from 'react-aria-components'
|
|
3
3
|
import { cn } from '../../lib/cn'
|
|
4
|
-
import { Badge, type BadgeVariants } from '../badge/badge'
|
|
4
|
+
import { Badge, BadgeAction, type BadgeVariants } from '../badge/badge'
|
|
5
5
|
import {
|
|
6
6
|
type FieldAppearance,
|
|
7
7
|
FieldCaret,
|
|
@@ -14,13 +14,20 @@ import {
|
|
|
14
14
|
fieldMinHeight,
|
|
15
15
|
fieldTextStyles,
|
|
16
16
|
} from '../field/field'
|
|
17
|
+
import { ListboxCheck } from '../field/listbox-check'
|
|
18
|
+
import { useChipRowWrap } from '../field/use-chip-row-wrap'
|
|
17
19
|
|
|
18
20
|
/* =========================================================================
|
|
19
21
|
BadgeMultiSelect — a MultiSelect specialized for picking status-like
|
|
20
22
|
values. Each option is a <Badge/> — both in the dropdown and as the
|
|
21
|
-
selected
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
selected chip. The × lives in the badge's own `actions` slot (no extra
|
|
24
|
+
chip chrome), so the color coding from the Badge variant is what you
|
|
25
|
+
see, cleanly.
|
|
26
|
+
|
|
27
|
+
`appearance="cell"` is the inline table editor: a fixed-height box that
|
|
28
|
+
keeps the selected chips on one line (clipping the overflow — the open
|
|
29
|
+
listbox is where the full selection is read), and `defaultOpen` drops
|
|
30
|
+
the editor straight into the listbox the moment the cell is entered.
|
|
24
31
|
========================================================================= */
|
|
25
32
|
|
|
26
33
|
export type BadgeMultiSelectOption<T extends Key = Key> = {
|
|
@@ -82,6 +89,12 @@ export type BadgeMultiSelectProps<T extends Key = Key> = {
|
|
|
82
89
|
* inputs; useful for keyboard chains where the trigger should take focus
|
|
83
90
|
* when its surrounding context becomes ready. */
|
|
84
91
|
autoFocus?: boolean
|
|
92
|
+
/** Open the listbox on mount — the inline cell editor passes this so a
|
|
93
|
+
* click into the cell lands straight in the options. Initial state
|
|
94
|
+
* only, like RAC's `defaultOpen`. */
|
|
95
|
+
defaultOpen?: boolean
|
|
96
|
+
/** Accessible name for the trigger + listbox when no visible `label` is rendered. */
|
|
97
|
+
'aria-label'?: string
|
|
85
98
|
className?: string
|
|
86
99
|
}
|
|
87
100
|
|
|
@@ -99,25 +112,73 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
99
112
|
size = 'md',
|
|
100
113
|
appearance,
|
|
101
114
|
autoFocus = false,
|
|
115
|
+
defaultOpen = false,
|
|
116
|
+
'aria-label': ariaLabel,
|
|
102
117
|
className,
|
|
103
118
|
}: BadgeMultiSelectProps<T>) {
|
|
119
|
+
const isCell = appearance === 'cell'
|
|
104
120
|
const styles = fieldControlStyles({ size, invalid: isInvalid, appearance })
|
|
105
|
-
const focusWithin =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
109
|
-
const minHeight = appearance === 'cell' ? 'min-h-[29px]' : fieldMinHeight[size]
|
|
121
|
+
const focusWithin = isCell
|
|
122
|
+
? 'focus-within:border-accent focus-within:shadow-none'
|
|
123
|
+
: 'focus-within:border-accent focus-within:shadow-[var(--shadow-focus)]'
|
|
110
124
|
const [isOpen, setIsOpen] = useState(false)
|
|
111
125
|
const triggerRef = useRef<HTMLDivElement>(null)
|
|
126
|
+
const chipsRef = useRef<HTMLDivElement>(null)
|
|
127
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
128
|
+
// Set when an outside pointer closed the list, so the close effect below
|
|
129
|
+
// does not pull focus back to the trigger the user just clicked away from.
|
|
130
|
+
const closedByPointerRef = useRef(false)
|
|
112
131
|
|
|
132
|
+
// Mount-only: focus first, THEN open. React Aria records the element to
|
|
133
|
+
// restore focus to during the popover's first render; opening in the
|
|
134
|
+
// mount render would record whatever focused us (an inline cell host),
|
|
135
|
+
// and unmounting the editor would hand focus straight back to it.
|
|
136
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: autoFocus / defaultOpen are one-shot mount signals, not sustained.
|
|
113
137
|
useEffect(() => {
|
|
114
138
|
if (autoFocus) triggerRef.current?.focus()
|
|
115
|
-
|
|
116
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
if (defaultOpen && !isDisabled) setIsOpen(true)
|
|
117
140
|
}, [])
|
|
118
141
|
|
|
142
|
+
// A non-modal popover restores nothing on close: when the list held focus
|
|
143
|
+
// (keyboard use, Escape) the focus would silently drop to <body>, leaving
|
|
144
|
+
// an inline cell editor stranded. Hand it back to the trigger — unless the
|
|
145
|
+
// close came from a click that already focused something else.
|
|
146
|
+
const wasOpenRef = useRef(false)
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (wasOpenRef.current && !isOpen && !closedByPointerRef.current) {
|
|
149
|
+
const active = document.activeElement
|
|
150
|
+
const strandedInList =
|
|
151
|
+
!active || active === document.body || !!active.closest('[data-rac][role="listbox"]')
|
|
152
|
+
if (strandedInList) triggerRef.current?.focus()
|
|
153
|
+
}
|
|
154
|
+
wasOpenRef.current = isOpen
|
|
155
|
+
closedByPointerRef.current = false
|
|
156
|
+
}, [isOpen])
|
|
157
|
+
|
|
119
158
|
const selectedSet = new Set(selectedKeys)
|
|
120
159
|
const selectedOptions = options.filter((o) => selectedSet.has(o.id))
|
|
160
|
+
// Cell editor height follows the chips: one row while they fit on a line,
|
|
161
|
+
// the two-row pop-out (scrolling) once they wrap.
|
|
162
|
+
const hasWrapped = useChipRowWrap(
|
|
163
|
+
chipsRef,
|
|
164
|
+
isCell,
|
|
165
|
+
JSON.stringify(selectedOptions.map((o) => o.id)),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// React Aria leaves non-modal popovers non-dismissable; close on any
|
|
169
|
+
// pointer that lands outside both the trigger and the list.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!isOpen) return
|
|
172
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
173
|
+
const target = event.target
|
|
174
|
+
if (!(target instanceof Node)) return
|
|
175
|
+
if (triggerRef.current?.contains(target) || popoverRef.current?.contains(target)) return
|
|
176
|
+
closedByPointerRef.current = true
|
|
177
|
+
setIsOpen(false)
|
|
178
|
+
}
|
|
179
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
180
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
|
181
|
+
}, [isOpen])
|
|
121
182
|
const listboxOptions = hideSelectedOptions
|
|
122
183
|
? options.filter((o) => !selectedSet.has(o.id))
|
|
123
184
|
: options
|
|
@@ -133,6 +194,14 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
133
194
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
|
134
195
|
e.preventDefault()
|
|
135
196
|
setIsOpen(true)
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (e.key === 'Escape' && isOpen) {
|
|
200
|
+
// Focus can sit on the trigger while the non-modal list is open (after
|
|
201
|
+
// a chip ×, say) — Escape closes the list first, before any host acts.
|
|
202
|
+
e.preventDefault()
|
|
203
|
+
e.stopPropagation()
|
|
204
|
+
setIsOpen(false)
|
|
136
205
|
}
|
|
137
206
|
}
|
|
138
207
|
|
|
@@ -143,6 +212,7 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
143
212
|
<div
|
|
144
213
|
ref={triggerRef}
|
|
145
214
|
role="combobox"
|
|
215
|
+
aria-label={ariaLabel}
|
|
146
216
|
aria-expanded={isOpen}
|
|
147
217
|
aria-haspopup="listbox"
|
|
148
218
|
aria-disabled={isDisabled || undefined}
|
|
@@ -154,41 +224,65 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
154
224
|
data-disabled={isDisabled || undefined}
|
|
155
225
|
className={cn(
|
|
156
226
|
styles.box(),
|
|
157
|
-
'flex
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
227
|
+
'flex items-stretch cursor-pointer',
|
|
228
|
+
// Cell editor: one table row (29px under a 32px row) while the chips
|
|
229
|
+
// fit on a line; two rows (64px), popping out of the row, once they
|
|
230
|
+
// wrap — the chips then scroll inside. Field mode grows with its
|
|
231
|
+
// content like every other chip field.
|
|
232
|
+
isCell
|
|
233
|
+
? hasWrapped
|
|
234
|
+
? 'h-[64px] p-1'
|
|
235
|
+
: 'h-[29px] px-1 py-0'
|
|
236
|
+
: [fieldChipPadding[size], fieldMinHeight[size]],
|
|
161
237
|
focusWithin,
|
|
162
238
|
)}
|
|
163
239
|
>
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
240
|
+
{/* The chips scroll inside this inner box, not the trigger itself:
|
|
241
|
+
React Aria closes a popover when an element containing its
|
|
242
|
+
trigger scrolls, and the trigger contains itself. */}
|
|
243
|
+
<div
|
|
244
|
+
ref={chipsRef}
|
|
245
|
+
className={cn(
|
|
246
|
+
'flex flex-1 flex-wrap items-center gap-1.5 min-w-0',
|
|
247
|
+
isCell &&
|
|
248
|
+
(hasWrapped ? 'content-start overflow-y-auto' : 'content-center overflow-hidden'),
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
{selectedOptions.length === 0 ? (
|
|
252
|
+
// No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
|
|
253
|
+
// overall height so it matches the Input/Select trigger at the same
|
|
254
|
+
// size.
|
|
255
|
+
<span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
|
|
256
|
+
{placeholder}
|
|
257
|
+
</span>
|
|
258
|
+
) : (
|
|
259
|
+
selectedOptions.map((o) => (
|
|
260
|
+
<Badge
|
|
261
|
+
key={o.id}
|
|
262
|
+
{...badgeChrome(o)}
|
|
263
|
+
className="shrink-0"
|
|
264
|
+
actions={
|
|
265
|
+
<BadgeAction
|
|
266
|
+
label="Remove"
|
|
267
|
+
onPress={() => remove(o.id)}
|
|
268
|
+
isDisabled={isDisabled}
|
|
269
|
+
/>
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
{o.label}
|
|
273
|
+
</Badge>
|
|
274
|
+
))
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
{isCell ? null : (
|
|
278
|
+
<span className="flex items-center pl-1.5 pr-1">
|
|
279
|
+
<FieldCaret />
|
|
170
280
|
</span>
|
|
171
|
-
) : (
|
|
172
|
-
selectedOptions.map((o) => (
|
|
173
|
-
<RemovableBadge
|
|
174
|
-
key={o.id}
|
|
175
|
-
{...badgeChrome(o)}
|
|
176
|
-
onRemove={() => remove(o.id)}
|
|
177
|
-
isDisabled={isDisabled}
|
|
178
|
-
>
|
|
179
|
-
{o.label}
|
|
180
|
-
</RemovableBadge>
|
|
181
|
-
))
|
|
182
281
|
)}
|
|
183
|
-
<span className="ml-auto pr-1">
|
|
184
|
-
<FieldCaret />
|
|
185
|
-
</span>
|
|
186
282
|
</div>
|
|
187
283
|
|
|
188
284
|
{description ? <p className={fieldHintStyles}>{description}</p> : null}
|
|
189
|
-
{errorMessage &&
|
|
190
|
-
<p className={fieldErrorStyles}>{errorMessage}</p>
|
|
191
|
-
) : null}
|
|
285
|
+
{errorMessage && !isCell ? <p className={fieldErrorStyles}>{errorMessage}</p> : null}
|
|
192
286
|
|
|
193
287
|
<Popover
|
|
194
288
|
isOpen={isOpen}
|
|
@@ -196,103 +290,71 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
196
290
|
triggerRef={triggerRef}
|
|
197
291
|
offset={6}
|
|
198
292
|
placement="bottom start"
|
|
293
|
+
// Non-modal: the trigger stays usable while the list is open — scroll
|
|
294
|
+
// the chips, click a ×, without first dismissing the popover. Clicks
|
|
295
|
+
// on the trigger itself must not count as "outside" (they toggle).
|
|
296
|
+
isNonModal
|
|
297
|
+
shouldCloseOnInteractOutside={(element) => !triggerRef.current?.contains(element)}
|
|
199
298
|
className={cn(
|
|
200
|
-
|
|
201
|
-
|
|
299
|
+
// At least as wide as the trigger, wider when a badge needs it —
|
|
300
|
+
// a narrow table column must not clip its own options.
|
|
301
|
+
'min-w-[var(--trigger-width)] w-max max-w-[min(28rem,calc(100vw-2rem))]',
|
|
302
|
+
// Float above modals + full-screen editors, matching Select.
|
|
303
|
+
'z-[1300]',
|
|
304
|
+
// The popover element itself takes focus when opened programmatically
|
|
305
|
+
// (defaultOpen) — no browser outline around the whole panel.
|
|
306
|
+
'outline-none rounded-md border border-rule bg-surface p-1',
|
|
202
307
|
'shadow-[var(--shadow-raised)]',
|
|
203
308
|
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
204
309
|
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
205
310
|
)}
|
|
206
311
|
>
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
312
|
+
<div ref={popoverRef}>
|
|
313
|
+
<ListBox
|
|
314
|
+
aria-label={typeof label === 'string' ? label : (ariaLabel ?? 'Options')}
|
|
315
|
+
selectionMode="multiple"
|
|
316
|
+
// RAC clears a multi-selection on Escape by default — here Escape
|
|
317
|
+
// must close the list (useOverlay) and keep the selection.
|
|
318
|
+
escapeKeyBehavior="none"
|
|
319
|
+
selectedKeys={selectedSet}
|
|
320
|
+
onSelectionChange={(keys) => {
|
|
321
|
+
if (keys === 'all') {
|
|
322
|
+
onSelectionChange(new Set(options.map((o) => o.id)))
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
onSelectionChange(keys as Set<T>)
|
|
326
|
+
}}
|
|
327
|
+
// Land in the list straight away so arrows / Enter / Escape work
|
|
328
|
+
// without a Tab first (non-modal popovers do not move focus).
|
|
329
|
+
autoFocus="first"
|
|
330
|
+
className="outline-none max-h-72 overflow-auto"
|
|
331
|
+
renderEmptyState={() => (
|
|
332
|
+
<div className={cn('px-3 py-2', fieldHintStyles)}>All options selected</div>
|
|
333
|
+
)}
|
|
334
|
+
>
|
|
335
|
+
{listboxOptions.map((option) => (
|
|
336
|
+
<ListBoxItem
|
|
337
|
+
key={option.id}
|
|
338
|
+
id={option.id}
|
|
339
|
+
textValue={option.label}
|
|
340
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
341
|
+
className={cn(
|
|
342
|
+
'group flex items-center gap-2.5',
|
|
343
|
+
'rounded-[6px] px-2.5 py-1.5',
|
|
344
|
+
'cursor-pointer outline-none select-none',
|
|
345
|
+
// Selection is carried by the check box, not a row fill.
|
|
346
|
+
'data-[hovered]:bg-surface-2',
|
|
347
|
+
'data-[focused]:bg-surface-2',
|
|
348
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
349
|
+
)}
|
|
350
|
+
>
|
|
351
|
+
<ListboxCheck />
|
|
352
|
+
<Badge {...badgeChrome(option)}>{option.label}</Badge>
|
|
353
|
+
</ListBoxItem>
|
|
354
|
+
))}
|
|
355
|
+
</ListBox>
|
|
356
|
+
</div>
|
|
243
357
|
</Popover>
|
|
244
358
|
</div>
|
|
245
359
|
)
|
|
246
360
|
}
|
|
247
|
-
|
|
248
|
-
/* =========================================================================
|
|
249
|
-
Internal: a Badge with a trailing × button baked into the pill.
|
|
250
|
-
The × inherits the badge's text color via `currentColor`, so it reads
|
|
251
|
-
correctly against every variant's fill.
|
|
252
|
-
========================================================================= */
|
|
253
|
-
|
|
254
|
-
function RemovableBadge({
|
|
255
|
-
children,
|
|
256
|
-
onRemove,
|
|
257
|
-
isDisabled,
|
|
258
|
-
...chrome
|
|
259
|
-
}: BadgeChrome & {
|
|
260
|
-
children: ReactNode
|
|
261
|
-
onRemove: () => void
|
|
262
|
-
isDisabled: boolean
|
|
263
|
-
}) {
|
|
264
|
-
return (
|
|
265
|
-
<Badge {...badgeChrome(chrome)}>
|
|
266
|
-
<span>{children}</span>
|
|
267
|
-
<button
|
|
268
|
-
type="button"
|
|
269
|
-
aria-label="Remove"
|
|
270
|
-
disabled={isDisabled}
|
|
271
|
-
onClick={(e) => {
|
|
272
|
-
e.stopPropagation()
|
|
273
|
-
onRemove()
|
|
274
|
-
}}
|
|
275
|
-
className={cn(
|
|
276
|
-
'inline-flex items-center justify-center shrink-0',
|
|
277
|
-
'size-3.5 rounded-full',
|
|
278
|
-
'text-current/70 hover:text-current hover:bg-current/10',
|
|
279
|
-
'outline-none cursor-pointer',
|
|
280
|
-
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
281
|
-
'disabled:opacity-45 disabled:cursor-not-allowed',
|
|
282
|
-
// Pull the button flush with the right edge so the pill still feels
|
|
283
|
-
// symmetric around the leading dot.
|
|
284
|
-
'-mr-1',
|
|
285
|
-
)}
|
|
286
|
-
>
|
|
287
|
-
<svg aria-hidden viewBox="0 0 10 10" className="size-2.5" fill="none">
|
|
288
|
-
<path
|
|
289
|
-
d="M2 2l6 6M8 2l-6 6"
|
|
290
|
-
stroke="currentColor"
|
|
291
|
-
strokeWidth="1.4"
|
|
292
|
-
strokeLinecap="round"
|
|
293
|
-
/>
|
|
294
|
-
</svg>
|
|
295
|
-
</button>
|
|
296
|
-
</Badge>
|
|
297
|
-
)
|
|
298
|
-
}
|
|
@@ -90,7 +90,7 @@ export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElemen
|
|
|
90
90
|
{...props}
|
|
91
91
|
className={cn(
|
|
92
92
|
'flex justify-between items-center',
|
|
93
|
-
'px-5 py-
|
|
93
|
+
'px-5 py-3 border-b border-rule',
|
|
94
94
|
'bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
|
|
95
95
|
'font-mono text-[11.5px] uppercase tracking-[0.06em] text-ink-4',
|
|
96
96
|
className,
|
|
@@ -3,6 +3,7 @@ import type { ColumnDef, SortingState } from '@tanstack/react-table'
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
|
|
5
5
|
import { Badge } from '../badge'
|
|
6
|
+
import { Button } from '../button'
|
|
6
7
|
import { DataTable, type InfiniteQueryLike } from './data-table'
|
|
7
8
|
|
|
8
9
|
/* =========================================================================
|
|
@@ -271,8 +272,8 @@ export const LoadingMore: Story = {
|
|
|
271
272
|
export const EndOfList: Story = {
|
|
272
273
|
parameters: { controls: { hideNoControlsWarning: true } },
|
|
273
274
|
render: () => {
|
|
274
|
-
// Small dataset whose first page already covers everything —
|
|
275
|
-
//
|
|
275
|
+
// Small dataset whose first page already covers everything — no loader,
|
|
276
|
+
// no further fetch once the initial load resolves.
|
|
276
277
|
const query = useFakeInfiniteQuery({ total: 12 })
|
|
277
278
|
return <DataTable<Row> ariaLabel="Shipments — end" query={query} columns={columns} />
|
|
278
279
|
},
|
|
@@ -369,3 +370,53 @@ export const RowClickable: Story = {
|
|
|
369
370
|
)
|
|
370
371
|
},
|
|
371
372
|
}
|
|
373
|
+
|
|
374
|
+
/** `bodyAfter` is a trailing row: directly below the last row, and sticky to
|
|
375
|
+
* the bottom of the scroll viewport while the end of the list is out of view. */
|
|
376
|
+
export const WithTrailingRow: Story = {
|
|
377
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
378
|
+
render: () => {
|
|
379
|
+
const query = useFakeInfiniteQuery({ total: 60 })
|
|
380
|
+
return (
|
|
381
|
+
<DataTable<Row>
|
|
382
|
+
ariaLabel="Shipments — trailing row"
|
|
383
|
+
query={query}
|
|
384
|
+
columns={columns}
|
|
385
|
+
bodyAfter={
|
|
386
|
+
<Button variant="subtle" size="sm">
|
|
387
|
+
+ Add row
|
|
388
|
+
</Button>
|
|
389
|
+
}
|
|
390
|
+
/>
|
|
391
|
+
)
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Column resizing: drag a header's trailing separator (or focus it and use
|
|
396
|
+
* ←/→). Widths are controlled — the story keeps them in state and feeds them
|
|
397
|
+
* back through each column's `size`. */
|
|
398
|
+
export const ResizableColumns: Story = {
|
|
399
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
400
|
+
render: () => {
|
|
401
|
+
const query = useFakeInfiniteQuery({ total: 24 })
|
|
402
|
+
const [widths, setWidths] = useState<Record<string, number>>({})
|
|
403
|
+
const sized = useMemo(
|
|
404
|
+
() =>
|
|
405
|
+
columns.map((column) => {
|
|
406
|
+
// TanStack derives a column's id from its accessorKey when none is set.
|
|
407
|
+
const id = column.id ?? ('accessorKey' in column ? String(column.accessorKey) : undefined)
|
|
408
|
+
return id && widths[id] ? { ...column, size: widths[id] } : column
|
|
409
|
+
}),
|
|
410
|
+
[widths],
|
|
411
|
+
)
|
|
412
|
+
return (
|
|
413
|
+
<DataTable<Row>
|
|
414
|
+
ariaLabel="Shipments — resizable"
|
|
415
|
+
query={query}
|
|
416
|
+
columns={sized}
|
|
417
|
+
enableColumnResizing
|
|
418
|
+
onResizeColumn={({ id, width }) => setWidths((prev) => ({ ...prev, [id]: width }))}
|
|
419
|
+
/>
|
|
420
|
+
)
|
|
421
|
+
},
|
|
422
|
+
}
|