@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,386 @@
1
+ /**
2
+ * RankedBars — the `Traffic Source` shape: a label/value row over a bordered
3
+ * horizontal track, one row per entity, sorted by value.
4
+ *
5
+ * Promoted in ADR-146 D5 (chart layer).
6
+ *
7
+ * CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
8
+ * `apps/workspace/src`; the design-system gallery is a demo harness and does
9
+ * NOT count toward the ADR-008 D3 gate).
10
+ * TODAY (1 — gate NOT MET):
11
+ * • `components/ui/admin/analytics.tsx` — the "Where time goes" usage
12
+ * shares; it replaced the local `UsageBar` fork in the same pass
13
+ * PLANNED: a SEM-Spec term breakdown (`lenses/sem-spec/output/`) and a
14
+ * Discovery traffic/source panel. It also supersedes the ranked-bar
15
+ * specimen at `design-system/spec/shell-charts.tsx:134`, which is gallery
16
+ * code and not a consumer. Tracked in
17
+ * `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
18
+ *
19
+ * WHY THE TRACK IS BORDERED
20
+ * -------------------------
21
+ * The extracted anatomy (`METORIC-ANALYTICS-SPEC.md` 6.5) is a 317×12
22
+ * pill-radius track — a near-white fill under a light-grey 1px stroke, with
23
+ * the value bar sitting inside it. That border is not decoration: it is the
24
+ * only thing that makes a 3% row
25
+ * readable. A borderless track at a low value is a bar of nothing on a fill
26
+ * that is nearly the panel colour, i.e. an invisible row that still occupies
27
+ * space. So the track always carries `1px rgb(var(--border))`, and the row
28
+ * reads as "small" rather than as "broken". This is the same
29
+ * structure-is-required rule as CLAUDE.md's data-display surface recipe: the
30
+ * surface stays opaque, the grey structure lives inside it.
31
+ *
32
+ * MONOCHROME BY DEFAULT (ADR-146 D7)
33
+ * ----------------------------------
34
+ * The fill is `rgb(var(--accent))` unless an item names a palette `slot`. A
35
+ * ranked list is one measure over one dimension, so colouring the rows by
36
+ * their own rank would spend the identity channel re-encoding what bar length
37
+ * already shows. Pass `slot` only when the rows are genuinely different
38
+ * entities that are also identified by colour somewhere else on the screen
39
+ * (a legend, a matching line chart) — and assign the slot from the item's
40
+ * stable `key`, never from its index after sorting or filtering, or removing
41
+ * one row repaints every survivor.
42
+ *
43
+ * ROW RHYTHM
44
+ * ----------
45
+ * Rows carry `--space-2` of block padding and a `1px rgb(var(--border))`
46
+ * divider from the second row on. That puts `--space-4` between adjacent rows
47
+ * with the divider centred in the gap — the spec's row rhythm, expressed as
48
+ * padding + a rule rather than as a `gap` plus a separately positioned line,
49
+ * so the divider cannot drift off-centre when a row wraps.
50
+ *
51
+ * Token discipline: every colour is `rgb(var(--token))`; series hues resolve
52
+ * through the frozen `SERIES` lookup in `lib/series.ts` (ADR-146 D8 — never a
53
+ * constructed `--series-${i}`). The track fill is `--muted`, which is the
54
+ * sanctioned use for that token (a small muted well), never a card surface.
55
+ * Every size, gap and radius is a token; the only raw number is the track
56
+ * height, which is a prop with the spec's 12px default and is applied as an
57
+ * inline `style` rather than as a template-literal Tailwind class.
58
+ *
59
+ * Empty / loading / error are NOT handled here. `ChartFrame` owns those three
60
+ * states in reserved space (ADR-146 D5, D10.7) and this primitive is drawn
61
+ * inside it; `items={[]}` renders an empty list rather than a collapsed white
62
+ * gap in a frame that already reserves the box. A caller using `RankedBars`
63
+ * bare owes its own empty state.
64
+ *
65
+ * Motion: the fill tweens its width with `--dur-base` / `--ease-out` under
66
+ * `motion-safe:`, so a data change settles rather than snaps and
67
+ * `prefers-reduced-motion` jumps straight to the final width. Never
68
+ * `--ease-spring` on a data change — overshoot reads as imprecise.
69
+ */
70
+
71
+ import { cn } from './lib/utils'
72
+ import { normalizeToMax } from './lib/chart'
73
+ import { slotColor, type ChartSlot, type SeriesPalette } from './lib/series'
74
+
75
+ /** How `RankedBars` orders its rows before folding. */
76
+ export type RankedBarsSort = 'desc' | 'asc' | 'none'
77
+
78
+ export interface RankedBarsItem {
79
+ /**
80
+ * Stable identity key. Also the React key and the `onSelect` payload, and
81
+ * the thing a `slot` should be derived from (ADR-146 D7 — colour follows
82
+ * the entity, never its rank).
83
+ */
84
+ key: string
85
+ /** The row label. Always rendered — identity is never colour alone. */
86
+ label: string
87
+ /**
88
+ * The measure. Non-finite values are read as `0`; negatives are clamped to
89
+ * an empty track (a bar cannot be shorter than nothing). A series that
90
+ * genuinely crosses zero is not a ranked-bar chart.
91
+ */
92
+ value: number
93
+ /**
94
+ * Optional palette slot. Omit for the monochrome accent default — see the
95
+ * header. `'other'` renders the neutral fold swatch.
96
+ */
97
+ slot?: ChartSlot | undefined
98
+ /**
99
+ * Pre-formatted value text. Format through `@lovett/ui/format` — this
100
+ * primitive never formats a number, it only sets it in `tabular-nums`.
101
+ * Falls back to `formatValue(value)`.
102
+ */
103
+ valueLabel?: string | undefined
104
+ }
105
+
106
+ export interface RankedBarsProps {
107
+ /** The rows. Order only matters when `sort` is `'none'`. */
108
+ items: readonly RankedBarsItem[]
109
+ /**
110
+ * Row order. Default `'desc'` — a ranked list is descending unless the
111
+ * caller says otherwise. `'none'` keeps `items` order verbatim.
112
+ */
113
+ sort?: RankedBarsSort
114
+ /**
115
+ * Keep the first `topN` rows after sorting and fold the remainder into a
116
+ * single summed row. Omit for no fold. A fold is how a long tail stays
117
+ * honest without generating a ninth hue (ADR-146 D2).
118
+ */
119
+ topN?: number
120
+ /** Label for the folded row. Default `'Other'`. */
121
+ otherLabel?: string
122
+ /** Identity key for the folded row. Default `'other'`. */
123
+ otherKey?: string
124
+ /**
125
+ * Fixed denominator for the fill widths — pass the true total when the
126
+ * track should read as share-of-total rather than share-of-largest. Omit to
127
+ * normalise against the largest displayed row (the usual ranked-bar
128
+ * reading). A non-positive or non-finite `max` yields empty tracks rather
129
+ * than a division by zero.
130
+ */
131
+ max?: number
132
+ /**
133
+ * Which palette an item's `slot` indexes into. `'scatter'` is the
134
+ * all-pairs, three-slot palette. Default `'default'`.
135
+ */
136
+ palette?: SeriesPalette
137
+ /**
138
+ * Formats a value that has no `valueLabel`, and the folded row's sum.
139
+ * Default `String`. **`formatPercent` takes percent units, not a 0-1
140
+ * fraction** (ADR-146 D11) — convert at the call site.
141
+ */
142
+ formatValue?: (value: number) => string
143
+ /** Track height in px. Default `12`, the extracted anatomy's value. */
144
+ trackHeight?: number
145
+ /**
146
+ * Reserved width of the value column, so a value growing from "9" to
147
+ * "11,227" cannot resize the row. Pass a token-derived length.
148
+ * Default `var(--space-12)`.
149
+ */
150
+ valueMinWidth?: string
151
+ /**
152
+ * Floor for the LABEL column. The label/value line is the same shape as
153
+ * `ChartLegend`'s row — a `flex-1 min-w-0` label beside a `shrink-0` value
154
+ * with a reserved width — which is the shape that let `DonutChart`'s legend
155
+ * crush its labels to `width: 0` at 390px. Measured 2026-09-06 the ranked
156
+ * rows themselves did NOT reproduce that (they are full-width, with no plot
157
+ * competing for the row), but the failure mode is one narrow container
158
+ * away, so the floor is applied here too.
159
+ *
160
+ * Bounded by `min(..., 100%)` so it can never widen the row past its
161
+ * container; the line wraps and the value drops below the label instead.
162
+ * Pass a token-derived length. Default `calc(var(--space-12) * 2)`.
163
+ */
164
+ labelMinWidth?: string
165
+ /** Draw the between-row dividers. Default `true`. */
166
+ dividers?: boolean
167
+ /**
168
+ * Makes each row a button, receiving the row's `key`. Omit for a static
169
+ * list — an inert row must not advertise an interaction it does not have.
170
+ */
171
+ onSelect?: (key: string) => void
172
+ /**
173
+ * The emphasized row. Every other row dims. Dimming is opacity only, so
174
+ * nothing reflows. `null` = the caller uses the emphasis model but nothing
175
+ * is chosen yet; **omitted entirely** = the caller does not use it at all,
176
+ * and the rows then carry no `aria-pressed` (an inert list of links must
177
+ * not announce itself as a set of unpressed toggles).
178
+ */
179
+ activeKey?: string | null | undefined
180
+ /** Accessible name for the list. Default `'Ranked values'`. */
181
+ label?: string
182
+ /** Optional className on the list container. */
183
+ className?: string
184
+ }
185
+
186
+ interface RankedRow {
187
+ key: string
188
+ label: string
189
+ value: number
190
+ slot: ChartSlot | undefined
191
+ valueLabel: string | undefined
192
+ }
193
+
194
+ /** Clamp a share to `0..1` without ever returning a non-finite number. */
195
+ function share(value: number, max: number): number {
196
+ if (!Number.isFinite(max) || max <= 0) return 0
197
+ const fraction = value / max
198
+ if (!Number.isFinite(fraction) || fraction <= 0) return 0
199
+ return fraction >= 1 ? 1 : fraction
200
+ }
201
+
202
+ export function RankedBars({
203
+ items,
204
+ sort = 'desc',
205
+ topN,
206
+ otherLabel = 'Other',
207
+ otherKey = 'other',
208
+ max,
209
+ palette = 'default',
210
+ formatValue = String,
211
+ trackHeight = 12,
212
+ valueMinWidth = 'var(--space-12)',
213
+ labelMinWidth = 'calc(var(--space-12) * 2)',
214
+ dividers = true,
215
+ onSelect,
216
+ activeKey,
217
+ label = 'Ranked values',
218
+ className,
219
+ }: RankedBarsProps) {
220
+ const interactive = onSelect !== undefined
221
+
222
+ // Absorb non-finite input here so nothing downstream has to. A NaN width
223
+ // is not a visual bug — it drops the element, silently, with every gate
224
+ // green.
225
+ const base: RankedRow[] = items.map((item) => ({
226
+ key: item.key,
227
+ label: item.label,
228
+ value: Number.isFinite(item.value) ? item.value : 0,
229
+ slot: item.slot,
230
+ valueLabel: item.valueLabel,
231
+ }))
232
+
233
+ const ordered =
234
+ sort === 'none'
235
+ ? base
236
+ : [...base].sort((a, b) => (sort === 'desc' ? b.value - a.value : a.value - b.value))
237
+
238
+ let rows = ordered
239
+ if (topN !== undefined && Number.isInteger(topN) && topN > 0 && ordered.length > topN) {
240
+ const rest = ordered.slice(topN)
241
+ const total = rest.reduce((sum, row) => sum + row.value, 0)
242
+ rows = [
243
+ ...ordered.slice(0, topN),
244
+ {
245
+ key: otherKey,
246
+ label: otherLabel,
247
+ value: total,
248
+ slot: 'other',
249
+ valueLabel: formatValue(total),
250
+ },
251
+ ]
252
+ }
253
+
254
+ const fractions =
255
+ max === undefined
256
+ ? normalizeToMax(rows.map((row) => row.value))
257
+ : rows.map((row) => share(row.value, max))
258
+
259
+ return (
260
+ <ul aria-label={label} className={cn('flex w-full flex-col', className)}>
261
+ {rows.map((row, index) => {
262
+ const fraction = fractions[index] ?? 0
263
+ const fill = row.slot === undefined ? 'rgb(var(--accent))' : slotColor(row.slot, palette)
264
+ const dimmed = activeKey !== null && activeKey !== undefined && activeKey !== row.key
265
+
266
+ const body = (
267
+ <>
268
+ {/* `flex-wrap` is what makes the label floor safe: when label and
269
+ value cannot both fit, the VALUE wraps to a second line and
270
+ the label keeps its width, rather than the label collapsing. */}
271
+ <div className="flex w-full flex-wrap items-baseline gap-3">
272
+ <span
273
+ className="min-w-0 flex-1 truncate text-left text-[14px] font-semibold"
274
+ style={{
275
+ color: 'rgb(var(--foreground))',
276
+ minWidth: `min(${labelMinWidth}, 100%)`,
277
+ }}
278
+ title={row.label}
279
+ >
280
+ {row.label}
281
+ </span>
282
+ <span
283
+ className="shrink-0 text-right text-[14px] font-semibold tabular-nums"
284
+ style={{
285
+ color: 'rgb(var(--foreground))',
286
+ minWidth: valueMinWidth,
287
+ marginInlineStart: 'auto',
288
+ }}
289
+ >
290
+ {row.valueLabel ?? formatValue(row.value)}
291
+ </span>
292
+ </div>
293
+ <div
294
+ aria-hidden="true"
295
+ className="w-full overflow-hidden"
296
+ style={{
297
+ marginTop: 'var(--space-2)',
298
+ height: trackHeight,
299
+ background: 'rgb(var(--muted))',
300
+ border: '1px solid rgb(var(--border))',
301
+ borderRadius: 'var(--radius-full)',
302
+ }}
303
+ >
304
+ <div
305
+ className={cn(
306
+ 'h-full',
307
+ // `motion-reduce:transition-none` is load-bearing, not belt
308
+ // and braces. The duration below is inline and therefore
309
+ // unconditional, and `transition-property`'s INITIAL value
310
+ // is `all` — so under prefers-reduced-motion, where the
311
+ // motion-safe class does not apply, the fill would inherit
312
+ // `transition: all var(--dur-base)` and tween its width,
313
+ // colour and opacity anyway. A reduce user would get MORE
314
+ // motion than everyone else.
315
+ 'motion-safe:transition-[width,opacity] motion-reduce:transition-none',
316
+ )}
317
+ style={{
318
+ width: `${fraction * 100}%`,
319
+ background: fill,
320
+ borderRadius: 'var(--radius-full)',
321
+ // The mark carries most of the de-emphasis so the label
322
+ // does not have to: 0.7 (row) x 0.55 (mark) = 0.385 on the
323
+ // bar, while the text stays at 0.7 and above AA.
324
+ opacity: dimmed ? 0.55 : 1,
325
+ transitionDuration: 'var(--dur-base)',
326
+ transitionTimingFunction: 'var(--ease-out)',
327
+ }}
328
+ />
329
+ </div>
330
+ </>
331
+ )
332
+
333
+ const rowClass = cn(
334
+ 'flex w-full flex-col text-left',
335
+ // Same guard as the fill above, and applied to BOTH branches so a
336
+ // static list and an interactive one settle identically.
337
+ 'motion-safe:transition-opacity motion-reduce:transition-none',
338
+ )
339
+ const rowStyle = {
340
+ paddingBlock: 'var(--space-2)',
341
+ // 0.55 here put 14px --foreground text at 4.21:1 on a light card —
342
+ // under AA. A de-emphasized row is not a disabled one, so it owes
343
+ // the same 4.5:1 as any other text.
344
+ opacity: dimmed ? 0.7 : 1,
345
+ transitionDuration: 'var(--dur-fast)',
346
+ transitionTimingFunction: 'var(--ease-out)',
347
+ }
348
+
349
+ return (
350
+ <li
351
+ key={row.key}
352
+ className="min-w-0"
353
+ style={{
354
+ borderTop:
355
+ dividers && index > 0 ? '1px solid rgb(var(--border))' : undefined,
356
+ }}
357
+ >
358
+ {interactive ? (
359
+ <button
360
+ type="button"
361
+ aria-pressed={activeKey === undefined ? undefined : activeKey === row.key}
362
+ onClick={() => onSelect(row.key)}
363
+ className={cn(
364
+ rowClass,
365
+ 'cursor-pointer rounded-[var(--radius-xs)]',
366
+ // A clickable row that only reacts on focus reads as inert.
367
+ // The house hover tint, on the row rather than the mark, so
368
+ // the hit target and the feedback are the same shape.
369
+ 'hover:bg-[rgb(var(--surface-overlay))]',
370
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
371
+ )}
372
+ style={rowStyle}
373
+ >
374
+ {body}
375
+ </button>
376
+ ) : (
377
+ <div className={rowClass} style={rowStyle}>
378
+ {body}
379
+ </div>
380
+ )}
381
+ </li>
382
+ )
383
+ })}
384
+ </ul>
385
+ )
386
+ }
@@ -31,9 +31,32 @@
31
31
  * control.
