@lovett/ui 0.0.10 → 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.
Files changed (158) 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 +6064 -80
  4. package/dist/index.js +21561 -9696
  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 +1586 -2
  9. package/dist/theme-v2.css +228 -0
  10. package/dist/tokens.css +121 -0
  11. package/package.json +8 -1
  12. package/src/__tests__/anchor.test.tsx +422 -0
  13. package/src/__tests__/avatar.test.tsx +272 -0
  14. package/src/__tests__/bar-chart.test.tsx +809 -0
  15. package/src/__tests__/board.test.tsx +420 -0
  16. package/src/__tests__/chart-math.test.ts +922 -0
  17. package/src/__tests__/chart-series.test.ts +339 -0
  18. package/src/__tests__/code-block.test.tsx +134 -0
  19. package/src/__tests__/combobox.test.tsx +677 -0
  20. package/src/__tests__/display-popover.test.tsx +195 -0
  21. package/src/__tests__/display-store.test.tsx +208 -0
  22. package/src/__tests__/donut-chart.test.tsx +397 -0
  23. package/src/__tests__/dropdown-menu.test.tsx +572 -0
  24. package/src/__tests__/filter-menu.test.tsx +175 -0
  25. package/src/__tests__/gauge-ring.test.tsx +233 -0
  26. package/src/__tests__/helpers/geometry.ts +58 -0
  27. package/src/__tests__/layer-stack.test.tsx +228 -0
  28. package/src/__tests__/line-chart.test.tsx +612 -0
  29. package/src/__tests__/popover.test.tsx +460 -0
  30. package/src/__tests__/ranked-bars.test.tsx +343 -0
  31. package/src/__tests__/remark-underline.test.ts +194 -0
  32. package/src/__tests__/select.test.tsx +543 -0
  33. package/src/__tests__/sparkline.test.tsx +368 -0
  34. package/src/__tests__/stat-layer.test.tsx +271 -0
  35. package/src/__tests__/stat-strip.test.tsx +175 -0
  36. package/src/__tests__/status.test.tsx +293 -0
  37. package/src/__tests__/tabs.test.tsx +303 -0
  38. package/src/__tests__/token-shape.test.ts +132 -2
  39. package/src/__tests__/tooltip.test.tsx +355 -0
  40. package/src/avatar.tsx +352 -0
  41. package/src/bar-chart.tsx +1214 -0
  42. package/src/board.tsx +658 -0
  43. package/src/calculator-shell-v2.tsx +19 -39
  44. package/src/chart-frame.tsx +960 -0
  45. package/src/chart-legend.tsx +304 -0
  46. package/src/chart-tooltip.tsx +267 -0
  47. package/src/code-block.tsx +62 -8
  48. package/src/combobox.tsx +796 -0
  49. package/src/delta-chip.tsx +263 -0
  50. package/src/detail/__tests__/activity-pane.test.tsx +186 -0
  51. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  52. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  53. package/src/detail/__tests__/field-row.test.tsx +357 -0
  54. package/src/detail/activity-pane.tsx +507 -0
  55. package/src/detail/calendar.tsx +355 -0
  56. package/src/detail/detail-divider.tsx +261 -0
  57. package/src/detail/detail-header.tsx +287 -0
  58. package/src/detail/detail-menu.tsx +254 -0
  59. package/src/detail/detail-surface.tsx +1110 -0
  60. package/src/detail/field-list.tsx +196 -0
  61. package/src/detail/field-row.tsx +1131 -0
  62. package/src/detail/index.ts +58 -0
  63. package/src/detail/segmented-choice.tsx +94 -0
  64. package/src/detail/types.ts +129 -0
  65. package/src/display-popover.tsx +487 -0
  66. package/src/display-store.tsx +242 -0
  67. package/src/donut-chart.tsx +988 -0
  68. package/src/dropdown-menu.tsx +412 -151
  69. package/src/filter-core/EXPORTS.md +85 -0
  70. package/src/filter-core/__tests__/columns.test.ts +159 -0
  71. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  72. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  73. package/src/filter-core/__tests__/operators.test.ts +235 -0
  74. package/src/filter-core/__tests__/state.test.ts +268 -0
  75. package/src/filter-core/__tests__/url.test.ts +350 -0
  76. package/src/filter-core/columns.ts +134 -0
  77. package/src/filter-core/date-utils.ts +38 -0
  78. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  79. package/src/filter-core/faceting.ts +120 -0
  80. package/src/filter-core/filter-fns.ts +335 -0
  81. package/src/filter-core/index.ts +105 -0
  82. package/src/filter-core/operators.ts +433 -0
  83. package/src/filter-core/state.ts +280 -0
  84. package/src/filter-core/types.ts +247 -0
  85. package/src/filter-core/url.ts +261 -0
  86. package/src/filter-dropdown.tsx +12 -0
  87. package/src/filter-menu.tsx +649 -0
  88. package/src/floating-drawer.tsx +19 -1
  89. package/src/gauge-ring.tsx +435 -0
  90. package/src/hue.ts +52 -0
  91. package/src/index.ts +402 -0
  92. package/src/kbd.tsx +27 -4
  93. package/src/lib/anchor.ts +427 -0
  94. package/src/lib/chart.ts +866 -0
  95. package/src/lib/focus.ts +74 -0
  96. package/src/lib/layer-stack.ts +215 -0
  97. package/src/lib/refs.ts +31 -0
  98. package/src/lib/remark-underline.ts +443 -0
  99. package/src/lib/series.ts +169 -0
  100. package/src/line-chart.tsx +1176 -0
  101. package/src/markdown.tsx +26 -7
  102. package/src/metric-card.tsx +57 -22
  103. package/src/modal.tsx +73 -66
  104. package/src/popover.tsx +407 -0
  105. package/src/progress-ledger.tsx +304 -0
  106. package/src/ranked-bars.tsx +386 -0
  107. package/src/segmented-pill.tsx +32 -9
  108. package/src/select.tsx +646 -0
  109. package/src/sortable.tsx +315 -1
  110. package/src/sparkline.tsx +416 -0
  111. package/src/stat-card.tsx +376 -0
  112. package/src/stat-row.tsx +108 -70
  113. package/src/stat-strip.tsx +327 -0
  114. package/src/status.tsx +215 -0
  115. package/src/styles.css +1586 -2
  116. package/src/tabs.tsx +206 -25
  117. package/src/task-card.tsx +610 -0
  118. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  119. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  120. package/src/thread/__tests__/emoji.test.ts +187 -0
  121. package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
  122. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  123. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  124. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  125. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  126. package/src/thread/__tests__/relative-time.test.ts +71 -0
  127. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  128. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  129. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  130. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  131. package/src/thread/__tests__/thread.test.tsx +662 -0
  132. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  133. package/src/thread/actions.tsx +196 -0
  134. package/src/thread/attachments.tsx +1071 -0
  135. package/src/thread/comment-body.tsx +148 -0
  136. package/src/thread/comment-tree.ts +182 -0
  137. package/src/thread/comment.tsx +915 -0
  138. package/src/thread/composer-footer.tsx +125 -0
  139. package/src/thread/composer.tsx +319 -0
  140. package/src/thread/emoji.ts +283 -0
  141. package/src/thread/index.ts +153 -0
  142. package/src/thread/link-preview.tsx +341 -0
  143. package/src/thread/markdown-format.ts +155 -0
  144. package/src/thread/markdown-spec.ts +754 -0
  145. package/src/thread/rail.tsx +372 -0
  146. package/src/thread/reactions.tsx +415 -0
  147. package/src/thread/relative-time.tsx +94 -0
  148. package/src/thread/rich-composer-impl.tsx +1601 -0
  149. package/src/thread/rich-composer.tsx +195 -0
  150. package/src/thread/scroll-caret.ts +37 -0
  151. package/src/thread/suggestion-list.tsx +182 -0
  152. package/src/thread/thread.tsx +718 -0
  153. package/src/thread/types.ts +218 -0
  154. package/src/thread/use-attachments.ts +598 -0
  155. package/src/thread/use-now.ts +73 -0
  156. package/src/thread/use-thread.ts +316 -0
  157. package/src/tokens.css +121 -0
  158. package/src/tooltip.tsx +297 -0
