@lovett/ui 0.0.11 → 0.1.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 +5402 -71
- package/dist/index.js +21992 -11618
- 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 +208 -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__/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 +186 -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 +507 -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 +242 -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 +296 -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 +315 -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 +235 -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 +662 -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 +915 -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 +218 -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,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
|
+
}
|
package/src/filter-dropdown.tsx
CHANGED
|
@@ -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={{
|