@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * FilterMenu + FilterChipBar — the UI layer over `filter-core`.
3
+ *
4
+ * `docs/design-briefs/tasks-board-action-bar.md` §5 and
5
+ * `tasks-board-header-primitives.md` §5. The source's filter ENGINE is worth
6
+ * having and its seven UI files are not; the engine is already ported
7
+ * (`src/filter-core/**`, pure TypeScript, 148 tests) and this is the UI on our
8
+ * own primitives. It inherits none of the three bugs the brief names:
9
+ *
10
+ * - facet counts are recomputed from the rows passed in on every render
11
+ * (theirs froze at mount behind an empty dep array), and they are counted
12
+ * against `facetedRowsFor`, so picking one option does not zero the rest;
13
+ * - there is no `useMemo` after an early return, because there is no early
14
+ * return: the popover content only mounts when open;
15
+ * - the operator promotion rule is never re-derived here — every write goes
16
+ * through `toggleOptionValue` / `toggleMultiOptionValue` in the core.
17
+ *
18
+ * TWO STEPS. Step one is a search box over the filterable FIELDS, each row an
19
+ * icon, a name and a chevron saying it leads somewhere. Step two is a
20
+ * MULTI-SELECT: checkboxes, several values at once, each value's count as
21
+ * SUPERSCRIPT after its label (`Backlog⁵⁰`) rather than as a trailing chip.
22
+ *
23
+ * The field list shows ONLY `option` / `multiOption` columns: text, number and
24
+ * raw date columns need editors this component does not have, and offering a
25
+ * field that opens onto nothing is the dead-control failure the port exists to
26
+ * avoid. A caller may pass columns of any type — `filterRows` still honours
27
+ * them, they simply are not offered here. (A date dimension is best declared
28
+ * as an option column of derived buckets, which is what the Tasks board does
29
+ * with `Due`.)
30
+ *
31
+ * `FilterChipBar` is the other half of the loop: a bar that appears BELOW the
32
+ * action bar once anything is applied, each filter a three-part chip
33
+ * (field · verb · value) with its own remove control, and a single `Clear` at
34
+ * the trailing edge. Nothing writes a private parallel filter state — a facet
35
+ * click, a chip edit and a quick scope all go through `onFiltersChange`.
36
+ */
37
+
38
+ import {
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ type KeyboardEvent as ReactKeyboardEvent,
43
+ type ReactNode,
44
+ } from 'react'
45
+ import { ArrowLeft, Check, ChevronRight, ListFilter, Search, X } from 'lucide-react'
46
+ import { cn } from './lib/utils'
47
+ import { handleTabTrap } from './lib/focus'
48
+ import { Button } from './button'
49
+ import { Input } from './input'
50
+ import { Popover } from './popover'
51
+ import {
52
+ facetedCounts,
53
+ facetedRowsFor,
54
+ getFilter,
55
+ isColumnOfType,
56
+ removeFilter,
57
+ toggleMultiOptionValue,
58
+ toggleOptionValue,
59
+ type AnyColumnConfig,
60
+ type ColumnOption,
61
+ type FilterModel,
62
+ type FiltersState,
63
+ } from './filter-core'
64
+
65
+ /**
66
+ * Resolves an option's icon. Caller-supplied, because `filter-core` stays
67
+ * React-free and this package will not pull in Lucide's dynamic icon map for
68
+ * a handful of names.
69
+ */
70
+ export type FilterOptionIconRenderer = (
71
+ option: ColumnOption,
72
+ columnId: string,
73
+ ) => ReactNode
74
+
75
+ /** Resolves a FIELD's icon, for the step-one list. Same reasoning. */
76
+ export type FilterFieldIconRenderer = (columnId: string) => ReactNode
77
+
78
+ interface EditableColumn<TData> {
79
+ readonly config: AnyColumnConfig<TData>
80
+ readonly options: readonly ColumnOption[]
81
+ }
82
+
83
+ /** The columns this UI can actually edit, with their option lists. */
84
+ function editableColumns<TData>(
85
+ columns: ReadonlyArray<AnyColumnConfig<TData>>,
86
+ ): ReadonlyArray<EditableColumn<TData>> {
87
+ const out: EditableColumn<TData>[] = []
88
+ for (const config of columns) {
89
+ if (config.type !== 'option' && config.type !== 'multiOption') continue
90
+ out.push({ config, options: config.options ?? [] })
91
+ }
92
+ return out
93
+ }
94
+
95
+ /**
96
+ * The string values of an option-ish filter.
97
+ *
98
+ * Narrowed on the discriminant rather than cast: `FilterModel` is a union and
99
+ * a `date` filter's `values` really are `Date[]`, so `as readonly string[]`
100
+ * would be a lie the compiler happens to accept. A filter on a non-option
101
+ * column contributes no option values, which is exactly right here.
102
+ */
103
+ function optionValuesOf(filter: FilterModel | undefined): readonly string[] {
104
+ if (!filter) return []
105
+ return filter.type === 'option' || filter.type === 'multiOption'
106
+ ? filter.values
107
+ : []
108
+ }
109
+
110
+ /* ------------------------------------------------------------------ */
111
+ /* Step two — the multi-select value list */
112
+ /* ------------------------------------------------------------------ */
113
+
114
+ interface FilterValueListProps<TData> {
115
+ rows: readonly TData[]
116
+ columns: ReadonlyArray<AnyColumnConfig<TData>>
117
+ filters: FiltersState
118
+ column: AnyColumnConfig<TData>
119
+ options: readonly ColumnOption[]
120
+ onFiltersChange: (next: FiltersState) => void
121
+ renderOptionIcon?: FilterOptionIconRenderer
122
+ }
123
+
124
+ function FilterValueList<TData>({
125
+ rows,
126
+ columns,
127
+ filters,
128
+ column,
129
+ options,
130
+ onFiltersChange,
131
+ renderOptionIcon,
132
+ }: FilterValueListProps<TData>) {
133
+ const [query, setQuery] = useState('')
134
+
135
+ // Counted against everything surviving the OTHER columns' filters, so
136
+ // selecting one option leaves the rest of this list countable.
137
+ const counts = useMemo(
138
+ () => facetedCounts(facetedRowsFor(rows, columns, filters, column.id), column),
139
+ [rows, columns, filters, column],
140
+ )
141
+
142
+ const active = getFilter(filters, column.id)
143
+ const selected = new Set(optionValuesOf(active))
144
+
145
+ const needle = query.trim().toLowerCase()
146
+ const shown = needle
147
+ ? options.filter((option) => option.label.toLowerCase().includes(needle))
148
+ : options
149
+
150
+ const toggle = (value: string) => {
151
+ if (isColumnOfType(column, 'option')) {
152
+ onFiltersChange(toggleOptionValue(filters, column, value))
153
+ return
154
+ }
155
+ if (isColumnOfType(column, 'multiOption')) {
156
+ onFiltersChange(toggleMultiOptionValue(filters, column, value))
157
+ }
158
+ }
159
+
160
+ return (
161
+ <div className="flex flex-col gap-1.5">
162
+ <FilterSearchField
163
+ value={query}
164
+ onChange={setQuery}
165
+ label={`Search ${column.displayName.toLowerCase()} values`}
166
+ placeholder="Search…"
167
+ />
168
+ {shown.length === 0 ? (
169
+ <p
170
+ className="px-2 py-3 text-center text-[12px]"
171
+ style={{ color: 'rgb(var(--text-tertiary))' }}
172
+ >
173
+ No matches.
174
+ </p>
175
+ ) : (
176
+ <div role="group" aria-label={column.displayName} className="flex flex-col">
177
+ {shown.map((option) => {
178
+ const isOn = selected.has(option.value)
179
+ const count = counts.get(option.value) ?? 0
180
+ return (
181
+ <button
182
+ key={option.value}
183
+ type="button"
184
+ role="checkbox"
185
+ aria-checked={isOn}
186
+ // Named explicitly: the accessible name computed from the
187
+ // children runs the label into the superscript ("To do2"),
188
+ // which is what a screen reader would read out.
189
+ aria-label={`${option.label}, ${count} matching`}
190
+ onClick={() => toggle(option.value)}
191
+ className={cn(
192
+ // Optical spacing: vertical padding under the horizontal,
193
+ // because the line box already carries space the glyphs do
194
+ // not fill (action-bar brief §6.5).
195
+ 'flex min-h-7 items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1',
196
+ 'text-start text-[12.5px]',
197
+ 'transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
198
+ 'hover:bg-[rgb(var(--surface-hover))]',
199
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
200
+ )}
201
+ >
202
+ <span
203
+ aria-hidden="true"
204
+ className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[var(--radius-xs)]"
205
+ style={{
206
+ border: `1.5px solid rgb(var(${isOn ? '--accent' : '--border-strong'}))`,
207
+ background: isOn ? 'rgb(var(--accent))' : 'transparent',
208
+ color: 'white',
209
+ }}
210
+ >
211
+ {isOn ? <Check className="h-3 w-3" strokeWidth={3} /> : null}
212
+ </span>
213
+ {renderOptionIcon ? (
214
+ <span className="flex shrink-0 items-center">
215
+ {renderOptionIcon(option, column.id)}
216
+ </span>
217
+ ) : null}
218
+ <span className="min-w-0 flex-1 truncate">
219
+ {option.label}
220
+ {/* Superscript, not a trailing chip: the count belongs to
221
+ the label. Hung on the far edge it reads as a second
222
+ column of unrelated numbers. */}
223
+ <sup
224
+ aria-hidden="true"
225
+ className="ms-0.5 text-[9.5px] font-medium tabular-nums"
226
+ style={{ color: 'rgb(var(--text-tertiary))' }}
227
+ >
228
+ {count}
229
+ </sup>
230
+ </span>
231
+ </button>
232
+ )
233
+ })}
234
+ </div>
235
+ )}
236
+ </div>
237
+ )
238
+ }
239
+
240
+ /* ------------------------------------------------------------------ */
241
+ /* The search field, shared by both steps */
242
+ /* ------------------------------------------------------------------ */
243
+
244
+ function FilterSearchField({
245
+ value,
246
+ onChange,
247
+ label,
248
+ placeholder,
249
+ autoFocus,
250
+ }: {
251
+ value: string
252
+ onChange: (next: string) => void
253
+ label: string
254
+ placeholder: string
255
+ autoFocus?: boolean
256
+ }) {
257
+ return (
258
+ <Input
259
+ inputSize="sm"
260
+ type="text"
261
+ value={value}
262
+ aria-label={label}
263
+ placeholder={placeholder}
264
+ leadingAffix={<Search className="h-3.5 w-3.5" />}
265
+ {...(autoFocus ? { 'data-autofocus': 'true' } : {})}
266
+ onChange={(event) => onChange(event.target.value)}
267
+ />
268
+ )
269
+ }
270
+
271
+ /* ------------------------------------------------------------------ */
272
+ /* FilterMenu */
273
+ /* ------------------------------------------------------------------ */
274
+
275
+ export interface FilterMenuProps<TData> {
276
+ /**
277
+ * The UNFILTERED rows in scope. Facet counts are computed from these, minus
278
+ * the filters on the other columns — pass the filtered set and every count
279
+ * collapses to what is already on screen.
280
+ */
281
+ rows: readonly TData[]
282
+ columns: ReadonlyArray<AnyColumnConfig<TData>>
283
+ filters: FiltersState
284
+ onFiltersChange: (next: FiltersState) => void
285
+ /** Trigger label. Default "Filter". Always rendered beside the icon. */
286
+ label?: string
287
+ renderOptionIcon?: FilterOptionIconRenderer
288
+ renderFieldIcon?: FilterFieldIconRenderer
289
+ className?: string
290
+ }
291
+
292
+ export function FilterMenu<TData>({
293
+ rows,
294
+ columns,
295
+ filters,
296
+ onFiltersChange,
297
+ label = 'Filter',
298
+ renderOptionIcon,
299
+ renderFieldIcon,
300
+ className,
301
+ }: FilterMenuProps<TData>) {
302
+ const [open, setOpen] = useState(false)
303
+ const [columnId, setColumnId] = useState<string | null>(null)
304
+ const [query, setQuery] = useState('')
305
+ const contentRef = useRef<HTMLDivElement | null>(null)
306
+
307
+ const editable = useMemo(() => editableColumns(columns), [columns])
308
+ const active = editable.find((entry) => entry.config.id === columnId) ?? null
309
+
310
+ const needle = query.trim().toLowerCase()
311
+ const shownFields = needle
312
+ ? editable.filter((entry) =>
313
+ entry.config.displayName.toLowerCase().includes(needle),
314
+ )
315
+ : editable
316
+
317
+ const close = (next: boolean) => {
318
+ setOpen(next)
319
+ if (!next) {
320
+ // Reopening always lands on the field list — a popover that reopens
321
+ // three levels deep into where you were last is disorienting.
322
+ setColumnId(null)
323
+ setQuery('')
324
+ }
325
+ }
326
+
327
+ return (
328
+ <Popover open={open} onOpenChange={close}>
329
+ <Popover.Trigger asChild>
330
+ {/* Icon AND text, never one of the two (action-bar brief §2). */}
331
+ <Button
332
+ variant="secondary"
333
+ size="sm"
334
+ className={className ?? ''}
335
+ leadingIcon={
336
+ <ListFilter className="h-3.5 w-3.5" strokeWidth={1.5} aria-hidden="true" />
337
+ }
338
+ >
339
+ {label}
340
+ </Button>
341
+ </Popover.Trigger>
342
+ <Popover.Content
343
+ ref={contentRef}
344
+ aria-label={active ? `Filter by ${active.config.displayName}` : 'Add a filter'}
345
+ align="end"
346
+ className="w-64 p-3"
347
+ onKeyDown={(event: ReactKeyboardEvent<HTMLDivElement>) =>
348
+ handleTabTrap(event, contentRef.current)
349
+ }
350
+ >
351
+ {active ? (
352
+ <div className="flex flex-col gap-1.5">
353
+ <div className="flex items-center gap-1.5">
354
+ <Button
355
+ variant="icon"
356
+ size="sm"
357
+ aria-label="Back to fields"
358
+ onClick={() => {
359
+ setColumnId(null)
360
+ setQuery('')
361
+ }}
362
+ >
363
+ <ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
364
+ </Button>
365
+ <span className="text-[12.5px] font-semibold">
366
+ {active.config.displayName}
367
+ </span>
368
+ </div>
369
+ <FilterValueList
370
+ rows={rows}
371
+ columns={columns}
372
+ filters={filters}
373
+ column={active.config}
374
+ options={active.options}
375
+ onFiltersChange={onFiltersChange}
376
+ {...(renderOptionIcon ? { renderOptionIcon } : {})}
377
+ />
378
+ </div>
379
+ ) : (
380
+ <div className="flex flex-col gap-1.5">
381
+ <FilterSearchField
382
+ value={query}
383
+ onChange={setQuery}
384
+ label="Search filter fields"
385
+ placeholder="Filter by…"
386
+ autoFocus
387
+ />
388
+ {shownFields.length === 0 ? (
389
+ <p
390
+ className="px-2 py-3 text-center text-[12px]"
391
+ style={{ color: 'rgb(var(--text-tertiary))' }}
392
+ >
393
+ No fields match.
394
+ </p>
395
+ ) : (
396
+ <div role="group" aria-label="Filter fields" className="flex flex-col">
397
+ {shownFields.map((entry) => {
398
+ const count = getFilter(filters, entry.config.id)?.values.length ?? 0
399
+ return (
400
+ <button
401
+ key={entry.config.id}
402
+ type="button"
403
+ aria-label={
404
+ count > 0
405
+ ? `${entry.config.displayName}, ${count} selected`
406
+ : entry.config.displayName
407
+ }
408
+ onClick={() => {
409
+ setColumnId(entry.config.id)
410
+ setQuery('')
411
+ }}
412
+ className={cn(
413
+ 'flex min-h-7 items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1',
414
+ 'text-start text-[12.5px]',
415
+ 'transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
416
+ 'hover:bg-[rgb(var(--surface-hover))]',
417
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
418
+ )}
419
+ >
420
+ {renderFieldIcon ? (
421
+ <span
422
+ className="flex shrink-0 items-center"
423
+ style={{ color: 'rgb(var(--text-tertiary))' }}
424
+ >
425
+ {renderFieldIcon(entry.config.id)}
426
+ </span>
427
+ ) : null}
428
+ <span className="flex-1 truncate">{entry.config.displayName}</span>
429
+ {count > 0 ? (
430
+ <span
431
+ className="shrink-0 text-[11.5px] tabular-nums"
432
+ style={{ color: 'rgb(var(--accent-ink))' }}
433
+ >
434
+ {count}
435
+ </span>
436
+ ) : null}
437
+ {/* The affordance that says this row leads somewhere. */}
438
+ <ChevronRight
439
+ className="h-3.5 w-3.5 shrink-0"
440
+ strokeWidth={1.5}
441
+ aria-hidden="true"
442
+ style={{ color: 'rgb(var(--text-muted))' }}
443
+ />
444
+ </button>
445
+ )
446
+ })}
447
+ </div>
448
+ )}
449
+ </div>
450
+ )}
451
+ </Popover.Content>
452
+ </Popover>
453
+ )
454
+ }
455
+
456
+ /* ------------------------------------------------------------------ */
457
+ /* FilterChipBar */
458
+ /* ------------------------------------------------------------------ */
459
+
460
+ /** "Building", "Building, Review", "Building, Review +2". */
461
+ function summarize(options: readonly ColumnOption[], values: readonly string[]): string {
462
+ const labels = values.map(
463
+ (value) => options.find((option) => option.value === value)?.label ?? value,
464
+ )
465
+ if (labels.length <= 2) return labels.join(', ')
466
+ return `${labels.slice(0, 2).join(', ')} +${labels.length - 2}`
467
+ }
468
+
469
+ export interface FilterChipBarProps<TData> {
470
+ rows: readonly TData[]
471
+ columns: ReadonlyArray<AnyColumnConfig<TData>>
472
+ filters: FiltersState
473
+ onFiltersChange: (next: FiltersState) => void
474
+ renderOptionIcon?: FilterOptionIconRenderer
475
+ className?: string
476
+ }
477
+
478
+ /**
479
+ * The applied filters, as a bar under the action bar.
480
+ *
481
+ * ⚑ | Column is Building ✕ | ................ [Clear]
482
+ *
483
+ * Renders NOTHING when nothing is applied — no reserved strip, no empty frame
484
+ * — so an unfiltered board is the action bar and the columns, nothing else.
485
+ */
486
+ export function FilterChipBar<TData>({
487
+ rows,
488
+ columns,
489
+ filters,
490
+ onFiltersChange,
491
+ renderOptionIcon,
492
+ className,
493
+ }: FilterChipBarProps<TData>) {
494
+ const editable = useMemo(() => editableColumns(columns), [columns])
495
+ if (filters.length === 0) return null
496
+
497
+ return (
498
+ <div
499
+ role="group"
500
+ aria-label="Active filters"
501
+ className={cn(
502
+ 'flex flex-wrap items-center gap-2 rounded-[var(--radius-lg)] px-3 py-2',
503
+ className,
504
+ )}
505
+ style={{
506
+ background: 'rgb(var(--surface-frame))',
507
+ border: '1px solid rgb(var(--border))',
508
+ }}
509
+ >
510
+ <ListFilter
511
+ className="h-3.5 w-3.5 shrink-0"
512
+ strokeWidth={1.5}
513
+ aria-hidden="true"
514
+ style={{ color: 'rgb(var(--text-tertiary))' }}
515
+ />
516
+ {filters.map((filter) => {
517
+ const entry = editable.find((candidate) => candidate.config.id === filter.columnId)
518
+ // A filter on a column this UI cannot edit is still real and still
519
+ // removable — it just does not open a value list.
520
+ const displayName =
521
+ entry?.config.displayName ??
522
+ columns.find((column) => column.id === filter.columnId)?.displayName ??
523
+ filter.columnId
524
+ const summary = summarize(entry?.options ?? [], optionValuesOf(filter))
525
+ return (
526
+ <FilterChip
527
+ key={filter.columnId}
528
+ displayName={displayName}
529
+ operator={filter.operator}
530
+ summary={summary}
531
+ onRemove={() => onFiltersChange(removeFilter(filters, filter.columnId))}
532
+ {...(entry
533
+ ? {
534
+ edit: (
535
+ <FilterValueList
536
+ rows={rows}
537
+ columns={columns}
538
+ filters={filters}
539
+ column={entry.config}
540
+ options={entry.options}
541
+ onFiltersChange={onFiltersChange}
542
+ {...(renderOptionIcon ? { renderOptionIcon } : {})}
543
+ />
544
+ ),
545
+ }
546
+ : {})}
547
+ />
548
+ )
549
+ })}
550
+ <div className="flex-1" />
551
+ <Button variant="secondary" size="sm" onClick={() => onFiltersChange([])}>
552
+ Clear
553
+ </Button>
554
+ </div>
555
+ )
556
+ }
557
+
558
+ function FilterChip({
559
+ displayName,
560
+ operator,
561
+ summary,
562
+ onRemove,
563
+ edit,
564
+ }: {
565
+ displayName: string
566
+ operator: string
567
+ summary: string
568
+ onRemove: () => void
569
+ edit?: ReactNode
570
+ }) {
571
+ const contentRef = useRef<HTMLDivElement | null>(null)
572
+ const chipLabel = `${displayName} ${operator} ${summary}`
573
+
574
+ const body = (
575
+ <span className="flex items-center gap-1.5 text-[11.5px]">
576
+ <span className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
577
+ {displayName}
578
+ </span>
579
+ {/* The verb reads in the mono face and the muted ink: it is the joint
580
+ between the two halves, not a third thing to read. */}
581
+ <span
582
+ className="text-[10.5px]"
583
+ style={{ color: 'rgb(var(--text-muted))', fontFamily: 'var(--font-mono)' }}
584
+ >
585
+ {operator}
586
+ </span>
587
+ <span className="font-medium" style={{ color: 'rgb(var(--accent-ink))' }}>
588
+ {summary}
589
+ </span>
590
+ </span>
591
+ )
592
+
593
+ return (
594
+ <span
595
+ // CONCENTRIC WITHIN 8px (action-bar brief §6.1). The chip is
596
+ // --radius-sm (8) and pads its trailing control by 2px, so the remove
597
+ // control is exactly 8 − 2 = 6 = --radius-xs. Both are real tokens and
598
+ // the derivation is exact, not approximate.
599
+ className="inline-flex min-h-7 items-center gap-1.5 rounded-[var(--radius-sm)] py-0.5 pe-0.5 ps-2"
600
+ style={{
601
+ background: 'rgb(var(--surface-card))',
602
+ border: '1px solid rgb(var(--border))',
603
+ boxShadow: 'var(--shadow-sm)',
604
+ }}
605
+ >
606
+ {edit ? (
607
+ <Popover>
608
+ <Popover.Trigger asChild>
609
+ <button
610
+ type="button"
611
+ aria-label={`Edit filter: ${chipLabel}`}
612
+ className="rounded-[var(--radius-xs)] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
613
+ >
614
+ {body}
615
+ </button>
616
+ </Popover.Trigger>
617
+ <Popover.Content
618
+ ref={contentRef}
619
+ aria-label={`Filter by ${displayName}`}
620
+ align="start"
621
+ className="w-60 p-3"
622
+ onKeyDown={(event: ReactKeyboardEvent<HTMLDivElement>) =>
623
+ handleTabTrap(event, contentRef.current)
624
+ }
625
+ >
626
+ {edit}
627
+ </Popover.Content>
628
+ </Popover>
629
+ ) : (
630
+ <span>{body}</span>
631
+ )}
632
+ {/* 24×24 hit area (interface standards §6), inside a 28px chip. */}
633
+ <button
634
+ type="button"
635
+ aria-label={`Remove filter: ${chipLabel}`}
636
+ onClick={onRemove}
637
+ className={cn(
638
+ 'flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-xs)]',
639
+ 'transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
640
+ 'hover:bg-[rgb(var(--surface-hover))]',
641
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
642
+ )}
643
+ style={{ color: 'rgb(var(--text-tertiary))' }}
644
+ >
645
+ <X className="h-3 w-3" strokeWidth={2} aria-hidden="true" />
646
+ </button>
647
+ </span>
648
+ )
649
+ }
@@ -49,6 +49,13 @@ export interface FloatingDrawerProps {
49
49
  onResize?: (next: number) => void
50
50
  /** Header title — short, e.g. the record's name or the drawer's purpose. */
51
51
  title?: ReactNode
52
+ /**
53
+ * Accessible name for the dialog, for a drawer whose `title` is JSX or
54
+ * absent. The panel derives `aria-label` from `title` only when it is a
55
+ * plain string, so a caller that owns its own header (or passes a node)
56
+ * otherwise ships a dialog with NO name at all.
57
+ */
58
+ ariaLabel?: string
52
59
  /** Optional element rendered to the right of the title, before close. */
53
60
  titleSlot?: ReactNode
54
61
  /** Optional footer — action buttons. */
@@ -110,6 +117,7 @@ export function FloatingDrawer({
110
117
  resizable = false,
111
118
  onResize,
112
119
  title,
120
+ ariaLabel,
113
121
  titleSlot,
114
122
  footer,
115
123
  children,
@@ -241,7 +249,17 @@ export function FloatingDrawer({
241
249
  <aside
242
250
  role="dialog"
243
251
  aria-modal={modal ? 'true' : undefined}
244
- aria-label={typeof title === 'string' ? title : undefined}
252
+ aria-label={typeof title === 'string' ? title : ariaLabel}
253
+ /**
254
+ * A CLOSED drawer is still mounted — that is how it animates out, and
255
+ * consumers that keep it mounted rely on it. But an off-screen panel
256
+ * left in the accessibility tree is a nameless dialog a screen-reader
257
+ * user can land in, and its controls stay in the tab order, which is a
258
+ * keyboard hole nobody sees. `inert` removes both, and reverts the
259
+ * moment it opens.
260
+ */
261
+ aria-hidden={isOpen ? undefined : 'true'}
262
+ inert={!isOpen}
245
263
  className={cn(
246
264
  panelZ,
247
265
  'flex flex-col overflow-hidden',