@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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
|
+
}
|
package/src/floating-drawer.tsx
CHANGED
|
@@ -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 :
|
|
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',
|