@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,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,369 @@
|
|
|
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 { cleanup, 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
|
+
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* FU-0019 — the pane is a conduit, and it used to be a leaky one.
|
|
190
|
+
*
|
|
191
|
+
* `ActivityPaneProps` declared `onSubmit` and `onReply` and stopped, so three
|
|
192
|
+
* mutations `<Thread>` already accepted could not be reached through the pane
|
|
193
|
+
* at all. Nothing errored, which is why it survived a release: `CommentItem`
|
|
194
|
+
* draws no control for a callback it was not given, so a host shipped an inert
|
|
195
|
+
* engagement row and a one-line overflow menu while the routes behind them had
|
|
196
|
+
* no caller anywhere in the browser.
|
|
197
|
+
*
|
|
198
|
+
* These assert at the CONTROL, never at the prop. A prop that arrives and
|
|
199
|
+
* lands nowhere is the same bug one layer further in.
|
|
200
|
+
*/
|
|
201
|
+
describe('ActivityPane — forwarding the thread callbacks', () => {
|
|
202
|
+
const WIRED: readonly ThreadComment[] = [
|
|
203
|
+
{
|
|
204
|
+
id: 'c1',
|
|
205
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
206
|
+
bodyMd: 'Looks right to me.',
|
|
207
|
+
createdAt: NOW - 60_000,
|
|
208
|
+
reactions: [{ key: 'up', count: 7, viewerReacted: true }],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'c-failed',
|
|
212
|
+
author: { id: 'u2', name: 'Grace Hopper' },
|
|
213
|
+
bodyMd: 'This one never left the device.',
|
|
214
|
+
createdAt: NOW - 30_000,
|
|
215
|
+
state: 'failed',
|
|
216
|
+
errorText: 'This comment did not send.',
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
/** Mounts the pane and opens Comments, which is where `<Thread>` lives. */
|
|
221
|
+
async function comments(
|
|
222
|
+
props: Partial<React.ComponentProps<typeof ActivityPane>> = {},
|
|
223
|
+
): Promise<HTMLElement> {
|
|
224
|
+
render(
|
|
225
|
+
<ActivityPane
|
|
226
|
+
comments={WIRED}
|
|
227
|
+
activity={activity}
|
|
228
|
+
draft=""
|
|
229
|
+
onDraftChange={() => {}}
|
|
230
|
+
onSubmit={() => {}}
|
|
231
|
+
now={NOW}
|
|
232
|
+
locale="en-US"
|
|
233
|
+
{...props}
|
|
234
|
+
/>,
|
|
235
|
+
)
|
|
236
|
+
await userEvent.click(screen.getByRole('tab', { name: /Comments/ }))
|
|
237
|
+
return panel('Comments')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
it('reports a reaction key from the chip the thread drew', async () => {
|
|
241
|
+
const onReact = vi.fn()
|
|
242
|
+
const shown = await comments({ onReact })
|
|
243
|
+
await userEvent.click(within(shown).getByRole('button', { name: 'Agree — 7' }))
|
|
244
|
+
expect(onReact).toHaveBeenCalledWith('c1', 'up')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('offers a picker, so a reaction can come into existence at all', async () => {
|
|
248
|
+
const shown = await comments({ onReact: vi.fn() })
|
|
249
|
+
expect(within(shown).getByRole('button', { name: 'Add a reaction' })).toBeInTheDocument()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('puts Edit and Delete in the overflow menu and reports the comment id', async () => {
|
|
253
|
+
const onEdit = vi.fn()
|
|
254
|
+
const onDelete = vi.fn()
|
|
255
|
+
const shown = await comments({ onEdit, onDelete })
|
|
256
|
+
await userEvent.click(
|
|
257
|
+
within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
258
|
+
)
|
|
259
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' }))
|
|
260
|
+
expect(onEdit).toHaveBeenCalledWith('c1')
|
|
261
|
+
|
|
262
|
+
await userEvent.click(
|
|
263
|
+
within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
264
|
+
)
|
|
265
|
+
const remove = screen.getByRole('menuitem', { name: 'Delete' })
|
|
266
|
+
expect(remove.getAttribute('data-variant')).toBe('destructive')
|
|
267
|
+
await userEvent.click(remove)
|
|
268
|
+
expect(onDelete).toHaveBeenCalledWith('c1')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('recovers a send that failed in its OWN composer', async () => {
|
|
272
|
+
const onRetry = vi.fn()
|
|
273
|
+
const shown = await comments({ onRetry })
|
|
274
|
+
await userEvent.click(within(shown).getByRole('button', { name: 'Try again' }))
|
|
275
|
+
expect(onRetry).toHaveBeenCalledWith('c-failed')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('draws none of them for a host that wires none — the 0.1.0 render', async () => {
|
|
279
|
+
const shown = await comments()
|
|
280
|
+
// What FU-0019 measured in a browser: a written reaction is readable and
|
|
281
|
+
// inert, and there is no picker, so a reaction can never be created.
|
|
282
|
+
expect(within(shown).getByRole('button', { name: 'Agree — 7' })).toBeDisabled()
|
|
283
|
+
expect(within(shown).queryByRole('button', { name: 'Add a reaction' })).toBeNull()
|
|
284
|
+
expect(within(shown).queryByRole('button', { name: 'Try again' })).toBeNull()
|
|
285
|
+
// The failure itself is still announced; only the recovery is missing.
|
|
286
|
+
expect(within(shown).getByText('This comment did not send.')).toBeInTheDocument()
|
|
287
|
+
// And no overflow menu at all: with no Edit, no Delete, no Copy link and
|
|
288
|
+
// no clipboard under jsdom, `CommentActions` has nothing to put in one and
|
|
289
|
+
// renders none. In a real browser it holds exactly one line — "Copy text",
|
|
290
|
+
// the single item that needs no callback — which is what FU-0019 measured.
|
|
291
|
+
expect(
|
|
292
|
+
within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
293
|
+
).toBeNull()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* FU-0032 — the Everything feed draws its own tombstone, and it has to agree
|
|
299
|
+
* with the one the Comments tab draws for the same comment.
|
|
300
|
+
*/
|
|
301
|
+
describe('ActivityPane — a moderated comment in the merged feed', () => {
|
|
302
|
+
// One tombstone, two removals. Derived from a single base so the ONLY
|
|
303
|
+
// difference between the two renders is the field under test.
|
|
304
|
+
const BASE: ThreadComment = {
|
|
305
|
+
id: 'c-mod',
|
|
306
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
307
|
+
bodyMd: '',
|
|
308
|
+
createdAt: NOW - 60_000,
|
|
309
|
+
deletedAt: NOW - 30_000,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const REMOVED: readonly ThreadComment[] = [{ ...BASE, moderated: true }]
|
|
313
|
+
const SELF: readonly ThreadComment[] = [{ ...BASE, id: 'c-self', moderated: false }]
|
|
314
|
+
|
|
315
|
+
function feed(list: readonly ThreadComment[]): HTMLElement {
|
|
316
|
+
render(
|
|
317
|
+
<ActivityPane
|
|
318
|
+
comments={list}
|
|
319
|
+
activity={[]}
|
|
320
|
+
draft=""
|
|
321
|
+
onDraftChange={() => {}}
|
|
322
|
+
onSubmit={() => {}}
|
|
323
|
+
now={NOW}
|
|
324
|
+
/>,
|
|
325
|
+
)
|
|
326
|
+
return panel('Everything')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
it('says an admin removed it', () => {
|
|
330
|
+
expect(feed(REMOVED)).toHaveTextContent('Comment removed by an admin')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('still says "Comment deleted" for an author\'s own delete', () => {
|
|
334
|
+
const shown = feed(SELF)
|
|
335
|
+
expect(shown).toHaveTextContent('Comment deleted')
|
|
336
|
+
expect(shown).not.toHaveTextContent('removed')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The feed WITHHOLDS the author of a tombstone, exactly as the thread does.
|
|
341
|
+
*
|
|
342
|
+
* Found in review. `FeedLine` was given `author` unconditionally, so the
|
|
343
|
+
* Everything tab printed "Ada Lovelace commented" beside "Comment removed by
|
|
344
|
+
* an admin" while the Comments tab one click away replaced her byline with
|
|
345
|
+
* `[removed]` and dropped her avatar to `?`. The same comment answered "who
|
|
346
|
+
* wrote this" two different ways depending on which tab you were on.
|
|
347
|
+
*
|
|
348
|
+
* It was harmless while the line read "Comment deleted" — a deletion nobody
|
|
349
|
+
* had to account for. FU-0032 is what made it matter: a name beside "removed
|
|
350
|
+
* by an admin" is a per-author public record of who got moderated, readable
|
|
351
|
+
* by everyone in the workspace, and it is the one fact the thread render
|
|
352
|
+
* deliberately refuses to publish.
|
|
353
|
+
*
|
|
354
|
+
* Asserted for BOTH removals, because the leak was never specific to
|
|
355
|
+
* moderation — only its consequence was.
|
|
356
|
+
*/
|
|
357
|
+
it('withholds the author of a tombstone, on both kinds of removal', () => {
|
|
358
|
+
expect(feed(REMOVED)).not.toHaveTextContent('Ada Lovelace')
|
|
359
|
+
cleanup()
|
|
360
|
+
expect(feed(SELF)).not.toHaveTextContent('Ada Lovelace')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('still names the author of a comment that is NOT a tombstone', () => {
|
|
364
|
+
const live: readonly ThreadComment[] = [
|
|
365
|
+
{ ...BASE, id: 'c-live', deletedAt: null, bodyMd: 'Still here.' },
|
|
366
|
+
]
|
|
367
|
+
expect(feed(live)).toHaveTextContent('Ada Lovelace')
|
|
368
|
+
})
|
|
369
|
+
})
|