@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,120 @@
1
+ /**
2
+ * @package @lovett/ui
3
+ * @file filter-core/faceting.ts
4
+ *
5
+ * Faceted counts, as PURE FUNCTIONS of the rows you pass in.
6
+ *
7
+ * THIS IS BUG FIX #3. The source computed facet counts inside a `useMemo`
8
+ * with an EMPTY dependency array (filter-value.tsx:481-489), so the numbers
9
+ * froze at popover-mount: adding a filter changed the board and left the
10
+ * facet counts showing the previous world. There is no memo, no lazy getter
11
+ * and no cache class here — counts are derived from the rows given, so they
12
+ * cannot go stale. Callers memoize with the row array as the dependency.
13
+ */
14
+
15
+ import { filterRows } from './filter-fns'
16
+ import { isColumnOfType } from './columns'
17
+ import type { AnyColumnConfig, FiltersState } from './types'
18
+
19
+ /**
20
+ * Count rows per option value for an `option` / `multiOption` column.
21
+ *
22
+ * Every value declared in `column.options` is present in the result, INCLUDING
23
+ * the ones no row has — a facet list that quietly omits its empty buckets
24
+ * reads as "this option does not exist" rather than "nothing matches". Values
25
+ * found in the data but not declared are appended after the declared ones.
26
+ *
27
+ * Throws for a column type that has no facets; asking a text column for its
28
+ * value counts is a programming error, not a runtime condition.
29
+ */
30
+ export function facetedCounts<TData>(
31
+ rows: readonly TData[],
32
+ column: AnyColumnConfig<TData>,
33
+ ): Map<string, number> {
34
+ const counts = new Map<string, number>()
35
+
36
+ if (isColumnOfType(column, 'option')) {
37
+ for (const option of column.options ?? []) counts.set(option.value, 0)
38
+ for (const row of rows) {
39
+ const value = column.accessor(row)
40
+ if (typeof value !== 'string') continue
41
+ counts.set(value, (counts.get(value) ?? 0) + 1)
42
+ }
43
+ return counts
44
+ }
45
+
46
+ if (isColumnOfType(column, 'multiOption')) {
47
+ for (const option of column.options ?? []) counts.set(option.value, 0)
48
+ for (const row of rows) {
49
+ const values = column.accessor(row)
50
+ if (!values) continue
51
+ // A row counts once per DISTINCT value it carries, so a duplicated
52
+ // label on one task cannot inflate the facet past the row count.
53
+ for (const value of new Set(values)) {
54
+ if (typeof value !== 'string') continue
55
+ counts.set(value, (counts.get(value) ?? 0) + 1)
56
+ }
57
+ }
58
+ return counts
59
+ }
60
+
61
+ throw new Error(
62
+ `[filter-core] facetedCounts is only defined for option and multiOption ` +
63
+ `columns; "${column.id}" is "${column.type}"`,
64
+ )
65
+ }
66
+
67
+ /**
68
+ * The rows a facet list for `columnId` should count: everything surviving
69
+ * every OTHER column's filters.
70
+ *
71
+ * Counting against the fully filtered set would zero every unselected option
72
+ * the moment one is picked, which makes the list unusable for widening a
73
+ * selection. Excluding the column's own filter is the standard behaviour and
74
+ * it stays a pure function of `(rows, columns, filters)`.
75
+ */
76
+ export function facetedRowsFor<TData>(
77
+ rows: readonly TData[],
78
+ columns: ReadonlyArray<AnyColumnConfig<TData>>,
79
+ filters: FiltersState,
80
+ columnId: string,
81
+ ): TData[] {
82
+ return filterRows(
83
+ rows,
84
+ columns,
85
+ filters.filter((filter) => filter.columnId !== columnId),
86
+ )
87
+ }
88
+
89
+ /**
90
+ * The observed `[min, max]` of a number column, or its declared bounds when
91
+ * both are configured. `null` when neither is available.
92
+ */
93
+ export function facetedMinMax<TData>(
94
+ rows: readonly TData[],
95
+ column: AnyColumnConfig<TData>,
96
+ ): readonly [number, number] | null {
97
+ if (!isColumnOfType(column, 'number')) {
98
+ throw new Error(
99
+ `[filter-core] facetedMinMax is only defined for number columns; ` +
100
+ `"${column.id}" is "${column.type}"`,
101
+ )
102
+ }
103
+
104
+ const { min, max } = column
105
+ if (typeof min === 'number' && typeof max === 'number') return [min, max]
106
+
107
+ let low = Number.POSITIVE_INFINITY
108
+ let high = Number.NEGATIVE_INFINITY
109
+ let seen = false
110
+
111
+ for (const row of rows) {
112
+ const value = column.accessor(row)
113
+ if (typeof value !== 'number' || Number.isNaN(value)) continue
114
+ seen = true
115
+ if (value < low) low = value
116
+ if (value > high) high = value
117
+ }
118
+
119
+ return seen ? [low, high] : null
120
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * @package @lovett/ui
3
+ * @file filter-core/filter-fns.ts
4
+ *
5
+ * The predicates — one per column type — and the combinator over them.
6
+ *
7
+ * Semantics:
8
+ * - ACROSS columns: AND. Every filter in the state must pass.
9
+ * - WITHIN a column: the operator decides. `is any of` is an OR over the
10
+ * value list; `include all of` is an AND over it.
11
+ * - An empty value list is a no-op filter (passes everything), so a
12
+ * half-built filter chip never blanks the board.
13
+ *
14
+ * Missing values. Each predicate takes `Nullable<T>` because an accessor is
15
+ * allowed to return nothing, and the answer is not the same for every type:
16
+ * - text — a missing value behaves as the empty string, so `does not
17
+ * contain "x"` is TRUE for a row with no title. The source
18
+ * returned false for both halves of the pair, which made the two
19
+ * operators non-complementary.
20
+ * - option / multiOption — a missing value matches no positive operator and
21
+ * every negated one, for the same reason.
22
+ * - number / date — a missing value matches NOTHING, including the negated
23
+ * operators. "Due before Friday" over a task with no due date is
24
+ * not a question with a true answer; excluding it is the
25
+ * behaviour a board wants. Config-level sentinels (see the
26
+ * `assignee` example) are the way to make absence filterable.
27
+ */
28
+
29
+ import { isColumnOfType } from './columns'
30
+ import { endOfDay, isSameDay, startOfDay } from './date-utils'
31
+ import { operatorDetails } from './operators'
32
+ import type {
33
+ AnyColumnConfig,
34
+ FilterModel,
35
+ FiltersState,
36
+ Nullable,
37
+ } from './types'
38
+
39
+ /* ------------------------------------------------------------------ */
40
+ /* Arity guards */
41
+ /* ------------------------------------------------------------------ */
42
+
43
+ /**
44
+ * Assert a filter carries the number of values its operator requires.
45
+ *
46
+ * THIS IS BUG FIX #1. The source wrote the plural half as
47
+ *
48
+ * filterValue.operator in ['is between', 'is not between']
49
+ *
50
+ * (filter-fns.ts:80). `in` on an ARRAY tests index keys — '0', '1', 'length'
51
+ * — so the condition is false for every operator and the guard never fired.
52
+ * A one-value `is between` sailed past it into an interval with an undefined
53
+ * end. Here the requirement is read off the operator table's `arity`, which
54
+ * cannot be written that way.
55
+ */
56
+ function assertArity(filter: FilterModel): void {
57
+ const details = operatorDetails(filter.type, filter.operator)
58
+ const count = filter.values.length
59
+
60
+ if (details.arity === 'one' && count > 1) {
61
+ throw new Error(
62
+ `[filter-core] operator "${filter.operator}" on column ` +
63
+ `"${filter.columnId}" takes at most one value, got ${count}`,
64
+ )
65
+ }
66
+ if (details.arity === 'two' && count !== 2) {
67
+ throw new Error(
68
+ `[filter-core] operator "${filter.operator}" on column ` +
69
+ `"${filter.columnId}" requires exactly two values, got ${count}`,
70
+ )
71
+ }
72
+ }
73
+
74
+ /* ------------------------------------------------------------------ */
75
+ /* Predicates */
76
+ /* ------------------------------------------------------------------ */
77
+
78
+ export function textFilterFn(
79
+ input: Nullable<string>,
80
+ filter: FilterModel<'text'>,
81
+ ): boolean {
82
+ assertArity(filter)
83
+ const needle = filter.values[0]
84
+ if (needle === undefined) return true
85
+
86
+ const trimmed = needle.toLowerCase().trim()
87
+ if (trimmed === '') return true
88
+
89
+ const found = (input ?? '').toLowerCase().trim().includes(trimmed)
90
+
91
+ switch (filter.operator) {
92
+ case 'contains':
93
+ return found
94
+ case 'does not contain':
95
+ return !found
96
+ }
97
+ }
98
+
99
+ export function numberFilterFn(
100
+ input: Nullable<number>,
101
+ filter: FilterModel<'number'>,
102
+ ): boolean {
103
+ assertArity(filter)
104
+ const first = filter.values[0]
105
+ if (first === undefined) return true
106
+ if (typeof input !== 'number' || Number.isNaN(input)) return false
107
+
108
+ switch (filter.operator) {
109
+ case 'is':
110
+ return input === first
111
+ case 'is not':
112
+ return input !== first
113
+ case 'is greater than':
114
+ return input > first
115
+ case 'is greater than or equal to':
116
+ return input >= first
117
+ case 'is less than':
118
+ return input < first
119
+ case 'is less than or equal to':
120
+ return input <= first
121
+ case 'is between':
122
+ case 'is not between': {
123
+ const second = filter.values[1]
124
+ // assertArity has already guaranteed two values.
125
+ if (second === undefined) return true
126
+ const lower = Math.min(first, second)
127
+ const upper = Math.max(first, second)
128
+ const within = input >= lower && input <= upper
129
+ return filter.operator === 'is between' ? within : !within
130
+ }
131
+ }
132
+ }
133
+
134
+ export function dateFilterFn(
135
+ input: Nullable<Date>,
136
+ filter: FilterModel<'date'>,
137
+ ): boolean {
138
+ assertArity(filter)
139
+ const first = filter.values[0]
140
+ if (first === undefined) return true
141
+ if (!(input instanceof Date) || Number.isNaN(input.getTime())) return false
142
+
143
+ switch (filter.operator) {
144
+ case 'is':
145
+ return isSameDay(input, first)
146
+ case 'is not':
147
+ return !isSameDay(input, first)
148
+ case 'is before':
149
+ return input.getTime() < startOfDay(first).getTime()
150
+ case 'is on or after':
151
+ return input.getTime() >= startOfDay(first).getTime()
152
+ case 'is after':
153
+ return input.getTime() > endOfDay(first).getTime()
154
+ case 'is on or before':
155
+ return input.getTime() <= endOfDay(first).getTime()
156
+ case 'is between':
157
+ case 'is not between': {
158
+ const second = filter.values[1]
159
+ // assertArity has already guaranteed two values.
160
+ if (second === undefined) return true
161
+ const [lower, upper] =
162
+ first.getTime() <= second.getTime() ? [first, second] : [second, first]
163
+ const within =
164
+ input.getTime() >= startOfDay(lower).getTime() &&
165
+ input.getTime() <= endOfDay(upper).getTime()
166
+ return filter.operator === 'is between' ? within : !within
167
+ }
168
+ }
169
+ }
170
+
171
+ export function optionFilterFn(
172
+ input: Nullable<string>,
173
+ filter: FilterModel<'option'>,
174
+ ): boolean {
175
+ assertArity(filter)
176
+ if (filter.values.length === 0) return true
177
+
178
+ // Exact match, not case-folded. Option values are ids; lower-casing them
179
+ // (as the source did) silently collides two options that differ by case.
180
+ const found = input != null && filter.values.includes(input)
181
+
182
+ switch (filter.operator) {
183
+ case 'is':
184
+ case 'is any of':
185
+ return found
186
+ case 'is not':
187
+ case 'is none of':
188
+ return !found
189
+ }
190
+ }
191
+
192
+ export function multiOptionFilterFn(
193
+ input: Nullable<readonly string[]>,
194
+ filter: FilterModel<'multiOption'>,
195
+ ): boolean {
196
+ assertArity(filter)
197
+ if (filter.values.length === 0) return true
198
+
199
+ const present = new Set(input ?? [])
200
+ const selected = filter.values
201
+ const anyPresent = selected.some((value) => present.has(value))
202
+ const allPresent = selected.every((value) => present.has(value))
203
+
204
+ switch (filter.operator) {
205
+ case 'include':
206
+ case 'include any of':
207
+ return anyPresent
208
+ case 'include all of':
209
+ return allPresent
210
+ case 'exclude':
211
+ case 'exclude if any of':
212
+ return !anyPresent
213
+ case 'exclude if all':
214
+ return !allPresent
215
+ }
216
+ }
217
+
218
+ /* ------------------------------------------------------------------ */
219
+ /* Combinator */
220
+ /* ------------------------------------------------------------------ */
221
+
222
+ /** Unreachable-branch guard. A new ColumnDataType fails to compile here. */
223
+ function assertNever(value: never, message: string): never {
224
+ throw new Error(`${message}: ${JSON.stringify(value)}`)
225
+ }
226
+
227
+ /**
228
+ * Test one row against one filter, through one column.
229
+ *
230
+ * THIS IS BUG FIX #2. Every declared type has a case; there is no fallthrough
231
+ * `return true`. A filter whose type disagrees with its column's THROWS —
232
+ * a state that only arises from a programming error, since `pruneFilters`
233
+ * drops such a filter when it comes off the URL.
234
+ */
235
+ export function rowMatchesFilter<TData>(
236
+ row: TData,
237
+ column: AnyColumnConfig<TData>,
238
+ filter: FilterModel,
239
+ ): boolean {
240
+ const mismatch = (): never => {
241
+ throw new Error(
242
+ `[filter-core] filter on column "${filter.columnId}" is typed ` +
243
+ `"${filter.type}" but the column is "${column.type}"`,
244
+ )
245
+ }
246
+
247
+ switch (filter.type) {
248
+ case 'text':
249
+ return isColumnOfType(column, 'text')
250
+ ? textFilterFn(column.accessor(row), filter)
251
+ : mismatch()
252
+ case 'number':
253
+ return isColumnOfType(column, 'number')
254
+ ? numberFilterFn(column.accessor(row), filter)
255
+ : mismatch()
256
+ case 'date':
257
+ return isColumnOfType(column, 'date')
258
+ ? dateFilterFn(column.accessor(row), filter)
259
+ : mismatch()
260
+ case 'option':
261
+ return isColumnOfType(column, 'option')
262
+ ? optionFilterFn(column.accessor(row), filter)
263
+ : mismatch()
264
+ case 'multiOption':
265
+ return isColumnOfType(column, 'multiOption')
266
+ ? multiOptionFilterFn(column.accessor(row), filter)
267
+ : mismatch()
268
+ default:
269
+ return assertNever(filter, '[filter-core] unhandled filter type')
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Drop filters that cannot be applied to this column set: an unknown column
275
+ * id (a stale link after a column was renamed) or a type that disagrees with
276
+ * the column's declared type (a hand-edited URL). Also collapses duplicate
277
+ * entries for one column, keeping the first.
278
+ *
279
+ * Dropping is right here and throwing is right in `rowMatchesFilter`, because
280
+ * this is where UNTRUSTED input meets the config. CLAUDE.md: parse at the
281
+ * boundary, and let broken invariants throw further in.
282
+ */
283
+ export function pruneFilters<TData>(
284
+ filters: FiltersState,
285
+ columns: ReadonlyArray<AnyColumnConfig<TData>>,
286
+ ): FiltersState {
287
+ const byId = new Map(columns.map((column) => [column.id, column]))
288
+ const kept: FiltersState = []
289
+ const seen = new Set<string>()
290
+
291
+ for (const filter of filters) {
292
+ const column = byId.get(filter.columnId)
293
+ if (!column) continue
294
+ if (column.type !== filter.type) continue
295
+ if (seen.has(filter.columnId)) continue
296
+ seen.add(filter.columnId)
297
+ kept.push(filter)
298
+ }
299
+
300
+ return kept
301
+ }
302
+
303
+ /** Whether a row survives every filter in the state. AND across columns. */
304
+ export function rowMatchesFilters<TData>(
305
+ row: TData,
306
+ columns: ReadonlyMap<string, AnyColumnConfig<TData>>,
307
+ filters: FiltersState,
308
+ ): boolean {
309
+ for (const filter of filters) {
310
+ const column = columns.get(filter.columnId)
311
+ // An unknown column id is not a filter we can honour. Ignore it rather
312
+ // than blanking the board; pruneFilters is how a caller sees it happen.
313
+ if (!column) continue
314
+ if (!rowMatchesFilter(row, column, filter)) return false
315
+ }
316
+ return true
317
+ }
318
+
319
+ /**
320
+ * Apply a filter state to a row set. Pure — same inputs, same output, no
321
+ * caching. The caller memoizes.
322
+ */
323
+ export function filterRows<TData>(
324
+ rows: readonly TData[],
325
+ columns: ReadonlyArray<AnyColumnConfig<TData>>,
326
+ filters: FiltersState,
327
+ ): TData[] {
328
+ if (filters.length === 0) return [...rows]
329
+
330
+ const byId = new Map(columns.map((column) => [column.id, column]))
331
+ const applicable = pruneFilters(filters, columns)
332
+ if (applicable.length === 0) return [...rows]
333
+
334
+ return rows.filter((row) => rowMatchesFilters(row, byId, applicable))
335
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @package @lovett/ui
3
+ * @file filter-core/index.ts
4
+ *
5
+ * `filter-core` — the pure-TypeScript filter model behind the board header.
6
+ *
7
+ * Zero React, zero CSS, zero domain knowledge, zero runtime dependencies.
8
+ * The boundary is `ColumnConfig[]` in, `FiltersState` out; the UI layer sits
9
+ * on top of it and the URL is its persistence.
10
+ *
11
+ * Ported from bazza/ui's `data-table-filter` (vendored in continuity-control)
12
+ * with three of its bugs fixed on the way in — see the `THIS IS BUG FIX`
13
+ * comments in filter-fns.ts (arity guard, exhaustive combinator) and
14
+ * faceting.ts (counts as a pure function).
15
+ *
16
+ * The example config lives at ./examples/task-filter-columns and is NOT
17
+ * exported from here: it is illustration, not API.
18
+ */
19
+
20
+ export {
21
+ COLUMN_DATA_TYPES,
22
+ } from './types'
23
+
24
+ export type {
25
+ AnyColumnConfig,
26
+ ColumnAccessor,
27
+ ColumnConfig,
28
+ ColumnDataNativeMap,
29
+ ColumnDataType,
30
+ ColumnOption,
31
+ ColumnValueMap,
32
+ DateFilterOperator,
33
+ ElementType,
34
+ FilterDetails,
35
+ FilterModel,
36
+ FilterOperatorArity,
37
+ FilterOperatorDetails,
38
+ FilterOperators,
39
+ FilterOperatorTarget,
40
+ FiltersState,
41
+ FilterTypeOperatorDetails,
42
+ FilterValues,
43
+ MultiOptionFilterOperator,
44
+ Nullable,
45
+ NumberFilterOperator,
46
+ OptionBasedColumnDataType,
47
+ OptionFilterOperator,
48
+ TextFilterOperator,
49
+ } from './types'
50
+
51
+ export {
52
+ DEFAULT_OPERATORS,
53
+ FILTER_OPERATOR_DETAILS,
54
+ dateFilterOperators,
55
+ determineNewOperator,
56
+ isOperatorOf,
57
+ multiOptionFilterOperators,
58
+ negateOperator,
59
+ numberFilterOperators,
60
+ operatorDetails,
61
+ operatorValueArity,
62
+ operatorsForType,
63
+ optionFilterOperators,
64
+ relatedOperators,
65
+ textFilterOperators,
66
+ } from './operators'
67
+
68
+ export type { FilterOperatorDetailsShape } from './operators'
69
+
70
+ export { defineColumns, indexColumns, isColumnOfType } from './columns'
71
+
72
+ export {
73
+ dateFilterFn,
74
+ filterRows,
75
+ multiOptionFilterFn,
76
+ numberFilterFn,
77
+ optionFilterFn,
78
+ pruneFilters,
79
+ rowMatchesFilter,
80
+ rowMatchesFilters,
81
+ textFilterFn,
82
+ } from './filter-fns'
83
+
84
+ export { facetedCounts, facetedMinMax, facetedRowsFor } from './faceting'
85
+
86
+ export {
87
+ clearFilters,
88
+ getFilter,
89
+ removeFilter,
90
+ setDateFilter,
91
+ setFilterOperator,
92
+ setMultiOptionFilter,
93
+ setNumberFilter,
94
+ setOptionFilter,
95
+ setTextFilter,
96
+ toggleMultiOptionValue,
97
+ toggleOptionValue,
98
+ upsertFilter,
99
+ } from './state'
100
+
101
+ export {
102
+ FILTERS_SEARCH_PARAM,
103
+ parseFilters,
104
+ serializeFilters,
105
+ } from './url'