@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,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/operators.ts
|
|
4
|
+
*
|
|
5
|
+
* The operator tables — one per column type — plus the defaults and the
|
|
6
|
+
* singular/plural promotion rule.
|
|
7
|
+
*
|
|
8
|
+
* Ported from bazza/ui's `data-table-filter`. Dropped on the way in:
|
|
9
|
+
* - `key` (i18n keys; we have no i18n and the operator IS its label).
|
|
10
|
+
* - `relativeOf` (a hand-maintained list of sibling operators that drifts
|
|
11
|
+
* the moment an operator is added). `relatedOperators()` derives the same
|
|
12
|
+
* answer from `target`, so it cannot go stale.
|
|
13
|
+
*
|
|
14
|
+
* Added: `arity`. See the note on `FilterOperatorArity` in types.ts — this is
|
|
15
|
+
* the structural fix for the `in`-on-an-array date guard.
|
|
16
|
+
*
|
|
17
|
+
* `isNegated` means "reads as a negative phrase" (`is not`, `exclude`,
|
|
18
|
+
* `does not contain`) and drives UI phrasing only. `negation` / `negationOf`
|
|
19
|
+
* give the logical COMPLEMENT, which is what `negateOperator()` swaps to.
|
|
20
|
+
* Ordering operators pair off mutually (`is before` <-> `is on or after`) and
|
|
21
|
+
* are all positive — calling `is less than` a negation would be nonsense.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ColumnDataType,
|
|
26
|
+
FilterDetails,
|
|
27
|
+
FilterOperatorArity,
|
|
28
|
+
FilterOperators,
|
|
29
|
+
FilterOperatorTarget,
|
|
30
|
+
FilterTypeOperatorDetails,
|
|
31
|
+
} from './types'
|
|
32
|
+
|
|
33
|
+
/* ------------------------------------------------------------------ */
|
|
34
|
+
/* Tables */
|
|
35
|
+
/* ------------------------------------------------------------------ */
|
|
36
|
+
|
|
37
|
+
export const textFilterOperators = {
|
|
38
|
+
contains: {
|
|
39
|
+
value: 'contains',
|
|
40
|
+
target: 'single',
|
|
41
|
+
arity: 'one',
|
|
42
|
+
isNegated: false,
|
|
43
|
+
negation: 'does not contain',
|
|
44
|
+
},
|
|
45
|
+
'does not contain': {
|
|
46
|
+
value: 'does not contain',
|
|
47
|
+
target: 'single',
|
|
48
|
+
arity: 'one',
|
|
49
|
+
isNegated: true,
|
|
50
|
+
negationOf: 'contains',
|
|
51
|
+
},
|
|
52
|
+
} as const satisfies FilterDetails<'text'>
|
|
53
|
+
|
|
54
|
+
export const numberFilterOperators = {
|
|
55
|
+
is: {
|
|
56
|
+
value: 'is',
|
|
57
|
+
target: 'single',
|
|
58
|
+
arity: 'one',
|
|
59
|
+
singularOf: 'is between',
|
|
60
|
+
isNegated: false,
|
|
61
|
+
negation: 'is not',
|
|
62
|
+
},
|
|
63
|
+
'is not': {
|
|
64
|
+
value: 'is not',
|
|
65
|
+
target: 'single',
|
|
66
|
+
arity: 'one',
|
|
67
|
+
singularOf: 'is not between',
|
|
68
|
+
isNegated: true,
|
|
69
|
+
negationOf: 'is',
|
|
70
|
+
},
|
|
71
|
+
'is greater than': {
|
|
72
|
+
value: 'is greater than',
|
|
73
|
+
target: 'single',
|
|
74
|
+
arity: 'one',
|
|
75
|
+
singularOf: 'is between',
|
|
76
|
+
isNegated: false,
|
|
77
|
+
negation: 'is less than or equal to',
|
|
78
|
+
},
|
|
79
|
+
'is greater than or equal to': {
|
|
80
|
+
value: 'is greater than or equal to',
|
|
81
|
+
target: 'single',
|
|
82
|
+
arity: 'one',
|
|
83
|
+
singularOf: 'is between',
|
|
84
|
+
isNegated: false,
|
|
85
|
+
negation: 'is less than',
|
|
86
|
+
},
|
|
87
|
+
'is less than': {
|
|
88
|
+
value: 'is less than',
|
|
89
|
+
target: 'single',
|
|
90
|
+
arity: 'one',
|
|
91
|
+
singularOf: 'is between',
|
|
92
|
+
isNegated: false,
|
|
93
|
+
negation: 'is greater than or equal to',
|
|
94
|
+
},
|
|
95
|
+
'is less than or equal to': {
|
|
96
|
+
value: 'is less than or equal to',
|
|
97
|
+
target: 'single',
|
|
98
|
+
arity: 'one',
|
|
99
|
+
singularOf: 'is between',
|
|
100
|
+
isNegated: false,
|
|
101
|
+
negation: 'is greater than',
|
|
102
|
+
},
|
|
103
|
+
'is between': {
|
|
104
|
+
value: 'is between',
|
|
105
|
+
target: 'multiple',
|
|
106
|
+
arity: 'two',
|
|
107
|
+
pluralOf: 'is',
|
|
108
|
+
isNegated: false,
|
|
109
|
+
negation: 'is not between',
|
|
110
|
+
},
|
|
111
|
+
'is not between': {
|
|
112
|
+
value: 'is not between',
|
|
113
|
+
target: 'multiple',
|
|
114
|
+
arity: 'two',
|
|
115
|
+
pluralOf: 'is not',
|
|
116
|
+
isNegated: true,
|
|
117
|
+
negationOf: 'is between',
|
|
118
|
+
},
|
|
119
|
+
} as const satisfies FilterDetails<'number'>
|
|
120
|
+
|
|
121
|
+
export const dateFilterOperators = {
|
|
122
|
+
is: {
|
|
123
|
+
value: 'is',
|
|
124
|
+
target: 'single',
|
|
125
|
+
arity: 'one',
|
|
126
|
+
singularOf: 'is between',
|
|
127
|
+
isNegated: false,
|
|
128
|
+
negation: 'is not',
|
|
129
|
+
},
|
|
130
|
+
'is not': {
|
|
131
|
+
value: 'is not',
|
|
132
|
+
target: 'single',
|
|
133
|
+
arity: 'one',
|
|
134
|
+
singularOf: 'is not between',
|
|
135
|
+
isNegated: true,
|
|
136
|
+
negationOf: 'is',
|
|
137
|
+
},
|
|
138
|
+
'is before': {
|
|
139
|
+
value: 'is before',
|
|
140
|
+
target: 'single',
|
|
141
|
+
arity: 'one',
|
|
142
|
+
singularOf: 'is between',
|
|
143
|
+
isNegated: false,
|
|
144
|
+
negation: 'is on or after',
|
|
145
|
+
},
|
|
146
|
+
'is on or after': {
|
|
147
|
+
value: 'is on or after',
|
|
148
|
+
target: 'single',
|
|
149
|
+
arity: 'one',
|
|
150
|
+
singularOf: 'is between',
|
|
151
|
+
isNegated: false,
|
|
152
|
+
negation: 'is before',
|
|
153
|
+
},
|
|
154
|
+
'is after': {
|
|
155
|
+
value: 'is after',
|
|
156
|
+
target: 'single',
|
|
157
|
+
arity: 'one',
|
|
158
|
+
singularOf: 'is between',
|
|
159
|
+
isNegated: false,
|
|
160
|
+
negation: 'is on or before',
|
|
161
|
+
},
|
|
162
|
+
'is on or before': {
|
|
163
|
+
value: 'is on or before',
|
|
164
|
+
target: 'single',
|
|
165
|
+
arity: 'one',
|
|
166
|
+
singularOf: 'is between',
|
|
167
|
+
isNegated: false,
|
|
168
|
+
negation: 'is after',
|
|
169
|
+
},
|
|
170
|
+
'is between': {
|
|
171
|
+
value: 'is between',
|
|
172
|
+
target: 'multiple',
|
|
173
|
+
arity: 'two',
|
|
174
|
+
pluralOf: 'is',
|
|
175
|
+
isNegated: false,
|
|
176
|
+
negation: 'is not between',
|
|
177
|
+
},
|
|
178
|
+
'is not between': {
|
|
179
|
+
value: 'is not between',
|
|
180
|
+
target: 'multiple',
|
|
181
|
+
arity: 'two',
|
|
182
|
+
pluralOf: 'is not',
|
|
183
|
+
isNegated: true,
|
|
184
|
+
negationOf: 'is between',
|
|
185
|
+
},
|
|
186
|
+
} as const satisfies FilterDetails<'date'>
|
|
187
|
+
|
|
188
|
+
export const optionFilterOperators = {
|
|
189
|
+
is: {
|
|
190
|
+
value: 'is',
|
|
191
|
+
target: 'single',
|
|
192
|
+
arity: 'one',
|
|
193
|
+
singularOf: 'is any of',
|
|
194
|
+
isNegated: false,
|
|
195
|
+
negation: 'is not',
|
|
196
|
+
},
|
|
197
|
+
'is not': {
|
|
198
|
+
value: 'is not',
|
|
199
|
+
target: 'single',
|
|
200
|
+
arity: 'one',
|
|
201
|
+
singularOf: 'is none of',
|
|
202
|
+
isNegated: true,
|
|
203
|
+
negationOf: 'is',
|
|
204
|
+
},
|
|
205
|
+
'is any of': {
|
|
206
|
+
value: 'is any of',
|
|
207
|
+
target: 'multiple',
|
|
208
|
+
arity: 'many',
|
|
209
|
+
pluralOf: 'is',
|
|
210
|
+
isNegated: false,
|
|
211
|
+
negation: 'is none of',
|
|
212
|
+
},
|
|
213
|
+
'is none of': {
|
|
214
|
+
value: 'is none of',
|
|
215
|
+
target: 'multiple',
|
|
216
|
+
arity: 'many',
|
|
217
|
+
pluralOf: 'is not',
|
|
218
|
+
isNegated: true,
|
|
219
|
+
negationOf: 'is any of',
|
|
220
|
+
},
|
|
221
|
+
} as const satisfies FilterDetails<'option'>
|
|
222
|
+
|
|
223
|
+
export const multiOptionFilterOperators = {
|
|
224
|
+
include: {
|
|
225
|
+
value: 'include',
|
|
226
|
+
target: 'single',
|
|
227
|
+
arity: 'one',
|
|
228
|
+
singularOf: 'include any of',
|
|
229
|
+
isNegated: false,
|
|
230
|
+
negation: 'exclude',
|
|
231
|
+
},
|
|
232
|
+
exclude: {
|
|
233
|
+
value: 'exclude',
|
|
234
|
+
target: 'single',
|
|
235
|
+
arity: 'one',
|
|
236
|
+
singularOf: 'exclude if any of',
|
|
237
|
+
isNegated: true,
|
|
238
|
+
negationOf: 'include',
|
|
239
|
+
},
|
|
240
|
+
'include any of': {
|
|
241
|
+
value: 'include any of',
|
|
242
|
+
target: 'multiple',
|
|
243
|
+
arity: 'many',
|
|
244
|
+
pluralOf: 'include',
|
|
245
|
+
isNegated: false,
|
|
246
|
+
negation: 'exclude if any of',
|
|
247
|
+
},
|
|
248
|
+
'include all of': {
|
|
249
|
+
value: 'include all of',
|
|
250
|
+
target: 'multiple',
|
|
251
|
+
arity: 'many',
|
|
252
|
+
pluralOf: 'include',
|
|
253
|
+
isNegated: false,
|
|
254
|
+
negation: 'exclude if all',
|
|
255
|
+
},
|
|
256
|
+
'exclude if any of': {
|
|
257
|
+
value: 'exclude if any of',
|
|
258
|
+
target: 'multiple',
|
|
259
|
+
arity: 'many',
|
|
260
|
+
pluralOf: 'exclude',
|
|
261
|
+
isNegated: true,
|
|
262
|
+
negationOf: 'include any of',
|
|
263
|
+
},
|
|
264
|
+
'exclude if all': {
|
|
265
|
+
value: 'exclude if all',
|
|
266
|
+
target: 'multiple',
|
|
267
|
+
arity: 'many',
|
|
268
|
+
pluralOf: 'exclude',
|
|
269
|
+
isNegated: true,
|
|
270
|
+
negationOf: 'include all of',
|
|
271
|
+
},
|
|
272
|
+
} as const satisfies FilterDetails<'multiOption'>
|
|
273
|
+
|
|
274
|
+
/** Every operator table, keyed by column data type. */
|
|
275
|
+
export const FILTER_OPERATOR_DETAILS: FilterTypeOperatorDetails = {
|
|
276
|
+
text: textFilterOperators,
|
|
277
|
+
number: numberFilterOperators,
|
|
278
|
+
date: dateFilterOperators,
|
|
279
|
+
option: optionFilterOperators,
|
|
280
|
+
multiOption: multiOptionFilterOperators,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The operator a fresh filter starts on, by type and by how many values the
|
|
285
|
+
* user picked first.
|
|
286
|
+
*/
|
|
287
|
+
export const DEFAULT_OPERATORS: {
|
|
288
|
+
[K in ColumnDataType]: Record<FilterOperatorTarget, FilterOperators[K]>
|
|
289
|
+
} = {
|
|
290
|
+
text: { single: 'contains', multiple: 'contains' },
|
|
291
|
+
number: { single: 'is', multiple: 'is between' },
|
|
292
|
+
date: { single: 'is', multiple: 'is between' },
|
|
293
|
+
option: { single: 'is', multiple: 'is any of' },
|
|
294
|
+
multiOption: { single: 'include', multiple: 'include any of' },
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ------------------------------------------------------------------ */
|
|
298
|
+
/* Lookups */
|
|
299
|
+
/* ------------------------------------------------------------------ */
|
|
300
|
+
|
|
301
|
+
/** Every operator legal for a column type, in table order. */
|
|
302
|
+
export function operatorsForType<TType extends ColumnDataType>(
|
|
303
|
+
type: TType,
|
|
304
|
+
): Array<FilterOperators[TType]> {
|
|
305
|
+
return Object.keys(FILTER_OPERATOR_DETAILS[type]) as Array<
|
|
306
|
+
FilterOperators[TType]
|
|
307
|
+
>
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Runtime membership test, narrowing to the operator union for `type`.
|
|
312
|
+
* The single validated entry point used by the URL parser.
|
|
313
|
+
*/
|
|
314
|
+
export function isOperatorOf<TType extends ColumnDataType>(
|
|
315
|
+
type: TType,
|
|
316
|
+
value: unknown,
|
|
317
|
+
): value is FilterOperators[TType] {
|
|
318
|
+
return (
|
|
319
|
+
typeof value === 'string' &&
|
|
320
|
+
Object.hasOwn(FILTER_OPERATOR_DETAILS[type], value)
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Metadata for one operator. Throws if the operator is not in the table. */
|
|
325
|
+
export function operatorDetails<TType extends ColumnDataType>(
|
|
326
|
+
type: TType,
|
|
327
|
+
operator: FilterOperators[TType],
|
|
328
|
+
): FilterOperatorDetailsShape<TType> {
|
|
329
|
+
const table: Record<string, FilterOperatorDetailsShape<TType>> =
|
|
330
|
+
FILTER_OPERATOR_DETAILS[type]
|
|
331
|
+
const details = table[operator]
|
|
332
|
+
if (!details) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`[filter-core] unknown operator "${String(operator)}" for column type "${type}"`,
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
return details
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The widened shape of one operator's metadata. `FilterOperatorDetails` is
|
|
342
|
+
* keyed by the specific operator literal; this is the read-side view where
|
|
343
|
+
* the literal is not known statically.
|
|
344
|
+
*/
|
|
345
|
+
export type FilterOperatorDetailsShape<TType extends ColumnDataType> = {
|
|
346
|
+
readonly value: FilterOperators[TType]
|
|
347
|
+
readonly target: FilterOperatorTarget
|
|
348
|
+
readonly arity: FilterOperatorArity
|
|
349
|
+
readonly singularOf?: FilterOperators[TType]
|
|
350
|
+
readonly pluralOf?: FilterOperators[TType]
|
|
351
|
+
readonly isNegated: boolean
|
|
352
|
+
readonly negation?: FilterOperators[TType]
|
|
353
|
+
readonly negationOf?: FilterOperators[TType]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Operators a user could swap to without changing how many values they have
|
|
358
|
+
* selected — i.e. every operator sharing the current one's target.
|
|
359
|
+
*
|
|
360
|
+
* Replaces the source's hand-written `relativeOf`, which had to be edited in
|
|
361
|
+
* five places for every new operator and was already inconsistent.
|
|
362
|
+
*/
|
|
363
|
+
export function relatedOperators<TType extends ColumnDataType>(
|
|
364
|
+
type: TType,
|
|
365
|
+
operator: FilterOperators[TType],
|
|
366
|
+
): Array<FilterOperators[TType]> {
|
|
367
|
+
const { target } = operatorDetails(type, operator)
|
|
368
|
+
return operatorsForType(type).filter(
|
|
369
|
+
(candidate) =>
|
|
370
|
+
candidate !== operator && operatorDetails(type, candidate).target === target,
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The negated twin of an operator, or the operator itself if it has none. */
|
|
375
|
+
export function negateOperator<TType extends ColumnDataType>(
|
|
376
|
+
type: TType,
|
|
377
|
+
operator: FilterOperators[TType],
|
|
378
|
+
): FilterOperators[TType] {
|
|
379
|
+
const details = operatorDetails(type, operator)
|
|
380
|
+
return details.negation ?? details.negationOf ?? operator
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** How many values an operator requires: `[min, max]`, `max` may be Infinity. */
|
|
384
|
+
export function operatorValueArity<TType extends ColumnDataType>(
|
|
385
|
+
type: TType,
|
|
386
|
+
operator: FilterOperators[TType],
|
|
387
|
+
): readonly [number, number] {
|
|
388
|
+
switch (operatorDetails(type, operator).arity) {
|
|
389
|
+
case 'one':
|
|
390
|
+
return [1, 1]
|
|
391
|
+
case 'two':
|
|
392
|
+
return [2, 2]
|
|
393
|
+
case 'many':
|
|
394
|
+
return [1, Number.POSITIVE_INFINITY]
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ------------------------------------------------------------------ */
|
|
399
|
+
/* Promotion / demotion */
|
|
400
|
+
/* ------------------------------------------------------------------ */
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Pick the operator that fits a new value count.
|
|
404
|
+
*
|
|
405
|
+
* `is` promotes to `is any of` the moment a second value is picked, and
|
|
406
|
+
* demotes back when the list falls to one. An operator with no singular or
|
|
407
|
+
* plural twin (text's `contains`, or an already-correct arity) is left alone.
|
|
408
|
+
*
|
|
409
|
+
* Simplified from the source, which special-cased `values[0]` being an array
|
|
410
|
+
* to cope with its own inconsistent multiOption shape. Ours is flat, so the
|
|
411
|
+
* rule is just a length comparison.
|
|
412
|
+
*/
|
|
413
|
+
export function determineNewOperator<TType extends ColumnDataType>(
|
|
414
|
+
type: TType,
|
|
415
|
+
oldValues: readonly unknown[],
|
|
416
|
+
nextValues: readonly unknown[],
|
|
417
|
+
currentOperator: FilterOperators[TType],
|
|
418
|
+
): FilterOperators[TType] {
|
|
419
|
+
const before = oldValues.length
|
|
420
|
+
const after = nextValues.length
|
|
421
|
+
|
|
422
|
+
// No transition across the single/multiple boundary: nothing to do.
|
|
423
|
+
if (before === after) return currentOperator
|
|
424
|
+
if (before >= 2 && after >= 2) return currentOperator
|
|
425
|
+
if (before <= 1 && after <= 1) return currentOperator
|
|
426
|
+
|
|
427
|
+
const details = operatorDetails(type, currentOperator)
|
|
428
|
+
|
|
429
|
+
// Grew past one value: promote to the plural form.
|
|
430
|
+
if (after >= 2) return details.singularOf ?? currentOperator
|
|
431
|
+
// Fell back to one value or none: demote to the singular form.
|
|
432
|
+
return details.pluralOf ?? currentOperator
|
|
433
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @package @lovett/ui
|
|
3
|
+
* @file filter-core/state.ts
|
|
4
|
+
*
|
|
5
|
+
* Immutable transitions on a `FiltersState`.
|
|
6
|
+
*
|
|
7
|
+
* These exist so the operator-promotion rule has exactly ONE caller. Without
|
|
8
|
+
* them every consumer that writes a filter — the filter popover, a facet row
|
|
9
|
+
* click, a stats row click — re-derives "when do I switch `is` to `is any
|
|
10
|
+
* of`", and they drift. Setting a value list to empty REMOVES the filter, so
|
|
11
|
+
* a chip cleared to nothing does not linger as a no-op in the URL.
|
|
12
|
+
*
|
|
13
|
+
* Every function returns a new array; nothing is mutated.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { DEFAULT_OPERATORS, determineNewOperator } from './operators'
|
|
17
|
+
import type {
|
|
18
|
+
ColumnConfig,
|
|
19
|
+
FilterModel,
|
|
20
|
+
FiltersState,
|
|
21
|
+
FilterOperators,
|
|
22
|
+
} from './types'
|
|
23
|
+
|
|
24
|
+
/** The filter currently applied to a column, if any. */
|
|
25
|
+
export function getFilter(
|
|
26
|
+
state: FiltersState,
|
|
27
|
+
columnId: string,
|
|
28
|
+
): FilterModel | undefined {
|
|
29
|
+
return state.find((filter) => filter.columnId === columnId)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Drop one column's filter. */
|
|
33
|
+
export function removeFilter(
|
|
34
|
+
state: FiltersState,
|
|
35
|
+
columnId: string,
|
|
36
|
+
): FiltersState {
|
|
37
|
+
return state.filter((filter) => filter.columnId !== columnId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Drop every filter. */
|
|
41
|
+
export function clearFilters(): FiltersState {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Replace or append one column's filter, preserving order. */
|
|
46
|
+
export function upsertFilter(
|
|
47
|
+
state: FiltersState,
|
|
48
|
+
next: FilterModel,
|
|
49
|
+
): FiltersState {
|
|
50
|
+
const index = state.findIndex((filter) => filter.columnId === next.columnId)
|
|
51
|
+
if (index === -1) return [...state, next]
|
|
52
|
+
const copy = [...state]
|
|
53
|
+
copy[index] = next
|
|
54
|
+
return copy
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/* Per-type setters */
|
|
59
|
+
/* ------------------------------------------------------------------ */
|
|
60
|
+
|
|
61
|
+
export function setTextFilter<TData>(
|
|
62
|
+
state: FiltersState,
|
|
63
|
+
column: ColumnConfig<TData, 'text'>,
|
|
64
|
+
query: string,
|
|
65
|
+
): FiltersState {
|
|
66
|
+
if (query.trim() === '') return removeFilter(state, column.id)
|
|
67
|
+
const existing = getFilter(state, column.id)
|
|
68
|
+
const operator: FilterOperators['text'] =
|
|
69
|
+
existing?.type === 'text' ? existing.operator : DEFAULT_OPERATORS.text.single
|
|
70
|
+
return upsertFilter(state, {
|
|
71
|
+
columnId: column.id,
|
|
72
|
+
type: 'text',
|
|
73
|
+
operator,
|
|
74
|
+
values: [query],
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function setNumberFilter<TData>(
|
|
79
|
+
state: FiltersState,
|
|
80
|
+
column: ColumnConfig<TData, 'number'>,
|
|
81
|
+
values: readonly number[],
|
|
82
|
+
): FiltersState {
|
|
83
|
+
if (values.length === 0) return removeFilter(state, column.id)
|
|
84
|
+
const existing = getFilter(state, column.id)
|
|
85
|
+
const operator: FilterOperators['number'] =
|
|
86
|
+
existing?.type === 'number'
|
|
87
|
+
? determineNewOperator(
|
|
88
|
+
'number',
|
|
89
|
+
existing.values,
|
|
90
|
+
values,
|
|
91
|
+
existing.operator,
|
|
92
|
+
)
|
|
93
|
+
: values.length > 1
|
|
94
|
+
? DEFAULT_OPERATORS.number.multiple
|
|
95
|
+
: DEFAULT_OPERATORS.number.single
|
|
96
|
+
return upsertFilter(state, {
|
|
97
|
+
columnId: column.id,
|
|
98
|
+
type: 'number',
|
|
99
|
+
operator,
|
|
100
|
+
values: [...values],
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function setDateFilter<TData>(
|
|
105
|
+
state: FiltersState,
|
|
106
|
+
column: ColumnConfig<TData, 'date'>,
|
|
107
|
+
values: readonly Date[],
|
|
108
|
+
): FiltersState {
|
|
109
|
+
if (values.length === 0) return removeFilter(state, column.id)
|
|
110
|
+
const existing = getFilter(state, column.id)
|
|
111
|
+
const operator: FilterOperators['date'] =
|
|
112
|
+
existing?.type === 'date'
|
|
113
|
+
? determineNewOperator('date', existing.values, values, existing.operator)
|
|
114
|
+
: values.length > 1
|
|
115
|
+
? DEFAULT_OPERATORS.date.multiple
|
|
116
|
+
: DEFAULT_OPERATORS.date.single
|
|
117
|
+
return upsertFilter(state, {
|
|
118
|
+
columnId: column.id,
|
|
119
|
+
type: 'date',
|
|
120
|
+
operator,
|
|
121
|
+
values: [...values],
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function setOptionFilter<TData>(
|
|
126
|
+
state: FiltersState,
|
|
127
|
+
column: ColumnConfig<TData, 'option'>,
|
|
128
|
+
values: readonly string[],
|
|
129
|
+
): FiltersState {
|
|
130
|
+
if (values.length === 0) return removeFilter(state, column.id)
|
|
131
|
+
const existing = getFilter(state, column.id)
|
|
132
|
+
const operator: FilterOperators['option'] =
|
|
133
|
+
existing?.type === 'option'
|
|
134
|
+
? determineNewOperator(
|
|
135
|
+
'option',
|
|
136
|
+
existing.values,
|
|
137
|
+
values,
|
|
138
|
+
existing.operator,
|
|
139
|
+
)
|
|
140
|
+
: values.length > 1
|
|
141
|
+
? DEFAULT_OPERATORS.option.multiple
|
|
142
|
+
: DEFAULT_OPERATORS.option.single
|
|
143
|
+
return upsertFilter(state, {
|
|
144
|
+
columnId: column.id,
|
|
145
|
+
type: 'option',
|
|
146
|
+
operator,
|
|
147
|
+
values: [...values],
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function setMultiOptionFilter<TData>(
|
|
152
|
+
state: FiltersState,
|
|
153
|
+
column: ColumnConfig<TData, 'multiOption'>,
|
|
154
|
+
values: readonly string[],
|
|
155
|
+
): FiltersState {
|
|
156
|
+
if (values.length === 0) return removeFilter(state, column.id)
|
|
157
|
+
const existing = getFilter(state, column.id)
|
|
158
|
+
const operator: FilterOperators['multiOption'] =
|
|
159
|
+
existing?.type === 'multiOption'
|
|
160
|
+
? determineNewOperator(
|
|
161
|
+
'multiOption',
|
|
162
|
+
existing.values,
|
|
163
|
+
values,
|
|
164
|
+
existing.operator,
|
|
165
|
+
)
|
|
166
|
+
: values.length > 1
|
|
167
|
+
? DEFAULT_OPERATORS.multiOption.multiple
|
|
168
|
+
: DEFAULT_OPERATORS.multiOption.single
|
|
169
|
+
return upsertFilter(state, {
|
|
170
|
+
columnId: column.id,
|
|
171
|
+
type: 'multiOption',
|
|
172
|
+
operator,
|
|
173
|
+
values: [...values],
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ------------------------------------------------------------------ */
|
|
178
|
+
/* Toggles — the facet-row / stats-row click path */
|
|
179
|
+
/* ------------------------------------------------------------------ */
|
|
180
|
+
|
|
181
|
+
function toggled(values: readonly string[], value: string): string[] {
|
|
182
|
+
return values.includes(value)
|
|
183
|
+
? values.filter((candidate) => candidate !== value)
|
|
184
|
+
: [...values, value]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Add or remove one option value, promoting/demoting the operator with it. */
|
|
188
|
+
export function toggleOptionValue<TData>(
|
|
189
|
+
state: FiltersState,
|
|
190
|
+
column: ColumnConfig<TData, 'option'>,
|
|
191
|
+
value: string,
|
|
192
|
+
): FiltersState {
|
|
193
|
+
const existing = getFilter(state, column.id)
|
|
194
|
+
const current = existing?.type === 'option' ? existing.values : []
|
|
195
|
+
return setOptionFilter(state, column, toggled(current, value))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Add or remove one multi-option value. */
|
|
199
|
+
export function toggleMultiOptionValue<TData>(
|
|
200
|
+
state: FiltersState,
|
|
201
|
+
column: ColumnConfig<TData, 'multiOption'>,
|
|
202
|
+
value: string,
|
|
203
|
+
): FiltersState {
|
|
204
|
+
const existing = getFilter(state, column.id)
|
|
205
|
+
const current = existing?.type === 'multiOption' ? existing.values : []
|
|
206
|
+
return setMultiOptionFilter(state, column, toggled(current, value))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Swap a filter to its logical complement, leaving its values in place. */
|
|
210
|
+
export function setFilterOperator(
|
|
211
|
+
state: FiltersState,
|
|
212
|
+
columnId: string,
|
|
213
|
+
operator: FilterModel['operator'],
|
|
214
|
+
): FiltersState {
|
|
215
|
+
const existing = getFilter(state, columnId)
|
|
216
|
+
if (!existing) return state
|
|
217
|
+
|
|
218
|
+
switch (existing.type) {
|
|
219
|
+
case 'text':
|
|
220
|
+
return operator === 'contains' || operator === 'does not contain'
|
|
221
|
+
? upsertFilter(state, { ...existing, operator })
|
|
222
|
+
: state
|
|
223
|
+
case 'number':
|
|
224
|
+
return isMemberOf(NUMBER_OPERATORS, operator)
|
|
225
|
+
? upsertFilter(state, { ...existing, operator })
|
|
226
|
+
: state
|
|
227
|
+
case 'date':
|
|
228
|
+
return isMemberOf(DATE_OPERATORS, operator)
|
|
229
|
+
? upsertFilter(state, { ...existing, operator })
|
|
230
|
+
: state
|
|
231
|
+
case 'option':
|
|
232
|
+
return isMemberOf(OPTION_OPERATORS, operator)
|
|
233
|
+
? upsertFilter(state, { ...existing, operator })
|
|
234
|
+
: state
|
|
235
|
+
case 'multiOption':
|
|
236
|
+
return isMemberOf(MULTI_OPTION_OPERATORS, operator)
|
|
237
|
+
? upsertFilter(state, { ...existing, operator })
|
|
238
|
+
: state
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const NUMBER_OPERATORS = [
|
|
243
|
+
'is',
|
|
244
|
+
'is not',
|
|
245
|
+
'is greater than',
|
|
246
|
+
'is greater than or equal to',
|
|
247
|
+
'is less than',
|
|
248
|
+
'is less than or equal to',
|
|
249
|
+
'is between',
|
|
250
|
+
'is not between',
|
|
251
|
+
] as const
|
|
252
|
+
|
|
253
|
+
const DATE_OPERATORS = [
|
|
254
|
+
'is',
|
|
255
|
+
'is not',
|
|
256
|
+
'is before',
|
|
257
|
+
'is on or after',
|
|
258
|
+
'is after',
|
|
259
|
+
'is on or before',
|
|
260
|
+
'is between',
|
|
261
|
+
'is not between',
|
|
262
|
+
] as const
|
|
263
|
+
|
|
264
|
+
const OPTION_OPERATORS = ['is', 'is not', 'is any of', 'is none of'] as const
|
|
265
|
+
|
|
266
|
+
const MULTI_OPTION_OPERATORS = [
|
|
267
|
+
'include',
|
|
268
|
+
'exclude',
|
|
269
|
+
'include any of',
|
|
270
|
+
'include all of',
|
|
271
|
+
'exclude if any of',
|
|
272
|
+
'exclude if all',
|
|
273
|
+
] as const
|
|
274
|
+
|
|
275
|
+
function isMemberOf<T extends string>(
|
|
276
|
+
allowed: readonly T[],
|
|
277
|
+
value: string,
|
|
278
|
+
): value is T {
|
|
279
|
+
return (allowed as readonly string[]).includes(value)
|
|
280
|
+
}
|