32
32
  *
33
33
  * Icon-agnostic. Token-only styling.
34
+ *
35
+ * ## Why this is a `group`, not a `tablist`
36
+ *
37
+ * It was a `tablist` of `tab`s, each carrying
38
+ * `aria-controls={`${reactId}-panel-${item.id}`}` — ids that no consumer
39
+ * could ever render, because `reactId` was minted inside this component and
40
+ * never exposed. A grep of every call site (ad-intel filter bar, community
41
+ * post modal, tasks display menu, discovery research options, personas add
42
+ * modal, social CropCard, generate form/results/landing-page, budget
43
+ * flighting view chips, design-system demos) found no `role="tabpanel"`
44
+ * anywhere: none of them render tab panels, and none of them could have
45
+ * wired one up if they wanted to. Every `aria-controls` in the shipped DOM
46
+ * pointed at nothing, and a screen reader was being told "tab 2 of 3" about
47
+ * a sort order, a research depth, and a crop mode.
48
+ *
49
+ * `detail/segmented-choice.tsx` already makes this argument in full and
50
+ * lands on `role="group"` + `aria-pressed`; this is the same control with
51
+ * the same answer. A group of toggle buttons says what these actually are —
52
+ * one of N is chosen, nothing is revealed — and the model needs no ids at
53
+ * all, so it cannot rot into a dangling reference again.
54
+ *
55
+ * VISUALS ARE UNCHANGED. This is a role/state change only; every class,
56
+ * token and style below is byte-for-byte what the tablist rendered.
34
57
  */
