@proteos/ui 0.54.1 → 0.56.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.
Files changed (33) hide show
  1. package/dist/index.cjs +679 -321
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +84 -9
  4. package/dist/index.d.ts +84 -9
  5. package/dist/index.js +677 -324
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/badge/badge.stories.tsx +37 -1
  9. package/src/components/badge/badge.test.tsx +39 -2
  10. package/src/components/badge/badge.tsx +65 -1
  11. package/src/components/badge/index.ts +2 -2
  12. package/src/components/badge-group/badge-group.stories.tsx +75 -0
  13. package/src/components/badge-group/badge-group.test.tsx +121 -0
  14. package/src/components/badge-group/badge-group.tsx +208 -0
  15. package/src/components/badge-group/index.ts +7 -0
  16. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
  17. package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
  19. package/src/components/data-table/data-table.stories.tsx +53 -2
  20. package/src/components/data-table/data-table.tsx +45 -25
  21. package/src/components/field/listbox-check.tsx +44 -0
  22. package/src/components/field/use-chip-row-wrap.ts +45 -0
  23. package/src/components/multi-select/multi-select.tsx +8 -39
  24. package/src/components/record-filter/chip-value.tsx +6 -5
  25. package/src/components/record-filter/filter-value-input.tsx +11 -9
  26. package/src/components/record-filter/operators.test.ts +67 -12
  27. package/src/components/record-filter/operators.ts +43 -2
  28. package/src/components/record-filter/types.ts +6 -1
  29. package/src/components/table/table.test.tsx +88 -9
  30. package/src/components/table/table.tsx +139 -13
  31. package/src/components/tag-input/tag-input.test.tsx +67 -1
  32. package/src/components/tag-input/tag-input.tsx +98 -64
  33. 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 pill. The × remove button lives INSIDE each selected badge
22
- (no extra chip chrome), so the color coding from the Badge variant is
23
- what you see, cleanly.
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
- appearance === 'cell'
107
- ? 'focus-within:border-accent focus-within:shadow-none'
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
- // mount-only — autoFocus is a one-shot signal, not sustained.
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 flex-wrap items-center gap-1.5 cursor-pointer',
158
- minHeight,
159
- fieldChipPadding[size],
160
- 'pr-2',
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
- {selectedOptions.length === 0 ? (
165
- // No vertical padding — wrapper's p-1.5 + min-h-[size] govern the
166
- // overall height so it matches the Input/Select trigger at the same
167
- // size.
168
- <span className={cn('text-ink-4 font-sans px-1', fieldTextStyles[size])}>
169
- {placeholder}
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 && appearance !== 'cell' ? (
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
- 'w-[var(--trigger-width)]',
201
- 'rounded-md border border-rule bg-surface p-1',
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
- <ListBox
208
- aria-label={typeof label === 'string' ? label : 'Options'}
209
- selectionMode="multiple"
210
- selectedKeys={selectedSet}
211
- onSelectionChange={(keys) => {
212
- if (keys === 'all') {
213
- onSelectionChange(new Set(options.map((o) => o.id)))
214
- return
215
- }
216
- onSelectionChange(keys as Set<T>)
217
- }}
218
- className="outline-none max-h-72 overflow-auto"
219
- renderEmptyState={() => (
220
- <div className={cn('px-3 py-2', fieldHintStyles)}>All options selected</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',
231
- 'rounded-[6px] px-3 py-2',
232
- 'cursor-pointer outline-none select-none',
233
- 'data-[hovered]:bg-surface-2',
234
- 'data-[focused]:bg-surface-2',
235
- 'data-[selected]:bg-surface-3',
236
- 'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
237
- )}
238
- >
239
- <Badge {...badgeChrome(option)}>{option.label}</Badge>
240
- </ListBoxItem>
241
- ))}
242
- </ListBox>
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
- }
@@ -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 — end marker
275
- // appears as soon as the initial load resolves.
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
+ }