@@ -0,0 +1,85 @@
1
+ # filter-core — export wiring
2
+
3
+ `packages/ui/src/filter-core/**` is self-contained and currently reachable
4
+ only by its own path. Nothing outside this directory was edited.
5
+
6
+ ## The one line to add
7
+
8
+ Append to `packages/ui/src/index.ts`:
9
+
10
+ ```ts
11
+ // Board filter model — pure TypeScript, no React. See src/filter-core/index.ts.
12
+ export * from './filter-core'
13
+ ```
14
+
15
+ `filter-core/index.ts` is an explicit barrel (no wildcard re-exports of its
16
+ own), so `export *` here surfaces exactly the list below and nothing else.
17
+ Every type is exported with `export type`, so `verbatimModuleSyntax` is
18
+ satisfied and none of it survives into the JS bundle.
19
+
20
+ ### If you would rather namespace it
21
+
22
+ The alternative, if `@lovett/ui`'s top level should stay component-only:
23
+
24
+ ```json
25
+ // packages/ui/package.json — "exports"
26
+ "./filter-core": "./src/filter-core/index.ts"
27
+ ```
28
+
29
+ …plus the matching `publishConfig.exports` entry and a second `tsup` entry
30
+ (`entry: { index: 'src/index.ts', 'filter-core': 'src/filter-core/index.ts' }`).
31
+ That is three edits instead of one; the flat `export *` is the cheaper default
32
+ and there are no name collisions (checked against the current `src/index.ts`).
33
+
34
+ ## What that line surfaces
35
+
36
+ **Values**
37
+
38
+ ```
39
+ COLUMN_DATA_TYPES
40
+ DEFAULT_OPERATORS, FILTER_OPERATOR_DETAILS
41
+ textFilterOperators, numberFilterOperators, dateFilterOperators,
42
+ optionFilterOperators, multiOptionFilterOperators
43
+ operatorsForType, operatorDetails, operatorValueArity, relatedOperators,
44
+ isOperatorOf, negateOperator, determineNewOperator
45
+ defineColumns, indexColumns, isColumnOfType
46
+ textFilterFn, numberFilterFn, dateFilterFn, optionFilterFn, multiOptionFilterFn
47
+ rowMatchesFilter, rowMatchesFilters, pruneFilters, filterRows
48
+ facetedCounts, facetedRowsFor, facetedMinMax
49
+ getFilter, upsertFilter, removeFilter, clearFilters, setFilterOperator
50
+ setTextFilter, setNumberFilter, setDateFilter, setOptionFilter,
51
+ setMultiOptionFilter, toggleOptionValue, toggleMultiOptionValue
52
+ FILTERS_SEARCH_PARAM, serializeFilters, parseFilters
53
+ ```
54
+
55
+ **Types**
56
+
57
+ ```
58
+ Nullable, ElementType
59
+ ColumnDataType, OptionBasedColumnDataType, ColumnDataNativeMap, ColumnValueMap
60
+ ColumnOption, ColumnAccessor, ColumnConfig, AnyColumnConfig
61
+ FilterValues, FilterModel, FiltersState
62
+ FilterOperators, FilterOperatorTarget, FilterOperatorArity
63
+ FilterOperatorDetails, FilterOperatorDetailsShape, FilterDetails,
64
+ FilterTypeOperatorDetails
65
+ TextFilterOperator, NumberFilterOperator, DateFilterOperator,
66
+ OptionFilterOperator, MultiOptionFilterOperator
67
+ ```
68
+
69
+ ## What it deliberately does NOT surface
70
+
71
+ `./examples/task-filter-columns` — the Tasks-board column set is illustration,
72
+ not API. The lens declares its own columns over its own row type. Import it
73
+ only from a test or while reading.
74
+
75
+ ## Notes for whoever wires this
76
+
77
+ - **No new dependency.** Nothing was added to `package.json` and nothing was
78
+ installed. `date-fns` (four helpers) is inlined in `date-utils.ts`.
79
+ - **`zod` was asked for and is NOT used**, because zod is not a dependency of
80
+ `@lovett/ui` and adding one to the design system is an ADR-level call plus a
81
+ `package.json` edit — outside this change's scope. `url.ts` hand-validates to
82
+ the same standard (every field, every bound, fresh object literals) and is
83
+ funnelled through a single `parseEntry` so a zod schema can replace it in one
84
+ place if you want it later.
85
+ - **CSS: none.** Nothing to add to `styles.css`.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Config-time validation.
3
+ *
4
+ * BUG 2, the other half: a column type with no predicate must FAIL LOUDLY
5
+ * where the columns are declared. In the source, declaring a date column was
6
+ * accepted and then silently filtered to everything.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { defineColumns, indexColumns, isColumnOfType } from '../index'
10
+ import type { AnyColumnConfig } from '../index'
11
+
12
+ interface Row {
13
+ readonly id: string
14
+ readonly name: string
15
+ }
16
+
17
+ /**
18
+ * Build a config the type system would reject, to exercise the RUNTIME gate.
19
+ * The double assertion is confined to this helper and is the only way to
20
+ * reach a validator whose whole job is catching what types cannot.
21
+ */
22
+ function illegal(config: unknown): AnyColumnConfig<Row> {
23
+ return config as AnyColumnConfig<Row>
24
+ }
25
+
26
+ const ok = { id: 'name', displayName: 'Name', accessor: (r: Row) => r.name }
27
+
28
+ describe('defineColumns', () => {
29
+ it('returns the columns it was given', () => {
30
+ const columns = defineColumns<Row>([{ ...ok, type: 'text' }])
31
+ expect(columns).toHaveLength(1)
32
+ expect(columns[0]?.id).toBe('name')
33
+ })
34
+
35
+ it('throws on an unsupported column type', () => {
36
+ expect(() =>
37
+ defineColumns<Row>([illegal({ ...ok, type: 'boolean' })]),
38
+ ).toThrow(/unsupported type "boolean"/)
39
+ })
40
+
41
+ it('throws on a missing type', () => {
42
+ expect(() => defineColumns<Row>([illegal({ ...ok })])).toThrow(
43
+ /unsupported type/,
44
+ )
45
+ })
46
+
47
+ it('throws on a blank id', () => {
48
+ expect(() =>
49
+ defineColumns<Row>([illegal({ ...ok, id: ' ', type: 'text' })]),
50
+ ).toThrow(/non-empty string id/)
51
+ })
52
+
53
+ it('throws on a reserved id', () => {
54
+ expect(() =>
55
+ defineColumns<Row>([illegal({ ...ok, id: '__proto__', type: 'text' })]),
56
+ ).toThrow(/reserved/)
57
+ })
58
+
59
+ it('throws on duplicate ids', () => {
60
+ expect(() =>
61
+ defineColumns<Row>([
62
+ { ...ok, type: 'text' },
63
+ { ...ok, type: 'text' },
64
+ ]),
65
+ ).toThrow(/duplicate column id/)
66
+ })
67
+
68
+ it('throws on a missing accessor or displayName', () => {
69
+ expect(() =>
70
+ defineColumns<Row>([illegal({ id: 'x', displayName: 'X', type: 'text' })]),
71
+ ).toThrow(/accessor function/)
72
+ expect(() =>
73
+ defineColumns<Row>([illegal({ ...ok, displayName: '', type: 'text' })]),
74
+ ).toThrow(/displayName/)
75
+ })
76
+
77
+ it('throws on a malformed option', () => {
78
+ expect(() =>
79
+ defineColumns<Row>([
80
+ illegal({ ...ok, type: 'option', options: [{ label: 'No value' }] }),
81
+ ]),
82
+ ).toThrow(/malformed option/)
83
+ })
84
+
85
+ it('throws on duplicate option values', () => {
86
+ expect(() =>
87
+ defineColumns<Row>([
88
+ {
89
+ ...ok,
90
+ type: 'option',
91
+ options: [
92
+ { value: 'a', label: 'A' },
93
+ { value: 'a', label: 'Also A' },
94
+ ],
95
+ },
96
+ ]),
97
+ ).toThrow(/duplicate option value/)
98
+ })
99
+
100
+ it('throws when a number column has min above max', () => {
101
+ expect(() =>
102
+ defineColumns<Row>([
103
+ illegal({ ...ok, type: 'number', min: 10, max: 1 }),
104
+ ]),
105
+ ).toThrow(/min greater than max/)
106
+ })
107
+
108
+ it('accepts every supported type', () => {
109
+ const columns = defineColumns<Row>([
110
+ { id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
111
+ { id: 'b', displayName: 'B', type: 'number', accessor: () => 1, min: 0, max: 5 },
112
+ { id: 'c', displayName: 'C', type: 'date', accessor: () => new Date() },
113
+ {
114
+ id: 'd',
115
+ displayName: 'D',
116
+ type: 'option',
117
+ accessor: (r) => r.name,
118
+ options: [{ value: 'x', label: 'X' }],
119
+ },
120
+ {
121
+ id: 'e',
122
+ displayName: 'E',
123
+ type: 'multiOption',
124
+ accessor: () => ['x'],
125
+ options: [{ value: 'x', label: 'X' }],
126
+ },
127
+ ])
128
+ expect(columns).toHaveLength(5)
129
+ })
130
+ })
131
+
132
+ describe('indexColumns / isColumnOfType', () => {
133
+ const columns = defineColumns<Row>([
134
+ { id: 'a', displayName: 'A', type: 'text', accessor: (r) => r.name },
135
+ {
136
+ id: 'b',
137
+ displayName: 'B',
138
+ type: 'option',
139
+ accessor: (r) => r.name,
140
+ options: [{ value: 'x', label: 'X' }],
141
+ },
142
+ ])
143
+
144
+ it('indexes by id', () => {
145
+ const index = indexColumns(columns)
146
+ expect(index.get('a')?.type).toBe('text')
147
+ expect(index.get('missing')).toBeUndefined()
148
+ })
149
+
150
+ it('narrows on the discriminant', () => {
151
+ const column = columns[1]
152
+ if (!column) throw new Error('fixture missing')
153
+ expect(isColumnOfType(column, 'option')).toBe(true)
154
+ expect(isColumnOfType(column, 'text')).toBe(false)
155
+ if (isColumnOfType(column, 'option')) {
156
+ expect(column.options?.[0]?.value).toBe('x')
157
+ }
158
+ })
159
+ })
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Faceted counts.
3
+ *
4
+ * BUG 3 — the source computed counts inside a `useMemo` with an EMPTY
5
+ * dependency array (filter-value.tsx:481-489), so they froze at popover
6
+ * mount and disagreed with the board from the first filter onwards. These
7
+ * are pure functions of the rows passed in, so the same call with different
8
+ * rows must give different answers.
9
+ */
10
+ import { describe, expect, it } from 'vitest'
11
+ import {
12
+ defineColumns,
13
+ facetedCounts,
14
+ facetedMinMax,
15
+ facetedRowsFor,
16
+ filterRows,
17
+ } from '../index'
18
+ import type { AnyColumnConfig, FiltersState } from '../index'
19
+
20
+ interface Task {
21
+ readonly id: string
22
+ readonly priority: string | null
23
+ readonly labels: readonly string[]
24
+ readonly points: number | null
25
+ }
26
+
27
+ const columns = defineColumns<Task>([
28
+ {
29
+ id: 'priority',
30
+ type: 'option',
31
+ displayName: 'Priority',
32
+ accessor: (t) => t.priority,
33
+ options: [
34
+ { value: 'low', label: 'Low' },
35
+ { value: 'medium', label: 'Medium' },
36
+ { value: 'high', label: 'High' },
37
+ ],
38
+ },
39
+ {
40
+ id: 'labels',
41
+ type: 'multiOption',
42
+ displayName: 'Labels',
43
+ accessor: (t) => t.labels,
44
+ options: [
45
+ { value: 'bug', label: 'Bug' },
46
+ { value: 'ui', label: 'UI' },
47
+ { value: 'perf', label: 'Perf' },
48
+ ],
49
+ },
50
+ {
51
+ id: 'points',
52
+ type: 'number',
53
+ displayName: 'Points',
54
+ accessor: (t) => t.points,
55
+ },
56
+ ])
57
+
58
+ function columnById(id: string): AnyColumnConfig<Task> {
59
+ const column = columns.find((candidate) => candidate.id === id)
60
+ if (!column) throw new Error(`no column ${id}`)
61
+ return column
62
+ }
63
+
64
+ const rows: Task[] = [
65
+ { id: 'a', priority: 'high', labels: ['bug'], points: 3 },
66
+ { id: 'b', priority: 'high', labels: ['ui', 'bug'], points: 8 },
67
+ { id: 'c', priority: 'low', labels: [], points: null },
68
+ { id: 'd', priority: null, labels: ['ui'], points: 5 },
69
+ ]
70
+
71
+ describe('facetedCounts', () => {
72
+ it('counts option values', () => {
73
+ const counts = facetedCounts(rows, columnById('priority'))
74
+ expect(counts.get('high')).toBe(2)
75
+ expect(counts.get('low')).toBe(1)
76
+ })
77
+
78
+ it('keeps declared options with no rows as zero buckets', () => {
79
+ const counts = facetedCounts(rows, columnById('priority'))
80
+ expect(counts.has('medium')).toBe(true)
81
+ expect(counts.get('medium')).toBe(0)
82
+ })
83
+
84
+ it('lists declared options first, in declaration order', () => {
85
+ const counts = facetedCounts(rows, columnById('priority'))
86
+ expect([...counts.keys()]).toEqual(['low', 'medium', 'high'])
87
+ })
88
+
89
+ it('ignores rows with no value rather than inventing a bucket', () => {
90
+ const counts = facetedCounts(rows, columnById('priority'))
91
+ const total = [...counts.values()].reduce((sum, n) => sum + n, 0)
92
+ expect(total).toBe(3)
93
+ })
94
+
95
+ it('counts each member of a multiOption column', () => {
96
+ const counts = facetedCounts(rows, columnById('labels'))
97
+ expect(counts.get('bug')).toBe(2)
98
+ expect(counts.get('ui')).toBe(2)
99
+ expect(counts.get('perf')).toBe(0)
100
+ })
101
+
102
+ it('counts a row once per distinct value, not once per occurrence', () => {
103
+ const duplicated: Task[] = [
104
+ { id: 'x', priority: null, labels: ['bug', 'bug'], points: null },
105
+ ]
106
+ expect(facetedCounts(duplicated, columnById('labels')).get('bug')).toBe(1)
107
+ })
108
+
109
+ it('appends an undeclared value found in the data', () => {
110
+ const extra: Task[] = [
111
+ { id: 'x', priority: 'urgent', labels: [], points: null },
112
+ ]
113
+ const counts = facetedCounts(extra, columnById('priority'))
114
+ expect([...counts.keys()]).toEqual(['low', 'medium', 'high', 'urgent'])
115
+ expect(counts.get('urgent')).toBe(1)
116
+ })
117
+
118
+ it('throws for a column type with no facets', () => {
119
+ expect(() => facetedCounts(rows, columnById('points'))).toThrow(
120
+ /only defined for option and multiOption/,
121
+ )
122
+ })
123
+
124
+ // BUG 3
125
+ it('recomputes from the rows it is given, so counts cannot go stale', () => {
126
+ const priority = columnById('priority')
127
+ const before = facetedCounts(rows, priority)
128
+ expect(before.get('high')).toBe(2)
129
+
130
+ const narrowed = filterRows(rows, columns, [
131
+ { columnId: 'labels', type: 'multiOption', operator: 'include', values: ['ui'] },
132
+ ])
133
+ const after = facetedCounts(narrowed, priority)
134
+
135
+ expect(after.get('high')).toBe(1)
136
+ // And the first result was not mutated by the second call.
137
+ expect(before.get('high')).toBe(2)
138
+ })
139
+ })
140
+
141
+ describe('facetedRowsFor', () => {
142
+ const state: FiltersState = [
143
+ { columnId: 'priority', type: 'option', operator: 'is', values: ['high'] },
144
+ { columnId: 'labels', type: 'multiOption', operator: 'include', values: ['ui'] },
145
+ ]
146
+
147
+ it('excludes the column its own facet list is for', () => {
148
+ // Without this, picking "high" would zero every other priority bucket and
149
+ // the list could never be widened.
150
+ const forPriority = facetedRowsFor(rows, columns, state, 'priority')
151
+ expect(forPriority.map((t) => t.id)).toEqual(['b', 'd'])
152
+ const counts = facetedCounts(forPriority, columnById('priority'))
153
+ expect(counts.get('high')).toBe(1)
154
+ expect(counts.get('low')).toBe(0)
155
+ })
156
+
157
+ it('still applies every other column filter', () => {
158
+ const forLabels = facetedRowsFor(rows, columns, state, 'labels')
159
+ expect(forLabels.map((t) => t.id)).toEqual(['a', 'b'])
160
+ })
161
+ })
162
+
163
+ describe('facetedMinMax', () => {
164
+ it('reports the observed range', () => {
165
+ expect(facetedMinMax(rows, columnById('points'))).toEqual([3, 8])
166
+ })
167
+
168
+ it('returns null when no row has a value', () => {
169
+ expect(facetedMinMax([], columnById('points'))).toBeNull()
170
+ })
171
+
172
+ it('prefers declared bounds over observed ones', () => {
173
+ const bounded = defineColumns<Task>([
174
+ {
175
+ id: 'points',
176
+ type: 'number',
177
+ displayName: 'Points',
178
+ accessor: (t) => t.points,
179
+ min: 0,
180
+ max: 100,
181
+ },
182
+ ])
183
+ const column = bounded[0]
184
+ if (!column) throw new Error('fixture missing')
185
+ expect(facetedMinMax(rows, column)).toEqual([0, 100])
186
+ })
187
+
188
+ it('throws for a non-number column', () => {
189
+ expect(() => facetedMinMax(rows, columnById('priority'))).toThrow(
190
+ /only defined for number columns/,
191
+ )
192
+ })
193
+ })