35
58
 
36
- import { useId, type ReactNode } from 'react'
59
+ import type { ReactNode } from 'react'
37
60
  import { cn } from './lib/utils'
38
61
 
39
62
  export interface SegmentedPillItem<Id extends string = string> {
@@ -49,8 +72,12 @@ export interface SegmentedPillProps<Id extends string = string> {
49
72
  items: SegmentedPillItem<Id>[]
50
73
  value: Id
51
74
  onChange: (next: Id) => void
52
- /** Accessible label for the whole group (rendered as aria-label on
53
- * the `role="tablist"` container). */
75
+ /**
76
+ * Accessible name for the whole group, rendered as `aria-label` on the
77
+ * `role="group"` container. Optional for backwards compatibility with the
78
+ * call sites that predate it, but a group of toggle buttons with no name is
79
+ * an unnamed group — supply it.
80
+ */
54
81
  ariaLabel?: string
55
82
  /** Size variant — controls vertical padding + font. Default `md`.
56
83
  * `xs` is for dense inline contexts (e.g. a sort toggle inside a tab bar). */
@@ -69,8 +96,6 @@ export function SegmentedPill<Id extends string = string>({
69
96
  stretch = true,
70
97
  className,
71
98
  }: SegmentedPillProps<Id>) {
72
- const reactId = useId()
73
-
74
99
  const padY = size === 'xs' ? 'py-1' : size === 'sm' ? 'py-1.5' : 'py-2'
75
100
  const textSize = size === 'xs' ? 'text-[11px]' : size === 'sm' ? 'text-[12px]' : 'text-[13px]'
76
101
  const padX = size === 'xs' ? 'px-2.5' : 'px-3'
@@ -78,7 +103,7 @@ export function SegmentedPill<Id extends string = string>({
78
103
 
79
104
  return (
80
105
  <div
81
- role="tablist"
106
+ role="group"
82
107
  aria-label={ariaLabel}
83
108
  className={cn(
84
109
  'flex items-center gap-1',
@@ -105,9 +130,7 @@ export function SegmentedPill<Id extends string = string>({
105
130
  <button
106
131
  key={item.id}
107
132
  type="button"
108
- role="tab"
109
- aria-selected={isActive}
110
- aria-controls={`${reactId}-panel-${item.id}`}
133
+ aria-pressed={isActive}
111
134
  disabled={item.disabled}
112
135
  onClick={() => onChange(item.id)}
113
136
  aria-label={item.ariaLabel}