@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- 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 +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- 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__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -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/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- 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/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -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 +507 -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 +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- 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 +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- 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/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- 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 +235 -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 +662 -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 +915 -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 +218 -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 +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeltaChip — the period-over-period change chip that sits beside a KPI value.
|
|
3
|
+
*
|
|
4
|
+
* Promoted in ADR-146 D5 (stat layer). It subsumes three independent forks
|
|
5
|
+
* that were all the same `ArrowUp` / `ArrowDown` / `Minus` ternary:
|
|
6
|
+
*
|
|
7
|
+
* • `Delta` — `components/ui/admin/stat-tile.tsx:10` (percent, `--success`/`--destructive` ink, no fill)
|
|
8
|
+
* • `DeltaChip` — `design-system/spec/shell-preview.tsx:254` (tinted fill, no border, up/down only — no flat)
|
|
9
|
+
* • `Trend` — `components/ui/community/primitives.tsx:117` (absolute count, down painted MUTED not red)
|
|
10
|
+
*
|
|
11
|
+
* Between them they cover percent, absolute and flat, so this primitive
|
|
12
|
+
* covers all three.
|
|
13
|
+
*
|
|
14
|
+
* CONSUMERS — measured 2026-09-06 (real `from '@lovett/ui'` imports under
|
|
15
|
+
* `apps/workspace/src`; the design-system gallery is a demo harness and does
|
|
16
|
+
* NOT count toward the ADR-008 D3 gate).
|
|
17
|
+
* TODAY (1 direct — gate NOT MET):
|
|
18
|
+
* • `components/ui/community/leaderboard.tsx` — points trend, absolute
|
|
19
|
+
* INDIRECT, and deliberately not counted: `components/ui/admin/stat-tile.tsx`
|
|
20
|
+
* renders one, but through `StatCard`'s `delta` prop — it never imports
|
|
21
|
+
* `DeltaChip`, so it is not a second direct consumer.
|
|
22
|
+
* PLANNED: no second direct consumer is named yet. The `Delta` fork above
|
|
23
|
+
* is deleted; the `DeltaChip` fork at `design-system/spec/shell-preview.tsx:254`
|
|
24
|
+
* is gallery code and cannot count. Tracked in
|
|
25
|
+
* `docs/follow-ups/_pending/adr-146-consumer-gate.md`.
|
|
26
|
+
*
|
|
27
|
+
* Migration of the remaining forks is ADR-146 D13
|
|
28
|
+
* (`converge-delta-chips.md`), not this file's job.
|
|
29
|
+
*
|
|
30
|
+
* THREE SIGNALS, NOT ONE — fill + border + ink
|
|
31
|
+
* --------------------------------------------
|
|
32
|
+
* The source kit measures a pale tinted fill PLUS a mid-tint border PLUS a
|
|
33
|
+
* saturated label — three signals, not one (METORIC-ANALYTICS-SPEC §5.2, the
|
|
34
|
+
* exact values are recorded there). The border is not decoration: a fill-only
|
|
35
|
+
* chip at ~10% alpha disappears against one of the two surfaces it has to
|
|
36
|
+
* live on — the white panel (`--surface-card`) and the grey tray
|
|
37
|
+
* (`--surface-frame`). Two of the three forks above shipped fill-only or
|
|
38
|
+
* ink-only for exactly this reason and are illegible on one surface each.
|
|
39
|
+
* Keep all three signals.
|
|
40
|
+
*
|
|
41
|
+
* DIRECTION IS NEVER COLOUR ALONE
|
|
42
|
+
* -------------------------------
|
|
43
|
+
* The arrow carries direction independently of the hue (ADR-146 D10.1, and
|
|
44
|
+
* the "don't rely on colour alone" design principle), and an `sr-only` word
|
|
45
|
+
* carries it for assistive tech. A red/green swap alone would be unreadable
|
|
46
|
+
* for a deuteranope and silent to a screen reader.
|
|
47
|
+
*
|
|
48
|
+
* UP IS NOT ALWAYS GOOD
|
|
49
|
+
* ---------------------
|
|
50
|
+
* `invert` exists because roughly half our metrics are cost-shaped — CPA,
|
|
51
|
+
* CPC, spend, bounce rate, time-to-first-response — where DOWN is the good
|
|
52
|
+
* outcome. Hardcoding "up is green" is wrong for all of them. `direction`
|
|
53
|
+
* (the arrow) and tone (the colour) are resolved separately: the arrow
|
|
54
|
+
* always follows the sign, the colour follows the sign XOR `invert`.
|
|
55
|
+
*
|
|
56
|
+
* Token discipline: every colour is a frozen `rgb(var(--token))` literal in
|
|
57
|
+
* `DELTA_TONE` — no constructed token names (ADR-146 D8), no colour literals.
|
|
58
|
+
* `--success-bg` / `--destructive-bg` already carry their alpha, so they are
|
|
59
|
+
* consumed bare and NEVER given a second one; the border alphas hang off the
|
|
60
|
+
* bare `--success` / `--destructive` triples, which is legal. Geometry is
|
|
61
|
+
* `--radius-full` + the `--space` scale; the 2px vertical pad the kit
|
|
62
|
+
* measures is written `calc(var(--space-1) / 2)` so it stays derived from the
|
|
63
|
+
* scale rather than becoming a magic number.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
import { ArrowDown, ArrowUp, Minus, type LucideIcon } from 'lucide-react'
|
|
67
|
+
import { cn } from './lib/utils'
|
|
68
|
+
import { formatNumber, formatPercent } from './format'
|
|
69
|
+
|
|
70
|
+
/** Which way the arrow points. Inferred from the sign unless overridden. */
|
|
71
|
+
export type DeltaDirection = 'up' | 'down' | 'flat'
|
|
72
|
+
|
|
73
|
+
/** How the magnitude is rendered. `percent` appends `%`. */
|
|
74
|
+
export type DeltaFormat = 'percent' | 'absolute'
|
|
75
|
+
|
|
76
|
+
/** Whether the change reads as good, bad or neither. Never set directly — it
|
|
77
|
+
* is derived from `direction` and `invert`. */
|
|
78
|
+
type DeltaTone = 'positive' | 'negative' | 'neutral'
|
|
79
|
+
|
|
80
|
+
interface DeltaToneStyle {
|
|
81
|
+
background: string
|
|
82
|
+
border: string
|
|
83
|
+
color: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Frozen literal lookup — the same shape as `metric-card.tsx`'s `TONE_COLOR`.
|
|
88
|
+
* Never build these strings at runtime: a constructed custom-property name
|
|
89
|
+
* that misses produces invalid CSS which the browser silently discards, with
|
|
90
|
+
* every gate green (ADR-146 D8, ENGINEERING-AXES.md:186).
|
|
91
|
+
*/
|
|
92
|
+
/** Whether the direction is an outcome to be judged, or just a direction. */
|
|
93
|
+
export type DeltaSentiment = 'directional' | 'none'
|
|
94
|
+
|
|
95
|
+
const DELTA_TONE: Record<DeltaTone, DeltaToneStyle> = {
|
|
96
|
+
positive: {
|
|
97
|
+
background: 'rgb(var(--success-bg))',
|
|
98
|
+
border: '1px solid rgb(var(--success) / 0.30)',
|
|
99
|
+
color: 'rgb(var(--success-ink))',
|
|
100
|
+
},
|
|
101
|
+
negative: {
|
|
102
|
+
background: 'rgb(var(--destructive-bg))',
|
|
103
|
+
border: '1px solid rgb(var(--destructive) / 0.30)',
|
|
104
|
+
color: 'rgb(var(--destructive-ink))',
|
|
105
|
+
},
|
|
106
|
+
neutral: {
|
|
107
|
+
background: 'rgb(var(--muted))',
|
|
108
|
+
border: '1px solid rgb(var(--border))',
|
|
109
|
+
color: 'rgb(var(--text-secondary))',
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const DELTA_ICON: Record<DeltaDirection, LucideIcon> = {
|
|
114
|
+
up: ArrowUp,
|
|
115
|
+
down: ArrowDown,
|
|
116
|
+
flat: Minus,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Spoken direction, so the chip is not colour- or glyph-only for AT. */
|
|
120
|
+
const DELTA_WORD: Record<DeltaDirection, string> = {
|
|
121
|
+
up: 'Up',
|
|
122
|
+
down: 'Down',
|
|
123
|
+
flat: 'No change,',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Direction from the sign OF THE NUMBER WE PRINT, not of the raw input.
|
|
128
|
+
*
|
|
129
|
+
* The two disagree whenever a change rounds away at the displayed
|
|
130
|
+
* precision: `+0.04` at one decimal prints "0.0%", and taking direction
|
|
131
|
+
* from the raw sign shipped that as a GREEN UP ARROW beside a zero. The
|
|
132
|
+
* chip would be claiming a rise it is simultaneously reporting as none.
|
|
133
|
+
* Rounding first makes the glyph, the hue and the numeral agree — which is
|
|
134
|
+
* the whole point of carrying direction in three channels.
|
|
135
|
+
*
|
|
136
|
+
* Non-finite input (NaN from a division by zero, a missing denominator)
|
|
137
|
+
* reads as flat rather than throwing — the formatters render it as an
|
|
138
|
+
* em-dash, so the chip degrades to "no change, —".
|
|
139
|
+
*/
|
|
140
|
+
function directionOf(value: number, decimals: number): DeltaDirection {
|
|
141
|
+
if (!Number.isFinite(value)) return 'flat'
|
|
142
|
+
const shown = Number(value.toFixed(decimals))
|
|
143
|
+
if (shown === 0) return 'flat'
|
|
144
|
+
return shown > 0 ? 'up' : 'down'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toneOf(
|
|
148
|
+
direction: DeltaDirection,
|
|
149
|
+
invert: boolean,
|
|
150
|
+
sentiment: DeltaSentiment,
|
|
151
|
+
): DeltaTone {
|
|
152
|
+
if (direction === 'flat') return 'neutral'
|
|
153
|
+
// Some numbers move without either direction being an outcome. The arrow
|
|
154
|
+
// still reports WHICH way; the colour declines to judge it.
|
|
155
|
+
if (sentiment === 'none') return 'neutral'
|
|
156
|
+
const good = invert ? direction === 'down' : direction === 'up'
|
|
157
|
+
return good ? 'positive' : 'negative'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface DeltaChipProps {
|
|
161
|
+
/**
|
|
162
|
+
* The change itself. For `format: 'percent'` this is in PERCENT UNITS, not
|
|
163
|
+
* a 0-1 fraction — pass `12.5` for "12.5%" (ADR-146 D11; `formatPercent`
|
|
164
|
+
* takes percent units, `format.ts:57-68`). The sign drives the arrow; the
|
|
165
|
+
* chip renders the magnitude, so the sign is never printed twice.
|
|
166
|
+
*/
|
|
167
|
+
value: number
|
|
168
|
+
/** `'percent'` (default) appends `%`; `'absolute'` renders a plain count. */
|
|
169
|
+
format?: DeltaFormat
|
|
170
|
+
/**
|
|
171
|
+
* Force the arrow. Defaults to the sign of `value`. Override only when the
|
|
172
|
+
* caller knows something the sign does not — e.g. a rank where "moved from
|
|
173
|
+
* 4 to 2" is an improvement expressed as a negative number.
|
|
174
|
+
*/
|
|
175
|
+
direction?: DeltaDirection
|
|
176
|
+
/**
|
|
177
|
+
* `true` when DOWN is the good outcome — cost, CPA, CPC, spend, bounce
|
|
178
|
+
* rate, latency. Flips the colour only; the arrow still follows the sign.
|
|
179
|
+
*/
|
|
180
|
+
invert?: boolean
|
|
181
|
+
/**
|
|
182
|
+
* Whether the direction carries a good/bad judgement at all.
|
|
183
|
+
*
|
|
184
|
+
* `'directional'` (default) is the KPI case: up is good, or down is when
|
|
185
|
+
* `invert` is set. `'none'` renders both directions in the neutral tone —
|
|
186
|
+
* the arrow still reports which way the number moved, but the colour makes
|
|
187
|
+
* no claim about it.
|
|
188
|
+
*
|
|
189
|
+
* Use `'none'` for counts that simply vary and are attached to a PERSON —
|
|
190
|
+
* a contributor's monthly total, a volunteer's hours. Painting those
|
|
191
|
+
* `--destructive` states that someone failed, spends the error channel on
|
|
192
|
+
* something that is not an error, and is the regression that reverted this
|
|
193
|
+
* chip's first leaderboard consumer (ADR-146 amendment, phase audit H1).
|
|
194
|
+
*/
|
|
195
|
+
sentiment?: DeltaSentiment
|
|
196
|
+
/** Decimal places. Defaults to 1 for percent, 0 for absolute. */
|
|
197
|
+
decimals?: number
|
|
198
|
+
/**
|
|
199
|
+
* Character slot reserved for the numeral so a ticking value cannot resize
|
|
200
|
+
* the chip (design principles → Layout stability). Sized for the largest
|
|
201
|
+
* plausible value; the chip still grows past it rather than clipping.
|
|
202
|
+
* Default 5 — fits "12.5%" and "1,204".
|
|
203
|
+
*/
|
|
204
|
+
reserveChars?: number
|
|
205
|
+
/** Optional class override for the outer chip. */
|
|
206
|
+
className?: string
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function DeltaChip({
|
|
210
|
+
value,
|
|
211
|
+
format = 'percent',
|
|
212
|
+
direction,
|
|
213
|
+
invert = false,
|
|
214
|
+
sentiment = 'directional',
|
|
215
|
+
decimals,
|
|
216
|
+
reserveChars = 5,
|
|
217
|
+
className,
|
|
218
|
+
}: DeltaChipProps) {
|
|
219
|
+
const places = decimals ?? (format === 'percent' ? 1 : 0)
|
|
220
|
+
const dir = direction ?? directionOf(value, places)
|
|
221
|
+
const tone = DELTA_TONE[toneOf(dir, invert, sentiment)]
|
|
222
|
+
const Icon = DELTA_ICON[dir]
|
|
223
|
+
|
|
224
|
+
const magnitude = Math.abs(value)
|
|
225
|
+
// percent units, not a fraction — see `value` above.
|
|
226
|
+
const text =
|
|
227
|
+
format === 'percent'
|
|
228
|
+
? formatPercent(magnitude, { decimals: places })
|
|
229
|
+
: formatNumber(magnitude, { decimals: places })
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<span
|
|
233
|
+
className={cn(
|
|
234
|
+
'inline-flex items-center shrink-0 align-middle',
|
|
235
|
+
'text-[12px] font-semibold leading-none tabular-nums',
|
|
236
|
+
// Colour is the only thing that animates here, and a data change
|
|
237
|
+
// eases out — never springs (ADR-146 D9). Reduced motion drops it.
|
|
238
|
+
'transition-colors duration-[var(--dur-base)] ease-[var(--ease-out)]',
|
|
239
|
+
'motion-reduce:transition-none',
|
|
240
|
+
className,
|
|
241
|
+
)}
|
|
242
|
+
style={{
|
|
243
|
+
gap: 'var(--space-1)',
|
|
244
|
+
// 2px/4px in the source kit. 2px is not on the --space scale, so it
|
|
245
|
+
// is derived from it rather than written as a literal.
|
|
246
|
+
padding: 'calc(var(--space-1) / 2) var(--space-1)',
|
|
247
|
+
borderRadius: 'var(--radius-full)',
|
|
248
|
+
background: tone.background,
|
|
249
|
+
border: tone.border,
|
|
250
|
+
color: tone.color,
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<span className="sr-only">{DELTA_WORD[dir]}</span>
|
|
254
|
+
<Icon size={16} strokeWidth={2} aria-hidden="true" className="shrink-0" />
|
|
255
|
+
<span
|
|
256
|
+
className="text-center"
|
|
257
|
+
style={{ minWidth: `${reserveChars}ch` }}
|
|
258
|
+
>
|
|
259
|
+
{text}
|
|
260
|
+
</span>
|
|
261
|
+
</span>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPane — three tabs, ONE composer.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing test is the last one: the composer must be the SAME element
|
|
5
|
+
* before and after a tab change, including on Activity. Anything that mounts a
|
|
6
|
+
* composer per tab passes a "the composer is visible" assertion and still
|
|
7
|
+
* throws away what you were typing when you glance at the history.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { render, screen, within } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
import { ActivityPane } from '../activity-pane'
|
|
14
|
+
import type { ThreadComment } from '../../thread/types'
|
|
15
|
+
import type { ActivityEntry } from '../types'
|
|
16
|
+
|
|
17
|
+
const NOW = new Date(2026, 8, 15, 12, 0, 0).getTime()
|
|
18
|
+
|
|
19
|
+
const comments: ThreadComment[] = [
|
|
20
|
+
{
|
|
21
|
+
id: 'c1',
|
|
22
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
23
|
+
bodyMd: 'Looks right to me.',
|
|
24
|
+
createdAt: NOW - 60_000,
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const activity: ActivityEntry[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'a1',
|
|
31
|
+
at: NOW - 120_000,
|
|
32
|
+
actor: { id: 'u2', name: 'Grace Hopper' },
|
|
33
|
+
field: 'Status',
|
|
34
|
+
from: 'Building',
|
|
35
|
+
to: 'Review',
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function Host(props: { onSubmit?: (body: string) => void }) {
|
|
40
|
+
const [draft, setDraft] = useState('')
|
|
41
|
+
return (
|
|
42
|
+
<ActivityPane
|
|
43
|
+
comments={comments}
|
|
44
|
+
activity={activity}
|
|
45
|
+
draft={draft}
|
|
46
|
+
onDraftChange={setDraft}
|
|
47
|
+
onSubmit={props.onSubmit ?? (() => {})}
|
|
48
|
+
now={NOW}
|
|
49
|
+
locale="en-US"
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function composer(): HTMLElement {
|
|
55
|
+
return screen.getByRole('textbox', { name: 'Add a comment' })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function panel(name: string): HTMLElement {
|
|
59
|
+
const node = document.querySelector<HTMLElement>(`[role="tabpanel"][aria-label="${name}"]`)
|
|
60
|
+
if (node === null) throw new Error(`no ${name} panel`)
|
|
61
|
+
return node
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('ActivityPane', () => {
|
|
65
|
+
it('opens on Everything with both kinds of entry, in time order', () => {
|
|
66
|
+
render(<Host />)
|
|
67
|
+
const everything = panel('Everything')
|
|
68
|
+
expect(everything).toBeVisible()
|
|
69
|
+
expect(everything).toHaveTextContent('Grace Hopper')
|
|
70
|
+
expect(everything).toHaveTextContent('changed Status from Building to Review')
|
|
71
|
+
expect(everything).toHaveTextContent('Looks right to me.')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('shows only the history on Activity', async () => {
|
|
75
|
+
render(<Host />)
|
|
76
|
+
await userEvent.click(screen.getByRole('tab', { name: /Activity/ }))
|
|
77
|
+
expect(panel('Activity')).toBeVisible()
|
|
78
|
+
expect(panel('Everything')).not.toBeVisible()
|
|
79
|
+
expect(panel('Activity')).not.toHaveTextContent('Looks right to me.')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('draws exactly one composer, on every tab, and keeps it mounted', async () => {
|
|
83
|
+
render(<Host />)
|
|
84
|
+
const first = composer()
|
|
85
|
+
await userEvent.type(first, 'half a thought')
|
|
86
|
+
|
|
87
|
+
for (const tab of ['Comments', 'Activity', 'Everything']) {
|
|
88
|
+
await userEvent.click(screen.getByRole('tab', { name: new RegExp(tab) }))
|
|
89
|
+
expect(screen.getAllByRole('textbox', { name: 'Add a comment' })).toHaveLength(1)
|
|
90
|
+
// The SAME node, with what was typed into it still there.
|
|
91
|
+
expect(composer()).toBe(first)
|
|
92
|
+
expect(first).toHaveValue('half a thought')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('submits the draft as a root comment', async () => {
|
|
97
|
+
const onSubmit = vi.fn()
|
|
98
|
+
render(<Host onSubmit={onSubmit} />)
|
|
99
|
+
await userEvent.type(composer(), 'Shipping this')
|
|
100
|
+
await userEvent.click(screen.getByRole('button', { name: 'Comment' }))
|
|
101
|
+
expect(onSubmit).toHaveBeenCalledWith('Shipping this')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('speaks in the interface voice when a tab is empty', async () => {
|
|
105
|
+
render(
|
|
106
|
+
<ActivityPane
|
|
107
|
+
comments={[]}
|
|
108
|
+
activity={[]}
|
|
109
|
+
draft=""
|
|
110
|
+
onDraftChange={() => {}}
|
|
111
|
+
onSubmit={() => {}}
|
|
112
|
+
now={NOW}
|
|
113
|
+
/>,
|
|
114
|
+
)
|
|
115
|
+
expect(panel('Everything')).toHaveTextContent(
|
|
116
|
+
'Comments and changes to this record show up together.',
|
|
117
|
+
)
|
|
118
|
+
await userEvent.click(screen.getByRole('tab', { name: /Activity/ }))
|
|
119
|
+
expect(panel('Activity')).toHaveTextContent('Every change to this record is recorded here.')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A host with no comment backend — Lightwork, and anything else that wants a
|
|
125
|
+
* history feed without a conversation.
|
|
126
|
+
*
|
|
127
|
+
* The pane used to REQUIRE `comments`, `draft`, `onDraftChange` and `onSubmit`,
|
|
128
|
+
* which meant a host with nowhere to post had two bad options: pass no-op
|
|
129
|
+
* handlers and ship a composer that silently discards what you type, or fork
|
|
130
|
+
* the file. One consumer did fork it — 77% duplicated — which is what these
|
|
131
|
+
* props exist to make unnecessary.
|
|
132
|
+
*/
|
|
133
|
+
describe('without a comment backend', () => {
|
|
134
|
+
const ACTIVITY = [
|
|
135
|
+
{ id: 'a1', at: 1_700_000_000_000, field: 'Status', from: 'Planned', to: 'Live' },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
it('drops the Comments tab rather than rendering it empty', () => {
|
|
139
|
+
render(<ActivityPane activity={ACTIVITY} />)
|
|
140
|
+
expect(screen.getByRole('tab', { name: /everything/i })).toBeInTheDocument()
|
|
141
|
+
expect(screen.getByRole('tab', { name: /activity/i })).toBeInTheDocument()
|
|
142
|
+
expect(screen.queryByRole('tab', { name: /comments/i })).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('draws no composer — there is nowhere to post', () => {
|
|
146
|
+
render(<ActivityPane activity={ACTIVITY} />)
|
|
147
|
+
expect(screen.queryByRole('textbox')).toBeNull()
|
|
148
|
+
expect(document.querySelector('[data-slot="activity-composer"]')).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('still shows the history', () => {
|
|
152
|
+
render(<ActivityPane activity={ACTIVITY} />)
|
|
153
|
+
// Every panel stays MOUNTED and is hidden rather than unmounted (so a
|
|
154
|
+
// scroll position and a half-read feed survive a tab change), so the same
|
|
155
|
+
// entry legitimately appears twice in the DOM. Scope to the visible one.
|
|
156
|
+
const shown = document.querySelector('[role="tabpanel"]:not([hidden])')
|
|
157
|
+
expect(shown).not.toBeNull()
|
|
158
|
+
expect(within(shown as HTMLElement).getByText(/Planned/)).toBeInTheDocument()
|
|
159
|
+
expect(within(shown as HTMLElement).getByText(/Live/)).toBeInTheDocument()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('never lands on a tab it does not render', () => {
|
|
163
|
+
// `comments` as the requested tab, with no backend: the pane must fall
|
|
164
|
+
// back rather than show a panel that was never mounted.
|
|
165
|
+
render(<ActivityPane activity={ACTIVITY} defaultTab="comments" />)
|
|
166
|
+
const selected = screen
|
|
167
|
+
.getAllByRole('tab')
|
|
168
|
+
.filter((t) => t.getAttribute('aria-selected') === 'true')
|
|
169
|
+
expect(selected).toHaveLength(1)
|
|
170
|
+
expect(selected[0]).toHaveTextContent(/everything/i)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('is unchanged for a host that DOES supply a backend', () => {
|
|
174
|
+
render(
|
|
175
|
+
<ActivityPane
|
|
176
|
+
activity={ACTIVITY}
|
|
177
|
+
comments={[]}
|
|
178
|
+
draft=""
|
|
179
|
+
onDraftChange={() => {}}
|
|
180
|
+
onSubmit={() => {}}
|
|
181
|
+
/>,
|
|
182
|
+
)
|
|
183
|
+
expect(screen.getByRole('tab', { name: /comments/i })).toBeInTheDocument()
|
|
184
|
+
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DetailHeader and DetailMenu — the slots, and the rule that governs them.
|
|
3
|
+
*
|
|
4
|
+
* §7 names three controls with no backing system, and the owner's standing
|
|
5
|
+
* rule is that a rendered control does what it says. Both components express
|
|
6
|
+
* that the same way: a slot the host does not fill is not drawn. These tests
|
|
7
|
+
* assert the ABSENCE, because a dead control is exactly the kind of regression
|
|
8
|
+
* that no other check catches.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { render, screen } from '@testing-library/react'
|
|
12
|
+
import userEvent from '@testing-library/user-event'
|
|
13
|
+
import { DetailHeader } from '../detail-header'
|
|
14
|
+
import { DetailMenu } from '../detail-menu'
|
|
15
|
+
|
|
16
|
+
const noop = () => {}
|
|
17
|
+
const NOW = new Date(2026, 8, 15, 12, 0, 0).getTime()
|
|
18
|
+
|
|
19
|
+
describe('DetailHeader', () => {
|
|
20
|
+
it('draws nothing for a slot the host did not fill', () => {
|
|
21
|
+
render(<DetailHeader title="Slice B" onClose={noop} />)
|
|
22
|
+
expect(screen.queryByRole('button', { name: /complete/i })).toBeNull()
|
|
23
|
+
expect(screen.queryByRole('button', { name: /favourite/i })).toBeNull()
|
|
24
|
+
expect(screen.queryByRole('button', { name: /Previous item/ })).toBeNull()
|
|
25
|
+
expect(document.querySelector('[data-slot="detail-reminder"]')).toBeNull()
|
|
26
|
+
expect(document.querySelector('[data-slot="detail-menu-slot"]')).toBeNull()
|
|
27
|
+
// A title with no change handler is a heading, not a field.
|
|
28
|
+
expect(screen.getByRole('heading', { name: 'Slice B' })).toBeInTheDocument()
|
|
29
|
+
expect(screen.queryByRole('textbox')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('reflects completion in the label, not only in colour', async () => {
|
|
33
|
+
const onCompleteChange = vi.fn()
|
|
34
|
+
const view = render(
|
|
35
|
+
<DetailHeader
|
|
36
|
+
title="Slice B"
|
|
37
|
+
onClose={noop}
|
|
38
|
+
complete={false}
|
|
39
|
+
onCompleteChange={onCompleteChange}
|
|
40
|
+
/>,
|
|
41
|
+
)
|
|
42
|
+
const toggle = screen.getByRole('button', { name: 'Mark complete' })
|
|
43
|
+
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
|
44
|
+
await userEvent.click(toggle)
|
|
45
|
+
expect(onCompleteChange).toHaveBeenCalledWith(true)
|
|
46
|
+
|
|
47
|
+
view.rerender(
|
|
48
|
+
<DetailHeader title="Slice B" onClose={noop} complete onCompleteChange={onCompleteChange} />,
|
|
49
|
+
)
|
|
50
|
+
expect(screen.getByRole('button', { name: 'Completed' })).toHaveAttribute(
|
|
51
|
+
'aria-pressed',
|
|
52
|
+
'true',
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('disables the step at the end it has no handler for', () => {
|
|
57
|
+
render(<DetailHeader title="Slice B" onClose={noop} onNext={noop} />)
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Previous item' })).toBeDisabled()
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Next item' })).toBeEnabled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('edits the title in place, committing on Enter', async () => {
|
|
63
|
+
const onTitleChange = vi.fn()
|
|
64
|
+
const onTitleCommit = vi.fn()
|
|
65
|
+
render(
|
|
66
|
+
<DetailHeader
|
|
67
|
+
title="Slice B"
|
|
68
|
+
onClose={noop}
|
|
69
|
+
onTitleChange={onTitleChange}
|
|
70
|
+
onTitleCommit={onTitleCommit}
|
|
71
|
+
/>,
|
|
72
|
+
)
|
|
73
|
+
const field = screen.getByRole('textbox', { name: 'Item title' })
|
|
74
|
+
await userEvent.type(field, '!{Enter}')
|
|
75
|
+
expect(onTitleChange).toHaveBeenCalledWith('Slice B!')
|
|
76
|
+
expect(onTitleCommit).toHaveBeenCalledTimes(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reads out when the item was updated', () => {
|
|
80
|
+
render(<DetailHeader title="Slice B" onClose={noop} updatedAt={NOW - 3_600_000} now={NOW} />)
|
|
81
|
+
expect(document.querySelector('[data-slot="detail-updated"]')).toHaveTextContent('Updated 1h')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('DetailMenu', () => {
|
|
86
|
+
it('renders only the actions the host supplied, in the brief order', async () => {
|
|
87
|
+
render(
|
|
88
|
+
<DetailMenu
|
|
89
|
+
mode="full"
|
|
90
|
+
onModeChange={noop}
|
|
91
|
+
actions={{ move: { onSelect: noop }, print: { onSelect: noop }, delete: { onSelect: noop } }}
|
|
92
|
+
/>,
|
|
93
|
+
)
|
|
94
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
95
|
+
const items = screen.getAllByRole('menuitem').map((item) => item.dataset['action'])
|
|
96
|
+
expect(items).toEqual(['move', 'print', 'delete'])
|
|
97
|
+
// Repeat has no scheduling engine behind it (§7), so it is not drawn.
|
|
98
|
+
expect(screen.queryByRole('menuitem', { name: /Repeat/ })).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('offers the three modes, with the current one pressed', async () => {
|
|
102
|
+
const onModeChange = vi.fn()
|
|
103
|
+
render(<DetailMenu mode="panel" onModeChange={onModeChange} />)
|
|
104
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
105
|
+
expect(screen.getByRole('button', { name: 'Open in side panel' })).toHaveAttribute(
|
|
106
|
+
'aria-pressed',
|
|
107
|
+
'true',
|
|
108
|
+
)
|
|
109
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open full screen' }))
|
|
110
|
+
expect(onModeChange).toHaveBeenCalledWith('full')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('asks twice before deleting', async () => {
|
|
114
|
+
const onDelete = vi.fn()
|
|
115
|
+
render(
|
|
116
|
+
<DetailMenu mode="full" onModeChange={noop} actions={{ delete: { onSelect: onDelete } }} />,
|
|
117
|
+
)
|
|
118
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
119
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
|
120
|
+
expect(onDelete).not.toHaveBeenCalled()
|
|
121
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Confirm delete' }))
|
|
122
|
+
expect(onDelete).toHaveBeenCalledTimes(1)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('offers the record id to the clipboard', async () => {
|
|
126
|
+
const onCopy = vi.fn()
|
|
127
|
+
render(
|
|
128
|
+
<DetailMenu mode="full" onModeChange={noop} recordId="itm_123" onCopyRecordId={onCopy} />,
|
|
129
|
+
)
|
|
130
|
+
await userEvent.click(screen.getByRole('button', { name: 'More actions' }))
|
|
131
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Copy record id itm_123' }))
|
|
132
|
+
expect(onCopy).toHaveBeenCalledWith('itm_123')
|
|
133
|
+
})
|
|
134
|
+
})
|