@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/segmented-pill.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
53
|
-
*
|
|
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="
|
|
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
|
-
|
|
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}
|