@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,339 @@
1
+ // @vitest-environment node
2
+ //
3
+ // Pure source scan — reads tokens.css and the primitive sources off disk.
4
+ // jsdom rewrites import.meta.url to an http URL that fileURLToPath rejects,
5
+ // and there is nothing here that needs a DOM.
6
+ /**
7
+ * The ADR-146 D8 guard: series colour is resolved by a static lookup, and the
8
+ * tokens that lookup names actually exist.
9
+ *
10
+ * WHY THIS TEST EXISTS
11
+ * --------------------
12
+ * `ENGINEERING-AXES.md:186` records the defect. A constructed custom-property
13
+ * name (`--folder-${key}`) produced `--folder-folder-violet`, which is
14
+ * undefined, which makes the enclosing `rgb()` invalid, which the browser
15
+ * **silently discards** — shipping white-on-white tiles with typecheck, lint,
16
+ * tests, build and the token gate all green. Nothing that reads source can
17
+ * see it, because the source is syntactically perfect. The only detector is a
18
+ * test that resolves each name against the declarations.
19
+ *
20
+ * WHY IT MUTATES (ENGINEERING-AXES.md:196-201)
21
+ * --------------------------------------------
22
+ * A guard that has never been observed failing is not a guard — it is a
23
+ * `expect(true).toBe(true)` with extra steps. Every assertion below is paired
24
+ * with a MUTATION that drives the same checker in the failing direction:
25
+ *
26
+ * • rename `--series-3` in a copy of tokens.css → the checker names it
27
+ * • typo one entry of a SERIES-shaped array → the checker names it
28
+ * • bake an alpha into `--series-5` → the alpha check fires
29
+ * • reorder / duplicate a slot → the order check fires
30
+ * • write `--series-${i}` in a source file → the scan fires
31
+ *
32
+ * So if a `--series-*` token is ever renamed or dropped in tokens.css, this
33
+ * file goes RED — and the mutation cases prove that claim rather than
34
+ * asserting it.
35
+ */
36
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+ import { describe, expect, it } from 'vitest'
40
+
41
+ import {
42
+ SCATTER_SLOT_COUNT,
43
+ SERIES,
44
+ SERIES_SCATTER,
45
+ SERIES_SLOT_COUNT,
46
+ isScatterSlot,
47
+ isSeriesSlot,
48
+ paletteColor,
49
+ scatterColor,
50
+ seriesColor,
51
+ slotColor,
52
+ } from '../lib/series'
53
+
54
+ const SRC = fileURLToPath(new URL('..', import.meta.url))
55
+ const TOKENS_CSS = readFileSync(join(SRC, 'tokens.css'), 'utf8')
56
+
57
+ /* ── the checkers (pure, so the mutations can drive the same code) ────── */
58
+
59
+ /**
60
+ * Every custom property `css` declares, mapped to its declared values (a
61
+ * token appears once per theme block).
62
+ */
63
+ function declaredTokens(css: string): Map<string, string[]> {
64
+ const out = new Map<string, string[]>()
65
+ for (const match of css.matchAll(/^\s*(--[a-z0-9-]+)\s*:\s*([^;]+);/gm)) {
66
+ const name = match[1]
67
+ const value = match[2]
68
+ if (name === undefined || value === undefined) continue
69
+ const list = out.get(name) ?? []
70
+ list.push(value.trim())
71
+ out.set(name, list)
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** The token name inside `rgb(var(--x))`, or `null` if the entry isn't that shape. */
77
+ function tokenOf(entry: string): string | null {
78
+ const match = /^rgb\(var\((--[a-z0-9-]+)\)\)$/.exec(entry)
79
+ return match?.[1] ?? null
80
+ }
81
+
82
+ /**
83
+ * THE GUARD. Every palette entry must be `rgb(var(--token))` naming a custom
84
+ * property `css` actually declares. Returns the offending entries — empty
85
+ * means clean.
86
+ */
87
+ function undeclaredEntries(entries: readonly string[], css: string): string[] {
88
+ const declared = declaredTokens(css)
89
+ const bad: string[] = []
90
+ for (const entry of entries) {
91
+ const token = tokenOf(entry)
92
+ if (token === null) {
93
+ bad.push(`${entry} — not a plain rgb(var(--token)) reference`)
94
+ } else if (!declared.has(token)) {
95
+ bad.push(`${entry} — ${token} is not declared in tokens.css`)
96
+ }
97
+ }
98
+ return bad
99
+ }
100
+
101
+ /**
102
+ * A series token must be a BARE RGB TRIPLE. Call sites apply their own alpha
103
+ * (`rgb(var(--series-1) / 0.12)` for an area fill); a baked-in alpha would
104
+ * expand to `rgb(2 137 234 / 0.3 / 0.12)`, which is invalid and dropped.
105
+ */
106
+ function alphaCarryingEntries(entries: readonly string[], css: string): string[] {
107
+ const declared = declaredTokens(css)
108
+ const bad: string[] = []
109
+ for (const entry of entries) {
110
+ const token = tokenOf(entry)
111
+ if (token === null) continue
112
+ for (const value of declared.get(token) ?? []) {
113
+ if (value.includes('/')) bad.push(`${token}: ${value}`)
114
+ }
115
+ }
116
+ return bad
117
+ }
118
+
119
+ /** Palette entries whose token does not match its 1-based slot number. */
120
+ function misorderedEntries(entries: readonly string[]): string[] {
121
+ return entries.flatMap((entry, index) => {
122
+ const expected = `rgb(var(--series-${index + 1}))`
123
+ return entry === expected ? [] : [`slot ${index}: ${entry} ≠ ${expected}`]
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Blank comments while preserving line numbers, so a violation still reports
129
+ * the line it is on. Without this the scan flags its own doc-blocks — every
130
+ * header in this package that spells the broken form out in order to warn
131
+ * against it. Same helper, same reason, as `token-shape.test.ts`.
132
+ *
133
+ * Line comments are only stripped when the line STARTS with `//`, so a
134
+ * `https://` inside a string survives.
135
+ */
136
+ function stripComments(source: string): string {
137
+ return source
138
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
139
+ .split('\n')
140
+ .map((line) => (/^\s*\/\//.test(line) ? '' : line))
141
+ .join('\n')
142
+ }
143
+
144
+ /**
145
+ * Source lines that BUILD a `--series-*` name instead of naming one — the
146
+ * exact shape ADR-146 D8 forbids. Comments are blanked first: a warning about
147
+ * the defect is not the defect.
148
+ */
149
+ function constructedNameHits(source: string): string[] {
150
+ const hits: string[] = []
151
+ stripComments(source)
152
+ .split('\n')
153
+ .forEach((line, i) => {
154
+ if (/--series-\$\{|['"`]--series-['"`]\s*\+|concat\(\s*['"`]--series-/.test(line)) {
155
+ hits.push(`${i + 1}: ${line.trim()}`)
156
+ }
157
+ })
158
+ return hits
159
+ }
160
+
161
+ /* ── source inventory ────────────────────────────────────────────────── */
162
+
163
+ function sourceFiles(dir: string, acc: string[] = []): string[] {
164
+ for (const entry of readdirSync(dir)) {
165
+ if (entry === 'node_modules' || entry === '__tests__') continue
166
+ const full = join(dir, entry)
167
+ if (statSync(full).isDirectory()) sourceFiles(full, acc)
168
+ else if (/\.tsx?$/.test(entry)) acc.push(full)
169
+ }
170
+ return acc
171
+ }
172
+
173
+ const FILES = sourceFiles(SRC)
174
+
175
+ /* ── the guard, against reality ──────────────────────────────────────── */
176
+
177
+ describe('SERIES resolves to tokens tokens.css actually declares', () => {
178
+ it('scans a real tokens.css and a real source tree', () => {
179
+ // If either input silently emptied, every assertion below would pass
180
+ // vacuously. Pin them.
181
+ expect(declaredTokens(TOKENS_CSS).size).toBeGreaterThan(100)
182
+ expect(FILES.length).toBeGreaterThan(50)
183
+ })
184
+
185
+ it('has exactly eight slots, in D2 order', () => {
186
+ expect(SERIES).toHaveLength(8)
187
+ expect(SERIES_SLOT_COUNT).toBe(8)
188
+ expect(misorderedEntries(SERIES)).toEqual([])
189
+ })
190
+
191
+ it('names only declared custom properties', () => {
192
+ expect(undeclaredEntries(SERIES, TOKENS_CSS)).toEqual([])
193
+ })
194
+
195
+ it('names only bare-triple tokens, so a call site may add its own alpha', () => {
196
+ expect(alphaCarryingEntries(SERIES, TOKENS_CSS)).toEqual([])
197
+ })
198
+
199
+ it('caps the all-pairs palette at slots 1, 6 and 7', () => {
200
+ expect(SERIES_SCATTER).toHaveLength(3)
201
+ expect(SCATTER_SLOT_COUNT).toBe(3)
202
+ expect([...SERIES_SCATTER]).toEqual([SERIES[0], SERIES[5], SERIES[6]])
203
+ expect(undeclaredEntries(SERIES_SCATTER, TOKENS_CSS)).toEqual([])
204
+ })
205
+
206
+ it('never constructs a --series-* name anywhere in the package', () => {
207
+ const violations = FILES.flatMap((file) =>
208
+ constructedNameHits(readFileSync(file, 'utf8')).map(
209
+ (hit) => `${file.slice(SRC.length)}:${hit}`,
210
+ ),
211
+ )
212
+ expect(violations).toEqual([])
213
+ })
214
+ })
215
+
216
+ /* ── MUTATION VERIFY — the guard fails in the failing direction ───────── */
217
+
218
+ describe('the guard fails when it should (mutation verify)', () => {
219
+ it('goes red when a --series-* token is RENAMED in tokens.css', () => {
220
+ // The exact regression this file exists to catch: someone renames a
221
+ // token in tokens.css and the lookup keeps naming the old one. Real
222
+ // tokens.css, one surgical rename.
223
+ const mutated = TOKENS_CSS.replace(
224
+ /^(\s*)--series-3:/m,
225
+ '$1--series-3-renamed:',
226
+ )
227
+ expect(mutated).not.toBe(TOKENS_CSS) // the mutation actually applied
228
+
229
+ const failures = undeclaredEntries(SERIES, mutated)
230
+ expect(failures).toHaveLength(1)
231
+ expect(failures[0]).toContain('--series-3')
232
+ expect(failures[0]).toContain('not declared')
233
+ })
234
+
235
+ it('goes red when a --series-* token is DELETED from tokens.css', () => {
236
+ const mutated = TOKENS_CSS.replace(/^\s*--series-8:[^;]+;.*$/m, '')
237
+ expect(mutated).not.toBe(TOKENS_CSS)
238
+ expect(undeclaredEntries(SERIES, mutated)).toHaveLength(1)
239
+ })
240
+
241
+ it('goes red when a palette entry has a typo in its token name', () => {
242
+ const mutated = SERIES.map((entry) =>
243
+ entry === 'rgb(var(--series-4))' ? 'rgb(var(--serie-4))' : entry,
244
+ )
245
+ const failures = undeclaredEntries(mutated, TOKENS_CSS)
246
+ expect(failures).toHaveLength(1)
247
+ expect(failures[0]).toContain('--serie-4')
248
+ })
249
+
250
+ it('goes red when a palette entry is not a plain rgb(var(--token)) reference', () => {
251
+ // Two realistic slips: the rgb() wrapper dropped, and an alpha baked into
252
+ // the entry (which would then stack with a call site's own alpha).
253
+ const unwrapped = SERIES.map((entry, i) => (i === 0 ? 'var(--series-1)' : entry))
254
+ const preAlpha = SERIES.map((entry, i) =>
255
+ i === 1 ? 'rgb(var(--series-2) / 0.5)' : entry,
256
+ )
257
+ expect(undeclaredEntries(unwrapped, TOKENS_CSS)).toHaveLength(1)
258
+ expect(undeclaredEntries(preAlpha, TOKENS_CSS)).toHaveLength(1)
259
+ })
260
+
261
+ it('goes red when a series token grows a baked-in alpha', () => {
262
+ const mutated = TOKENS_CSS.replace(
263
+ /^(\s*--series-5:\s*)([^;]+);/m,
264
+ '$1$2 / 0.3;',
265
+ )
266
+ expect(mutated).not.toBe(TOKENS_CSS)
267
+
268
+ const failures = alphaCarryingEntries(SERIES, mutated)
269
+ expect(failures).toHaveLength(1)
270
+ expect(failures[0]).toContain('--series-5')
271
+ })
272
+
273
+ it('goes red when two slots are swapped or one is duplicated', () => {
274
+ const swapped = [SERIES[1], SERIES[0], ...SERIES.slice(2)]
275
+ expect(misorderedEntries(swapped)).toHaveLength(2)
276
+
277
+ const duplicated = [SERIES[0], SERIES[0], ...SERIES.slice(2)]
278
+ expect(misorderedEntries(duplicated)).toHaveLength(1)
279
+ })
280
+
281
+ it('goes red when a source file constructs a --series-* name', () => {
282
+ // The literal shape ADR-146 D8 forbids. Built from parts so this test
283
+ // file does not itself trip its own scan above.
284
+ const dashes = '--'
285
+ const broken = `const color = \`rgb(var(${dashes}series-\${slot}))\``
286
+ expect(constructedNameHits(broken)).toHaveLength(1)
287
+
288
+ // ...and stays quiet on the correct shape.
289
+ expect(constructedNameHits('const color = SERIES[slot]')).toEqual([])
290
+
291
+ // Comments are blanked (a warning about the defect is not the defect),
292
+ // but blanking must not also blind the scan to real code on other lines.
293
+ expect(constructedNameHits(`// never write ${broken}`)).toEqual([])
294
+ expect(constructedNameHits(`/* ${broken} */\n${broken}`)).toHaveLength(1)
295
+ })
296
+ })
297
+
298
+ /* ── the resolver API ────────────────────────────────────────────────── */
299
+
300
+ describe('slot resolution', () => {
301
+ it('maps every legal slot to its own entry', () => {
302
+ expect(seriesColor(0)).toBe(SERIES[0])
303
+ expect(seriesColor(7)).toBe(SERIES[7])
304
+ expect(scatterColor(0)).toBe(SERIES_SCATTER[0])
305
+ expect(scatterColor(2)).toBe(SERIES_SCATTER[2])
306
+ })
307
+
308
+ it('narrows plain numbers, rejecting out-of-range and non-integers', () => {
309
+ expect(isSeriesSlot(0)).toBe(true)
310
+ expect(isSeriesSlot(7)).toBe(true)
311
+ expect(isSeriesSlot(8)).toBe(false)
312
+ expect(isSeriesSlot(-1)).toBe(false)
313
+ expect(isSeriesSlot(1.5)).toBe(false)
314
+ expect(isSeriesSlot(Number.NaN)).toBe(false)
315
+
316
+ expect(isScatterSlot(2)).toBe(true)
317
+ expect(isScatterSlot(3)).toBe(false)
318
+ })
319
+
320
+ it('returns undefined past a palette cap rather than wrapping around', () => {
321
+ // Wrapping would paint two different entities in one hue — a wrong
322
+ // chart that no gate can see. ADR-146 D2 folds to "Other" instead.
323
+ expect(paletteColor('scatter', 3)).toBeUndefined()
324
+ expect(paletteColor('default', 8)).toBeUndefined()
325
+ expect(paletteColor('default', 0)).toBe(SERIES[0])
326
+ expect(paletteColor('scatter', 1)).toBe(SERIES[5])
327
+ })
328
+
329
+ it("renders the 'other' fold as a neutral, never as a hue", () => {
330
+ const other = slotColor('other', 'default')
331
+ expect(other).toBe('rgb(var(--text-muted))')
332
+ expect(SERIES).not.toContain(other)
333
+ expect(declaredTokens(TOKENS_CSS).has('--text-muted')).toBe(true)
334
+
335
+ // A scatter chart's fourth series folds too, rather than reusing blue.
336
+ expect(slotColor(3, 'scatter')).toBe(other)
337
+ expect(slotColor(3, 'default')).toBe(SERIES[3])
338
+ })
339
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * CodeBlock / MarkdownCode / MarkdownPre — a code BLOCK is decided by where
3
+ * the code sits (inside react-markdown's `pre`), not by whether the fence
4
+ * names a language.
5
+ *
6
+ * Before this, a bare ``` fence — the rich composer's default (ADR-148 D3) —
7
+ * rendered as an inline chip because the only discriminator was the
8
+ * `language-*` className. The block surface is recognisable by its header:
9
+ * a language label and the "Copy code" button.
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest'
13
+ import { render } from '@testing-library/react'
14
+ import { MarkdownCode, MarkdownPre } from '../code-block'
15
+ import { MarkdownRenderer } from '../markdown'
16
+
17
+ const COPY = 'button[aria-label="Copy code"]'
18
+ /** The inline chip: a bare `<code>` carrying the chip classes, never inside a block. */
19
+ const CHIP = 'code.rounded'
20
+
21
+ function md(markdown: string): HTMLElement {
22
+ return render(<MarkdownRenderer>{markdown}</MarkdownRenderer>).container
23
+ }
24
+
25
+ describe('MarkdownRenderer code', () => {
26
+ it('a bare fence renders a plain code block, not a chip', () => {
27
+ const container = md('```\nconst a = 1\n```')
28
+ expect(container.querySelector(COPY)).not.toBeNull()
29
+ expect(container.querySelector('pre')?.textContent).toBe('const a = 1')
30
+ expect(container.textContent).toContain('plaintext')
31
+ expect(container.querySelector(CHIP)).toBeNull()
32
+ })
33
+
34
+ it('a fence with a language renders a highlighted code block', () => {
35
+ const container = md('```ts\nconst a = 1\n```')
36
+ expect(container.querySelector(COPY)).not.toBeNull()
37
+ expect(container.textContent).toContain('typescript')
38
+ // The highlighter owns a real <pre><code> of its own.
39
+ expect(container.querySelector('pre code')).not.toBeNull()
40
+ expect(container.querySelector('pre')?.textContent).toBe('const a = 1')
41
+ expect(container.querySelector(CHIP)).toBeNull()
42
+ })
43
+
44
+ it('single-backtick code stays inline', () => {
45
+ const container = md('run `pnpm dev:all` now')
46
+ const chip = container.querySelector(CHIP)
47
+ expect(chip?.textContent).toBe('pnpm dev:all')
48
+ expect(container.querySelector('pre')).toBeNull()
49
+ expect(container.querySelector(COPY)).toBeNull()
50
+ })
51
+
52
+ it('a fence whose first line looks like a language is code with that content', () => {
53
+ const container = md('```\njs\n```')
54
+ expect(container.querySelector(COPY)).not.toBeNull()
55
+ expect(container.querySelector('pre')?.textContent).toBe('js')
56
+ expect(container.textContent).toContain('plaintext')
57
+ expect(container.textContent).not.toContain('javascript')
58
+ })
59
+
60
+ it('an empty fence still renders a block', () => {
61
+ const container = md('```\n```')
62
+ expect(container.querySelector(COPY)).not.toBeNull()
63
+ expect(container.querySelector('pre')?.textContent).toBe('')
64
+ expect(container.querySelector(CHIP)).toBeNull()
65
+ })
66
+
67
+ it('an indented code block renders a block', () => {
68
+ const container = md(' indented')
69
+ expect(container.querySelector(COPY)).not.toBeNull()
70
+ expect(container.querySelector('pre')?.textContent).toBe('indented')
71
+ })
72
+
73
+ it('a code span that wraps onto a second line stays inline', () => {
74
+ const container = md('a `b\nc` d')
75
+ expect(container.querySelector(CHIP)?.textContent).toBe('b c')
76
+ expect(container.querySelector('pre')).toBeNull()
77
+ })
78
+
79
+ it('inline code inside a list item stays inline', () => {
80
+ const container = md('- run `x`')
81
+ expect(container.querySelector('li ' + CHIP)?.textContent).toBe('x')
82
+ expect(container.querySelector(COPY)).toBeNull()
83
+ })
84
+
85
+ it('a block and a chip in one document are told apart', () => {
86
+ const container = md('use `a`\n\n```\nb\n```')
87
+ expect(container.querySelector(CHIP)?.textContent).toBe('a')
88
+ expect(container.querySelector('pre')?.textContent).toBe('b')
89
+ expect(container.querySelectorAll(COPY)).toHaveLength(1)
90
+ })
91
+ })
92
+
93
+ describe('MarkdownCode used directly', () => {
94
+ it('bare is inline', () => {
95
+ const { container } = render(<MarkdownCode>pnpm dev:all</MarkdownCode>)
96
+ expect(container.querySelector(CHIP)?.textContent).toBe('pnpm dev:all')
97
+ expect(container.querySelector(COPY)).toBeNull()
98
+ })
99
+
100
+ it('a language-* className is a highlighted block', () => {
101
+ const { container } = render(<MarkdownCode className="language-json">{'{ "a": 1 }'}</MarkdownCode>)
102
+ expect(container.querySelector(COPY)).not.toBeNull()
103
+ expect(container.textContent).toContain('json')
104
+ })
105
+
106
+ it('a className that is not a language is still a block (the documented gotcha)', () => {
107
+ const { container } = render(<MarkdownCode className="text-sm">still a block</MarkdownCode>)
108
+ expect(container.querySelector(COPY)).not.toBeNull()
109
+ expect(container.textContent).toContain('plaintext')
110
+ })
111
+
112
+ it('inside MarkdownPre, bare code is a block', () => {
113
+ const { container } = render(
114
+ <MarkdownPre>
115
+ <MarkdownCode>{'x\n'}</MarkdownCode>
116
+ </MarkdownPre>,
117
+ )
118
+ expect(container.querySelector(COPY)).not.toBeNull()
119
+ expect(container.querySelector('pre')?.textContent).toBe('x')
120
+ expect(container.querySelector(CHIP)).toBeNull()
121
+ })
122
+
123
+ it('MarkdownPre renders no element of its own', () => {
124
+ const { container } = render(
125
+ <MarkdownPre>
126
+ <MarkdownCode>x</MarkdownCode>
127
+ </MarkdownPre>,
128
+ )
129
+ // The block's own container is the first child; nothing wraps it.
130
+ expect(container.children).toHaveLength(1)
131
+ expect(container.firstElementChild?.tagName).toBe('DIV')
132
+ expect(container.querySelectorAll('pre')).toHaveLength(1)
133
+ })
134
+ })
@@ -0,0 +1,195 @@
1
+ /**
2
+ * DisplayPopover + its row primitives.
3
+ *
4
+ * The contract is mostly about what CANNOT happen, because each of these
5
+ * primitives exists to make one of the source's defects unreachable
6
+ * (`docs/design-briefs/tasks-board-header-primitives.md` §4):
7
+ *
8
+ * - `ToggleRow` owns the id/htmlFor pair, so clicking the LABEL toggles.
9
+ * Theirs is hand-written at three call sites.
10
+ * - every `ChipToggleGroup` chip is `type="button"` with `aria-pressed`.
11
+ * Theirs is neither — inside a form it submits, and a screen reader is
12
+ * told nothing about which chips are on.
13
+ * - `PopoverFooterActions` renders NO primary unless one is supplied.
14
+ * Theirs is a `<button>` with no `onClick` in three files.
15
+ * - the panel keeps Tab inside itself, closes on Escape and hands focus
16
+ * back to its trigger.
17
+ */
18
+ import { useState } from 'react'
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
20
+ import { render, screen } from '@testing-library/react'
21
+ import userEvent from '@testing-library/user-event'
22
+ import {
23
+ ChipToggleGroup,
24
+ DisplayPopover,
25
+ PopoverFooterActions,
26
+ SelectRow,
27
+ ToggleRow,
28
+ } from '../display-popover'
29
+ import { mockGeometry, setViewport } from './helpers/geometry'
30
+
31
+ describe('ToggleRow', () => {
32
+ it('pairs the label with the control so clicking the label toggles', async () => {
33
+ const user = userEvent.setup()
34
+ const onCheckedChange = vi.fn()
35
+ render(
36
+ <ToggleRow
37
+ label="Show empty columns"
38
+ checked={false}
39
+ onCheckedChange={onCheckedChange}
40
+ />,
41
+ )
42
+
43
+ // getByLabelText only resolves when the htmlFor/id pair is real.
44
+ const control = screen.getByLabelText('Show empty columns')
45
+ expect(control).toHaveAttribute('type', 'checkbox')
46
+
47
+ await user.click(screen.getByText('Show empty columns'))
48
+ expect(onCheckedChange).toHaveBeenCalledWith(true)
49
+ })
50
+
51
+ it('mints a unique id per row, so two rows do not collide', () => {
52
+ render(
53
+ <>
54
+ <ToggleRow label="First" checked={false} onCheckedChange={() => {}} />
55
+ <ToggleRow label="Second" checked={false} onCheckedChange={() => {}} />
56
+ </>,
57
+ )
58
+ const first = screen.getByLabelText('First')
59
+ const second = screen.getByLabelText('Second')
60
+ expect(first.id).not.toBe('')
61
+ expect(first.id).not.toBe(second.id)
62
+ })
63
+ })
64
+
65
+ describe('SelectRow', () => {
66
+ it('names the control from the row label when none is given', () => {
67
+ render(
68
+ <SelectRow
69
+ label="Grouping"
70
+ value="column"
71
+ onValueChange={() => {}}
72
+ options={[
73
+ { value: 'column', label: 'Column' },
74
+ { value: 'assignee', label: 'Assignee' },
75
+ ]}
76
+ />,
77
+ )
78
+ expect(screen.getByRole('combobox', { name: 'Grouping' })).toBeInTheDocument()
79
+ })
80
+ })
81
+
82
+ describe('ChipToggleGroup', () => {
83
+ it('gives every chip type=button and aria-pressed', () => {
84
+ render(
85
+ <ChipToggleGroup
86
+ aria-label="Card properties"
87
+ items={[
88
+ { key: 'priority', label: 'Priority' },
89
+ { key: 'due', label: 'Due date' },
90
+ ]}
91
+ value={{ priority: true }}
92
+ onToggle={() => {}}
93
+ />,
94
+ )
95
+ const priority = screen.getByRole('button', { name: 'Priority', pressed: true })
96
+ const due = screen.getByRole('button', { name: 'Due date', pressed: false })
97
+ // A chip without type="button" submits the form it sits in.
98
+ expect(priority).toHaveAttribute('type', 'button')
99
+ expect(due).toHaveAttribute('type', 'button')
100
+ })
101
+
102
+ it('reports the key and the NEXT state, not just the key', async () => {
103
+ const user = userEvent.setup()
104
+ const onToggle = vi.fn()
105
+ render(
106
+ <ChipToggleGroup
107
+ aria-label="Card properties"
108
+ items={[{ key: 'priority', label: 'Priority' }]}
109
+ value={{ priority: true }}
110
+ onToggle={onToggle}
111
+ />,
112
+ )
113
+ await user.click(screen.getByRole('button', { name: 'Priority' }))
114
+ expect(onToggle).toHaveBeenCalledWith('priority', false)
115
+ })
116
+ })
117
+
118
+ describe('PopoverFooterActions', () => {
119
+ it('renders no primary action when none is supplied', () => {
120
+ render(<PopoverFooterActions onReset={() => {}} />)
121
+ expect(screen.getByRole('button', { name: 'Reset to defaults' })).toBeInTheDocument()
122
+ // Exactly one button: the dead "Set default for everyone" cannot happen.
123
+ expect(screen.getAllByRole('button')).toHaveLength(1)
124
+ })
125
+
126
+ it('renders the primary when one is supplied', () => {
127
+ const onClick = vi.fn()
128
+ render(
129
+ <PopoverFooterActions
130
+ onReset={() => {}}
131
+ primaryAction={{ label: 'Apply', onClick }}
132
+ />,
133
+ )
134
+ expect(screen.getAllByRole('button')).toHaveLength(2)
135
+ })
136
+
137
+ it('disables reset when the panel is already at its defaults', () => {
138
+ render(<PopoverFooterActions onReset={() => {}} resetDisabled />)
139
+ expect(screen.getByRole('button', { name: 'Reset to defaults' })).toBeDisabled()
140
+ })
141
+ })
142
+
143
+ describe('DisplayPopover', () => {
144
+ let restore: () => void
145
+
146
+ beforeEach(() => {
147
+ restore = mockGeometry()
148
+ setViewport(1280, 800)
149
+ })
150
+ afterEach(() => restore())
151
+
152
+ function Panel() {
153
+ const [flag, setFlag] = useState(false)
154
+ return (
155
+ <DisplayPopover
156
+ label="Display options"
157
+ trigger={<button type="button">Display</button>}
158
+ >
159
+ <DisplayPopover.Section>
160
+ <ToggleRow label="Show empty columns" checked={flag} onCheckedChange={setFlag} />
161
+ </DisplayPopover.Section>
162
+ <DisplayPopover.Section>
163
+ <PopoverFooterActions onReset={() => setFlag(false)} />
164
+ </DisplayPopover.Section>
165
+ </DisplayPopover>
166
+ )
167
+ }
168
+
169
+ it('opens, closes on Escape, and returns focus to its trigger', async () => {
170
+ const user = userEvent.setup()
171
+ render(<Panel />)
172
+ const trigger = screen.getByRole('button', { name: 'Display' })
173
+
174
+ await user.click(trigger)
175
+ expect(screen.getByRole('dialog', { name: 'Display options' })).toBeInTheDocument()
176
+
177
+ await user.keyboard('{Escape}')
178
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
179
+ expect(trigger).toHaveFocus()
180
+ })
181
+
182
+ it('keeps Tab inside the panel', async () => {
183
+ const user = userEvent.setup()
184
+ render(<Panel />)
185
+ await user.click(screen.getByRole('button', { name: 'Display' }))
186
+
187
+ const panel = screen.getByRole('dialog', { name: 'Display options' })
188
+ const reset = screen.getByRole('button', { name: 'Reset to defaults' })
189
+
190
+ reset.focus()
191
+ await user.tab()
192
+ // Without the trap, focus walks out of the portal onto the page behind.
193
+ expect(panel.contains(document.activeElement)).toBe(true)
194
+ })
195
+ })