@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,247 @@
1
+ /**
2
+ * @package @lovett/ui
3
+ * @file filter-core/types.ts
4
+ *
5
+ * The type layer for the board filter model.
6
+ *
7
+ * Ported (not copied) from bazza/ui's `data-table-filter` core, as vendored
8
+ * in continuity-control. Everything React, i18n and domain-specific has been
9
+ * stripped: this file is pure TypeScript and imports nothing.
10
+ *
11
+ * The one idea worth keeping verbatim is the TYPE GATING on `ColumnConfig`:
12
+ * `options` exists only on option-ish columns, `min`/`max` only on number
13
+ * columns, expressed as conditional types resolving to `never`. An illegal
14
+ * config does not compile — the config is the schema.
15
+ *
16
+ * Deliberate divergences from the source:
17
+ * - `FilterModel` defaults to a DISCRIMINATED UNION over `type` rather than
18
+ * `any`, so the combinator can switch exhaustively and a missed case is a
19
+ * compile error (source used `TType extends ColumnDataType = any`).
20
+ * - `multiOption` filter values are a FLAT `string[]`. The source's types say
21
+ * flat while its predicate reads `values[0]` as an array — the two never
22
+ * agreed. Flat is the honest shape.
23
+ * - `ColumnOption.icon` is a string (a Lucide icon NAME), not a React
24
+ * element. Icon resolution is the UI layer's job; this layer stays pure.
25
+ */
26
+
27
+ /** `T`, or absent. Accessors are allowed to return nothing for a row. */
28
+ export type Nullable<T> = T | null | undefined
29
+
30
+ /** The element type of an array; `T` itself when `T` is not an array. */
31
+ export type ElementType<T> = T extends readonly (infer U)[] ? U : T
32
+
33
+ /**
34
+ * Every column data type we handle, as a runtime list AND a union.
35
+ *
36
+ * The runtime list is load-bearing: `defineColumns` validates a declared
37
+ * `type` against it and THROWS on anything else. The source's combinator
38
+ * instead fell through to a silent `return true` for text/number/date, which
39
+ * meant adding a date column quietly disabled filtering on it.
40
+ */
41
+ export const COLUMN_DATA_TYPES = [
42
+ 'text',
43
+ 'number',
44
+ 'date',
45
+ 'option',
46
+ 'multiOption',
47
+ ] as const
48
+
49
+ export type ColumnDataType = (typeof COLUMN_DATA_TYPES)[number]
50
+
51
+ /** The two types that draw their values from a fixed option list. */
52
+ export type OptionBasedColumnDataType = Extract<
53
+ ColumnDataType,
54
+ 'option' | 'multiOption'
55
+ >
56
+
57
+ /** Maps a column data type to the native value a row carries for it. */
58
+ export type ColumnDataNativeMap = {
59
+ text: string
60
+ number: number
61
+ date: Date
62
+ option: string
63
+ multiOption: readonly string[]
64
+ }
65
+
66
+ /** What a column accessor is allowed to return, per type. Absent is legal. */
67
+ export type ColumnValueMap = {
68
+ [K in ColumnDataType]: Nullable<ColumnDataNativeMap[K]>
69
+ }
70
+
71
+ /**
72
+ * The values carried by a filter, per column type. ALWAYS an array, even for
73
+ * single-value operators — one shape to serialise, one shape to validate.
74
+ */
75
+ export type FilterValues<T extends ColumnDataType> = Array<
76
+ ElementType<ColumnDataNativeMap[T]>
77
+ >
78
+
79
+ /** A selectable value on an `option` / `multiOption` column. */
80
+ export interface ColumnOption {
81
+ /** Stable id. This is what lands in `FilterModel.values` and in the URL. */
82
+ readonly value: string
83
+ /** Human label. */
84
+ readonly label: string
85
+ /**
86
+ * OPTIONAL Lucide icon name (e.g. `'CircleCheck'`), resolved by the UI
87
+ * layer. A string, never a component — this module must stay React-free.
88
+ */
89
+ readonly icon?: string
90
+ }
91
+
92
+ /** Reads the filterable value for one column out of one row. */
93
+ export type ColumnAccessor<TData, TType extends ColumnDataType> = (
94
+ row: TData,
95
+ ) => ColumnValueMap[TType]
96
+
97
+ /**
98
+ * The configuration for one filterable column.
99
+ *
100
+ * Type gating (the reason this is a conditional type and not a flat object):
101
+ *
102
+ * defineColumns<Task>([
103
+ * { id: 'title', type: 'text', accessor: r => r.title, displayName: 'Title',
104
+ * min: 3 }, // <- compile error: min is `never` on text
105
+ * { id: 'priority', type: 'option', accessor: r => r.priority,
106
+ * displayName: 'Priority', options: [...] }, // <- fine
107
+ * ])
108
+ */
109
+ export type ColumnConfig<
110
+ TData,
111
+ TType extends ColumnDataType = ColumnDataType,
112
+ > = {
113
+ /** Stable id. Matches `FilterModel.columnId` and is what the URL carries. */
114
+ readonly id: string
115
+ readonly accessor: ColumnAccessor<TData, TType>
116
+ readonly displayName: string
117
+ readonly type: TType
118
+ /** Option list. Only legal on `option` / `multiOption`. */
119
+ readonly options?: TType extends OptionBasedColumnDataType
120
+ ? readonly ColumnOption[]
121
+ : never
122
+ /** Declared lower bound. Only legal on `number`. */
123
+ readonly min?: TType extends 'number' ? number : never
124
+ /** Declared upper bound. Only legal on `number`. */
125
+ readonly max?: TType extends 'number' ? number : never
126
+ }
127
+
128
+ /**
129
+ * A column config of any type, as a discriminated union over `type`.
130
+ *
131
+ * This is what collections are typed as. It is NOT `ColumnConfig<TData>` with
132
+ * the default union parameter: the conditional gates distribute there and
133
+ * would re-admit `options` on a text column.
134
+ */
135
+ export type AnyColumnConfig<TData> = {
136
+ [K in ColumnDataType]: ColumnConfig<TData, K>
137
+ }[ColumnDataType]
138
+
139
+ /* ------------------------------------------------------------------ */
140
+ /* Operators */
141
+ /* ------------------------------------------------------------------ */
142
+
143
+ export type TextFilterOperator = 'contains' | 'does not contain'
144
+
145
+ export type NumberFilterOperator =
146
+ | 'is'
147
+ | 'is not'
148
+ | 'is greater than'
149
+ | 'is greater than or equal to'
150
+ | 'is less than'
151
+ | 'is less than or equal to'
152
+ | 'is between'
153
+ | 'is not between'
154
+
155
+ export type DateFilterOperator =
156
+ | 'is'
157
+ | 'is not'
158
+ | 'is before'
159
+ | 'is on or after'
160
+ | 'is after'
161
+ | 'is on or before'
162
+ | 'is between'
163
+ | 'is not between'
164
+
165
+ export type OptionFilterOperator = 'is' | 'is not' | 'is any of' | 'is none of'
166
+
167
+ export type MultiOptionFilterOperator =
168
+ | 'include'
169
+ | 'exclude'
170
+ | 'include any of'
171
+ | 'include all of'
172
+ | 'exclude if any of'
173
+ | 'exclude if all'
174
+
175
+ /** Maps a column data type to its operator union. */
176
+ export type FilterOperators = {
177
+ text: TextFilterOperator
178
+ number: NumberFilterOperator
179
+ date: DateFilterOperator
180
+ option: OptionFilterOperator
181
+ multiOption: MultiOptionFilterOperator
182
+ }
183
+
184
+ /** How many values an operator applies to, as a coarse UI hint. */
185
+ export type FilterOperatorTarget = 'single' | 'multiple'
186
+
187
+ /**
188
+ * How many values an operator REQUIRES. This is the declarative replacement
189
+ * for the source's `operator in ['is between', 'is not between']` guard —
190
+ * `in` on an array tests indices, so that guard never fired (filter-fns.ts:80).
191
+ * An arity that lives in the operator table cannot be written that way.
192
+ */
193
+ export type FilterOperatorArity = 'one' | 'two' | 'many'
194
+
195
+ /** Metadata for one operator of one column type. */
196
+ export type FilterOperatorDetails<
197
+ TOperator,
198
+ TType extends ColumnDataType,
199
+ > = {
200
+ /** The operator itself. Also its display string — there is no i18n here. */
201
+ readonly value: TOperator
202
+ readonly target: FilterOperatorTarget
203
+ readonly arity: FilterOperatorArity
204
+ /** This operator is the SINGULAR of X: promote to X past one value. */
205
+ readonly singularOf?: FilterOperators[TType]
206
+ /** This operator is the PLURAL of Y: demote to Y at one value or fewer. */
207
+ readonly pluralOf?: FilterOperators[TType]
208
+ readonly isNegated: boolean
209
+ /** For a positive operator, its negated twin. */
210
+ readonly negation?: FilterOperators[TType]
211
+ /** For a negated operator, its positive twin. */
212
+ readonly negationOf?: FilterOperators[TType]
213
+ }
214
+
215
+ /** Every operator of one column type, keyed by operator. */
216
+ export type FilterDetails<T extends ColumnDataType> = {
217
+ [K in FilterOperators[T]]: FilterOperatorDetails<K, T>
218
+ }
219
+
220
+ /** Every operator of every column type. */
221
+ export type FilterTypeOperatorDetails = {
222
+ [K in ColumnDataType]: FilterDetails<K>
223
+ }
224
+
225
+ /* ------------------------------------------------------------------ */
226
+ /* Filter state */
227
+ /* ------------------------------------------------------------------ */
228
+
229
+ /**
230
+ * One column's filter.
231
+ *
232
+ * With no type argument this distributes into a discriminated union over
233
+ * `type`, which is what makes the combinator exhaustively checkable.
234
+ * `FilterModel<'date'>` picks out the single date member.
235
+ */
236
+ export type FilterModel<TType extends ColumnDataType = ColumnDataType> =
237
+ TType extends ColumnDataType
238
+ ? {
239
+ readonly columnId: string
240
+ readonly type: TType
241
+ readonly operator: FilterOperators[TType]
242
+ readonly values: FilterValues<TType>
243
+ }
244
+ : never
245
+
246
+ /** The whole filter state. At most one entry per column. */
247
+ export type FiltersState = FilterModel[]
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @package @lovett/ui
3
+ * @file filter-core/url.ts
4
+ *
5
+ * Filter state <-> URL search param.
6
+ *
7
+ * Filters belong in the URL: a compound filtered board is then a shareable
8
+ * link, and back/forward work. The consumer wires this to React Router's
9
+ * `useSearchParams`; this module knows nothing about React or the router.
10
+ *
11
+ * PARSING IS DEFENSIVE, NOT TRUSTING. A query string is untrusted input that
12
+ * a user can hand-edit and a third party can hand someone in a link, so
13
+ * `parseFilters` VALIDATES every entry and DROPS the ones that do not hold
14
+ * up, rather than throwing and taking the board down with them. CLAUDE.md:
15
+ * parse once at the boundary, constrain lengths and ranges as well as shapes,
16
+ * and pass validated types inward.
17
+ *
18
+ * NOTE ON ZOD. The brief called for zod, and zod is a workspace dependency —
19
+ * but it is NOT a dependency of `@lovett/ui`, and adding one to the design
20
+ * system is an ADR-level decision (CLAUDE.md §1) plus an edit to
21
+ * package.json, which is outside this change's scope. The validation below is
22
+ * hand-written to the same standard: every field checked, every bound
23
+ * enforced, nothing inferred from the input's own shape. It is deliberately
24
+ * structured as one `parseEntry` funnel so swapping in a zod schema later is
25
+ * a local change.
26
+ *
27
+ * WHAT IS ENFORCED
28
+ * - the payload is JSON, is an array, and is under a size cap
29
+ * - `columnId` is a non-empty, bounded string and not a prototype key
30
+ * - `type` is one of the five declared column data types
31
+ * - `operator` is a member of THAT type's operator table
32
+ * - `values` is a real array, bounded in length, with every element valid
33
+ * for the type (bounded string / finite number / parseable date)
34
+ * - the value COUNT satisfies the operator's arity, so nothing that
35
+ * survives parsing can later make a predicate throw
36
+ * - at most one filter per column, first occurrence wins
37
+ */
38
+
39
+ import { operatorValueArity, isOperatorOf } from './operators'
40
+ import { COLUMN_DATA_TYPES } from './types'
41
+ import type { ColumnDataType, FilterModel, FiltersState } from './types'
42
+
43
+ /** The conventional search-param name. Callers may use another. */
44
+ export const FILTERS_SEARCH_PARAM = 'filters'
45
+
46
+ /** Bounds. Generous for real use, small enough that a hostile link is inert. */
47
+ const MAX_RAW_LENGTH = 8192
48
+ const MAX_FILTERS = 32
49
+ const MAX_VALUES_PER_FILTER = 64
50
+ const MAX_STRING_LENGTH = 256
51
+ const MAX_COLUMN_ID_LENGTH = 128
52
+
53
+ /** Keys that must never be accepted as a column id. */
54
+ const FORBIDDEN_COLUMN_IDS: ReadonlySet<string> = new Set([
55
+ '__proto__',
56
+ 'constructor',
57
+ 'prototype',
58
+ ])
59
+
60
+ /* ------------------------------------------------------------------ */
61
+ /* Serialise */
62
+ /* ------------------------------------------------------------------ */
63
+
64
+ /**
65
+ * Encode a filter state for a search param.
66
+ *
67
+ * An empty state serialises to the EMPTY STRING, which is the caller's cue to
68
+ * delete the param rather than write `?filters=[]`. Dates go out as ISO
69
+ * strings; everything else is already JSON-native.
70
+ */
71
+ export function serializeFilters(state: FiltersState): string {
72
+ if (state.length === 0) return ''
73
+
74
+ const wire = state.map((filter) => ({
75
+ columnId: filter.columnId,
76
+ type: filter.type,
77
+ operator: filter.operator,
78
+ values:
79
+ filter.type === 'date'
80
+ ? filter.values.map((value) => value.toISOString())
81
+ : filter.values,
82
+ }))
83
+
84
+ return JSON.stringify(wire)
85
+ }
86
+
87
+ /* ------------------------------------------------------------------ */
88
+ /* Parse */
89
+ /* ------------------------------------------------------------------ */
90
+
91
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
92
+ return (
93
+ typeof value === 'object' &&
94
+ value !== null &&
95
+ !Array.isArray(value) &&
96
+ !(value instanceof Date)
97
+ )
98
+ }
99
+
100
+ /** Own-property read. An inherited or prototype-shaped key reads as absent. */
101
+ function ownProp(record: Record<string, unknown>, key: string): unknown {
102
+ return Object.hasOwn(record, key) ? record[key] : undefined
103
+ }
104
+
105
+ function asColumnId(value: unknown): string | null {
106
+ if (typeof value !== 'string') return null
107
+ if (value === '' || value.length > MAX_COLUMN_ID_LENGTH) return null
108
+ if (FORBIDDEN_COLUMN_IDS.has(value)) return null
109
+ return value
110
+ }
111
+
112
+ function asColumnDataType(value: unknown): ColumnDataType | null {
113
+ if (typeof value !== 'string') return null
114
+ for (const type of COLUMN_DATA_TYPES) if (type === value) return type
115
+ return null
116
+ }
117
+
118
+ function asRawValues(value: unknown): unknown[] | null {
119
+ if (!Array.isArray(value)) return null
120
+ if (value.length > MAX_VALUES_PER_FILTER) return null
121
+ return value
122
+ }
123
+
124
+ function asStrings(raw: readonly unknown[]): string[] | null {
125
+ const out: string[] = []
126
+ for (const item of raw) {
127
+ if (typeof item !== 'string') return null
128
+ if (item.length > MAX_STRING_LENGTH) return null
129
+ out.push(item)
130
+ }
131
+ return out
132
+ }
133
+
134
+ function asNumbers(raw: readonly unknown[]): number[] | null {
135
+ const out: number[] = []
136
+ for (const item of raw) {
137
+ if (typeof item !== 'number' || !Number.isFinite(item)) return null
138
+ out.push(item)
139
+ }
140
+ return out
141
+ }
142
+
143
+ function asDates(raw: readonly unknown[]): Date[] | null {
144
+ const out: Date[] = []
145
+ for (const item of raw) {
146
+ if (item instanceof Date) {
147
+ if (Number.isNaN(item.getTime())) return null
148
+ out.push(new Date(item.getTime()))
149
+ continue
150
+ }
151
+ if (typeof item !== 'string' || item.length > MAX_STRING_LENGTH) return null
152
+ const parsed = new Date(item)
153
+ if (Number.isNaN(parsed.getTime())) return null
154
+ out.push(parsed)
155
+ }
156
+ return out
157
+ }
158
+
159
+ function arityHolds(
160
+ type: ColumnDataType,
161
+ operator: string,
162
+ count: number,
163
+ ): boolean {
164
+ if (!isOperatorOf(type, operator)) return false
165
+ const [min, max] = operatorValueArity(type, operator)
166
+ return count >= min && count <= max
167
+ }
168
+
169
+ /**
170
+ * Validate one wire entry into a `FilterModel`, or `null` if it does not hold
171
+ * up. Every branch builds a FRESH object literal from validated primitives —
172
+ * the parsed input is never spread, so a `__proto__`-shaped key cannot ride
173
+ * along into the model.
174
+ */
175
+ function parseEntry(raw: unknown): FilterModel | null {
176
+ if (!isPlainRecord(raw)) return null
177
+
178
+ const columnId = asColumnId(ownProp(raw, 'columnId'))
179
+ if (columnId === null) return null
180
+
181
+ const type = asColumnDataType(ownProp(raw, 'type'))
182
+ if (type === null) return null
183
+
184
+ const operator = ownProp(raw, 'operator')
185
+ if (typeof operator !== 'string') return null
186
+
187
+ const rawValues = asRawValues(ownProp(raw, 'values'))
188
+ if (rawValues === null) return null
189
+
190
+ if (!arityHolds(type, operator, rawValues.length)) return null
191
+
192
+ switch (type) {
193
+ case 'text': {
194
+ const values = asStrings(rawValues)
195
+ if (!values || !isOperatorOf('text', operator)) return null
196
+ return { columnId, type: 'text', operator, values }
197
+ }
198
+ case 'number': {
199
+ const values = asNumbers(rawValues)
200
+ if (!values || !isOperatorOf('number', operator)) return null
201
+ return { columnId, type: 'number', operator, values }
202
+ }
203
+ case 'date': {
204
+ const values = asDates(rawValues)
205
+ if (!values || !isOperatorOf('date', operator)) return null
206
+ return { columnId, type: 'date', operator, values }
207
+ }
208
+ case 'option': {
209
+ const values = asStrings(rawValues)
210
+ if (!values || !isOperatorOf('option', operator)) return null
211
+ return { columnId, type: 'option', operator, values }
212
+ }
213
+ case 'multiOption': {
214
+ const values = asStrings(rawValues)
215
+ if (!values || !isOperatorOf('multiOption', operator)) return null
216
+ return { columnId, type: 'multiOption', operator, values }
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Decode a filter state from a search param.
223
+ *
224
+ * Accepts the raw param string (what `URLSearchParams.get` returns, including
225
+ * `null`) or an already-parsed value. NEVER THROWS: a malformed payload, a
226
+ * malformed entry, an unknown operator, a wrong-shaped value list or a
227
+ * value count the operator cannot use all resolve to that entry being
228
+ * dropped. A caller that wants to know it happened compares lengths, or
229
+ * re-serialises the result and rewrites the param.
230
+ */
231
+ export function parseFilters(raw: unknown): FiltersState {
232
+ let source: unknown = raw
233
+
234
+ if (source === null || source === undefined) return []
235
+
236
+ if (typeof source === 'string') {
237
+ const trimmed = source.trim()
238
+ if (trimmed === '' || trimmed.length > MAX_RAW_LENGTH) return []
239
+ try {
240
+ source = JSON.parse(trimmed)
241
+ } catch {
242
+ return []
243
+ }
244
+ }
245
+
246
+ if (!Array.isArray(source)) return []
247
+
248
+ const state: FiltersState = []
249
+ const seen = new Set<string>()
250
+
251
+ for (const entry of source) {
252
+ if (state.length >= MAX_FILTERS) break
253
+ const filter = parseEntry(entry)
254
+ if (!filter) continue
255
+ if (seen.has(filter.columnId)) continue
256
+ seen.add(filter.columnId)
257
+ state.push(filter)
258
+ }
259
+
260
+ return state
261
+ }
@@ -51,6 +51,12 @@ export interface FilterDropdownProps<T extends string = string> {
51
51
  neutralValue?: T
52
52
  /** Defaults to the option label. */
53
53
  triggerValueLabel?: string
54
+ /**
55
+ * Optional adornment at the leading edge of the trigger — a funnel, a
56
+ * category glyph. It inherits the trigger's colour, so it flips with the
57
+ * active state like the rest of the chip and never needs its own variant.
58
+ */
59
+ icon?: ReactNode
54
60
  className?: string
55
61
  align?: 'left' | 'right'
56
62
  }
@@ -62,6 +68,7 @@ export function FilterDropdown<T extends string = string>({
62
68
  options,
63
69
  neutralValue = 'all' as T,
64
70
  triggerValueLabel,
71
+ icon,
65
72
  className,
66
73
  align = 'left',
67
74
  }: FilterDropdownProps<T>) {
@@ -173,6 +180,11 @@ export function FilterDropdown<T extends string = string>({
173
180
  borderRadius: 999,
174
181
  }}
175
182
  >
183
+ {icon ? (
184
+ <span className="shrink-0 inline-flex items-center" aria-hidden="true">
185
+ {icon}
186
+ </span>
187
+ ) : null}
176
188
  <span
177
189
  className="font-semibold"
178
190
  style={{