@vaam-apps/ui 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/CHANGELOG.md +104 -0
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/dist/components/data/code.d.ts +37 -0
- package/dist/components/data/code.d.ts.map +1 -0
- package/dist/components/data/code.js +37 -0
- package/dist/components/data/code.js.map +1 -0
- package/dist/components/data/copy-button.d.ts +39 -0
- package/dist/components/data/copy-button.d.ts.map +1 -0
- package/dist/components/data/copy-button.js +50 -0
- package/dist/components/data/copy-button.js.map +1 -0
- package/dist/components/data/detail-row.d.ts +69 -0
- package/dist/components/data/detail-row.d.ts.map +1 -0
- package/dist/components/data/detail-row.js +27 -0
- package/dist/components/data/detail-row.js.map +1 -0
- package/dist/components/data/id-display.d.ts +33 -0
- package/dist/components/data/id-display.d.ts.map +1 -0
- package/dist/components/data/id-display.js +25 -0
- package/dist/components/data/id-display.js.map +1 -0
- package/dist/components/data/masked-value.d.ts +47 -0
- package/dist/components/data/masked-value.d.ts.map +1 -0
- package/dist/components/data/masked-value.js +46 -0
- package/dist/components/data/masked-value.js.map +1 -0
- package/dist/components/data/money.d.ts +28 -0
- package/dist/components/data/money.d.ts.map +1 -0
- package/dist/components/data/money.js +22 -0
- package/dist/components/data/money.js.map +1 -0
- package/dist/components/data/phone-display.d.ts +45 -0
- package/dist/components/data/phone-display.d.ts.map +1 -0
- package/dist/components/data/phone-display.js +23 -0
- package/dist/components/data/phone-display.js.map +1 -0
- package/dist/components/data/stat-tile.d.ts +33 -0
- package/dist/components/data/stat-tile.d.ts.map +1 -0
- package/dist/components/data/stat-tile.js +18 -0
- package/dist/components/data/stat-tile.js.map +1 -0
- package/dist/components/data/timestamp-display.d.ts +14 -0
- package/dist/components/data/timestamp-display.d.ts.map +1 -0
- package/dist/components/data/timestamp-display.js +77 -0
- package/dist/components/data/timestamp-display.js.map +1 -0
- package/dist/components/patterns/inline-banner.d.ts +22 -0
- package/dist/components/patterns/inline-banner.d.ts.map +1 -0
- package/dist/components/patterns/inline-banner.js +11 -0
- package/dist/components/patterns/inline-banner.js.map +1 -0
- package/dist/components/patterns/inline-empty-state.d.ts +20 -0
- package/dist/components/patterns/inline-empty-state.d.ts.map +1 -0
- package/dist/components/patterns/inline-empty-state.js +11 -0
- package/dist/components/patterns/inline-empty-state.js.map +1 -0
- package/dist/components/patterns/live-row.d.ts +23 -0
- package/dist/components/patterns/live-row.d.ts.map +1 -0
- package/dist/components/patterns/live-row.js +48 -0
- package/dist/components/patterns/live-row.js.map +1 -0
- package/dist/components/patterns/payload-inspector.d.ts +25 -0
- package/dist/components/patterns/payload-inspector.d.ts.map +1 -0
- package/dist/components/patterns/payload-inspector.js +33 -0
- package/dist/components/patterns/payload-inspector.js.map +1 -0
- package/dist/components/patterns/route-skeleton.d.ts +15 -0
- package/dist/components/patterns/route-skeleton.d.ts.map +1 -0
- package/dist/components/patterns/route-skeleton.js +7 -0
- package/dist/components/patterns/route-skeleton.js.map +1 -0
- package/dist/components/patterns/screen-layout.d.ts +14 -0
- package/dist/components/patterns/screen-layout.d.ts.map +1 -0
- package/dist/components/patterns/screen-layout.js +11 -0
- package/dist/components/patterns/screen-layout.js.map +1 -0
- package/dist/components/patterns/stale-write-banner.d.ts +26 -0
- package/dist/components/patterns/stale-write-banner.d.ts.map +1 -0
- package/dist/components/patterns/stale-write-banner.js +28 -0
- package/dist/components/patterns/stale-write-banner.js.map +1 -0
- package/dist/components/patterns/state-timeline.d.ts +58 -0
- package/dist/components/patterns/state-timeline.d.ts.map +1 -0
- package/dist/components/patterns/state-timeline.js +103 -0
- package/dist/components/patterns/state-timeline.js.map +1 -0
- package/dist/components/primitives/badge.d.ts +27 -0
- package/dist/components/primitives/badge.d.ts.map +1 -0
- package/dist/components/primitives/badge.js +33 -0
- package/dist/components/primitives/badge.js.map +1 -0
- package/dist/components/primitives/button.d.ts +12 -0
- package/dist/components/primitives/button.d.ts.map +1 -0
- package/dist/components/primitives/button.js +42 -0
- package/dist/components/primitives/button.js.map +1 -0
- package/dist/components/primitives/calendar.d.ts +39 -0
- package/dist/components/primitives/calendar.d.ts.map +1 -0
- package/dist/components/primitives/calendar.js +102 -0
- package/dist/components/primitives/calendar.js.map +1 -0
- package/dist/components/primitives/card.d.ts +24 -0
- package/dist/components/primitives/card.d.ts.map +1 -0
- package/dist/components/primitives/card.js +28 -0
- package/dist/components/primitives/card.js.map +1 -0
- package/dist/components/primitives/checkbox.d.ts +42 -0
- package/dist/components/primitives/checkbox.d.ts.map +1 -0
- package/dist/components/primitives/checkbox.js +31 -0
- package/dist/components/primitives/checkbox.js.map +1 -0
- package/dist/components/primitives/chip-select.d.ts +38 -0
- package/dist/components/primitives/chip-select.d.ts.map +1 -0
- package/dist/components/primitives/chip-select.js +25 -0
- package/dist/components/primitives/chip-select.js.map +1 -0
- package/dist/components/primitives/command-menu.d.ts +54 -0
- package/dist/components/primitives/command-menu.d.ts.map +1 -0
- package/dist/components/primitives/command-menu.js +24 -0
- package/dist/components/primitives/command-menu.js.map +1 -0
- package/dist/components/primitives/confirm-dialog.d.ts +52 -0
- package/dist/components/primitives/confirm-dialog.d.ts.map +1 -0
- package/dist/components/primitives/confirm-dialog.js +30 -0
- package/dist/components/primitives/confirm-dialog.js.map +1 -0
- package/dist/components/primitives/date-picker.d.ts +68 -0
- package/dist/components/primitives/date-picker.d.ts.map +1 -0
- package/dist/components/primitives/date-picker.js +107 -0
- package/dist/components/primitives/date-picker.js.map +1 -0
- package/dist/components/primitives/dialog.d.ts +50 -0
- package/dist/components/primitives/dialog.d.ts.map +1 -0
- package/dist/components/primitives/dialog.js +90 -0
- package/dist/components/primitives/dialog.js.map +1 -0
- package/dist/components/primitives/drawer.d.ts +71 -0
- package/dist/components/primitives/drawer.d.ts.map +1 -0
- package/dist/components/primitives/drawer.js +93 -0
- package/dist/components/primitives/drawer.js.map +1 -0
- package/dist/components/primitives/dropdown-menu.d.ts +40 -0
- package/dist/components/primitives/dropdown-menu.d.ts.map +1 -0
- package/dist/components/primitives/dropdown-menu.js +53 -0
- package/dist/components/primitives/dropdown-menu.js.map +1 -0
- package/dist/components/primitives/form-field.d.ts +94 -0
- package/dist/components/primitives/form-field.d.ts.map +1 -0
- package/dist/components/primitives/form-field.js +27 -0
- package/dist/components/primitives/form-field.js.map +1 -0
- package/dist/components/primitives/inline-confirm.d.ts +62 -0
- package/dist/components/primitives/inline-confirm.d.ts.map +1 -0
- package/dist/components/primitives/inline-confirm.js +11 -0
- package/dist/components/primitives/inline-confirm.js.map +1 -0
- package/dist/components/primitives/input.d.ts +4 -0
- package/dist/components/primitives/input.d.ts.map +1 -0
- package/dist/components/primitives/input.js +13 -0
- package/dist/components/primitives/input.js.map +1 -0
- package/dist/components/primitives/label.d.ts +26 -0
- package/dist/components/primitives/label.d.ts.map +1 -0
- package/dist/components/primitives/label.js +33 -0
- package/dist/components/primitives/label.js.map +1 -0
- package/dist/components/primitives/pagination.d.ts +54 -0
- package/dist/components/primitives/pagination.d.ts.map +1 -0
- package/dist/components/primitives/pagination.js +31 -0
- package/dist/components/primitives/pagination.js.map +1 -0
- package/dist/components/primitives/popover.d.ts +12 -0
- package/dist/components/primitives/popover.d.ts.map +1 -0
- package/dist/components/primitives/popover.js +22 -0
- package/dist/components/primitives/popover.js.map +1 -0
- package/dist/components/primitives/progress.d.ts +25 -0
- package/dist/components/primitives/progress.d.ts.map +1 -0
- package/dist/components/primitives/progress.js +25 -0
- package/dist/components/primitives/progress.js.map +1 -0
- package/dist/components/primitives/radio-group.d.ts +47 -0
- package/dist/components/primitives/radio-group.d.ts.map +1 -0
- package/dist/components/primitives/radio-group.js +11 -0
- package/dist/components/primitives/radio-group.js.map +1 -0
- package/dist/components/primitives/select.d.ts +39 -0
- package/dist/components/primitives/select.d.ts.map +1 -0
- package/dist/components/primitives/select.js +142 -0
- package/dist/components/primitives/select.js.map +1 -0
- package/dist/components/primitives/separator.d.ts +9 -0
- package/dist/components/primitives/separator.d.ts.map +1 -0
- package/dist/components/primitives/separator.js +16 -0
- package/dist/components/primitives/separator.js.map +1 -0
- package/dist/components/primitives/side-nav.d.ts +65 -0
- package/dist/components/primitives/side-nav.d.ts.map +1 -0
- package/dist/components/primitives/side-nav.js +100 -0
- package/dist/components/primitives/side-nav.js.map +1 -0
- package/dist/components/primitives/skeleton.d.ts +22 -0
- package/dist/components/primitives/skeleton.d.ts.map +1 -0
- package/dist/components/primitives/skeleton.js +25 -0
- package/dist/components/primitives/skeleton.js.map +1 -0
- package/dist/components/primitives/spinner.d.ts +20 -0
- package/dist/components/primitives/spinner.d.ts.map +1 -0
- package/dist/components/primitives/spinner.js +21 -0
- package/dist/components/primitives/spinner.js.map +1 -0
- package/dist/components/primitives/switch.d.ts +29 -0
- package/dist/components/primitives/switch.d.ts.map +1 -0
- package/dist/components/primitives/switch.js +24 -0
- package/dist/components/primitives/switch.js.map +1 -0
- package/dist/components/primitives/table.d.ts +23 -0
- package/dist/components/primitives/table.d.ts.map +1 -0
- package/dist/components/primitives/table.js +81 -0
- package/dist/components/primitives/table.js.map +1 -0
- package/dist/components/primitives/tabs.d.ts +38 -0
- package/dist/components/primitives/tabs.d.ts.map +1 -0
- package/dist/components/primitives/tabs.js +84 -0
- package/dist/components/primitives/tabs.js.map +1 -0
- package/dist/components/primitives/textarea.d.ts +4 -0
- package/dist/components/primitives/textarea.d.ts.map +1 -0
- package/dist/components/primitives/textarea.js +11 -0
- package/dist/components/primitives/textarea.js.map +1 -0
- package/dist/components/primitives/toast.d.ts +24 -0
- package/dist/components/primitives/toast.d.ts.map +1 -0
- package/dist/components/primitives/toast.js +58 -0
- package/dist/components/primitives/toast.js.map +1 -0
- package/dist/components/primitives/tooltip.d.ts +30 -0
- package/dist/components/primitives/tooltip.d.ts.map +1 -0
- package/dist/components/primitives/tooltip.js +6 -0
- package/dist/components/primitives/tooltip.js.map +1 -0
- package/dist/components/status/state-chip.d.ts +53 -0
- package/dist/components/status/state-chip.d.ts.map +1 -0
- package/dist/components/status/state-chip.js +8 -0
- package/dist/components/status/state-chip.js.map +1 -0
- package/dist/components/status/state-mark.d.ts +21 -0
- package/dist/components/status/state-mark.d.ts.map +1 -0
- package/dist/components/status/state-mark.js +90 -0
- package/dist/components/status/state-mark.js.map +1 -0
- package/dist/components/status/status-pill.d.ts +62 -0
- package/dist/components/status/status-pill.d.ts.map +1 -0
- package/dist/components/status/status-pill.js +65 -0
- package/dist/components/status/status-pill.js.map +1 -0
- package/dist/components/status/status-tokens.d.ts +110 -0
- package/dist/components/status/status-tokens.d.ts.map +1 -0
- package/dist/components/status/status-tokens.js +102 -0
- package/dist/components/status/status-tokens.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cn.d.ts +11 -0
- package/dist/lib/cn.d.ts.map +1 -0
- package/dist/lib/cn.js +78 -0
- package/dist/lib/cn.js.map +1 -0
- package/dist/lib/money.d.ts +73 -0
- package/dist/lib/money.d.ts.map +1 -0
- package/dist/lib/money.js +123 -0
- package/dist/lib/money.js.map +1 -0
- package/dist/lib/omit-undefined.d.ts +17 -0
- package/dist/lib/omit-undefined.d.ts.map +1 -0
- package/dist/lib/omit-undefined.js +24 -0
- package/dist/lib/omit-undefined.js.map +1 -0
- package/package.json +100 -0
- package/src/components/data/code.tsx +43 -0
- package/src/components/data/copy-button.tsx +97 -0
- package/src/components/data/detail-row.tsx +113 -0
- package/src/components/data/id-display.tsx +49 -0
- package/src/components/data/masked-value.tsx +96 -0
- package/src/components/data/money.tsx +46 -0
- package/src/components/data/phone-display.tsx +60 -0
- package/src/components/data/stat-tile.tsx +68 -0
- package/src/components/data/timestamp-display.tsx +106 -0
- package/src/components/patterns/inline-banner.tsx +60 -0
- package/src/components/patterns/inline-empty-state.tsx +45 -0
- package/src/components/patterns/live-row.tsx +75 -0
- package/src/components/patterns/payload-inspector.tsx +157 -0
- package/src/components/patterns/route-skeleton.tsx +77 -0
- package/src/components/patterns/screen-layout.tsx +44 -0
- package/src/components/patterns/stale-write-banner.tsx +44 -0
- package/src/components/patterns/state-timeline.tsx +229 -0
- package/src/components/primitives/badge.tsx +40 -0
- package/src/components/primitives/button.tsx +54 -0
- package/src/components/primitives/calendar.tsx +136 -0
- package/src/components/primitives/card.tsx +70 -0
- package/src/components/primitives/checkbox.tsx +101 -0
- package/src/components/primitives/chip-select.tsx +105 -0
- package/src/components/primitives/command-menu.tsx +92 -0
- package/src/components/primitives/confirm-dialog.tsx +109 -0
- package/src/components/primitives/date-picker.tsx +271 -0
- package/src/components/primitives/dialog.tsx +195 -0
- package/src/components/primitives/drawer.tsx +326 -0
- package/src/components/primitives/dropdown-menu.tsx +128 -0
- package/src/components/primitives/form-field.tsx +136 -0
- package/src/components/primitives/inline-confirm.tsx +118 -0
- package/src/components/primitives/input.tsx +21 -0
- package/src/components/primitives/label.tsx +43 -0
- package/src/components/primitives/pagination.tsx +110 -0
- package/src/components/primitives/popover.tsx +41 -0
- package/src/components/primitives/progress.tsx +66 -0
- package/src/components/primitives/radio-group.tsx +90 -0
- package/src/components/primitives/select.tsx +254 -0
- package/src/components/primitives/separator.tsx +49 -0
- package/src/components/primitives/side-nav.tsx +253 -0
- package/src/components/primitives/skeleton.tsx +25 -0
- package/src/components/primitives/spinner.tsx +39 -0
- package/src/components/primitives/switch.tsx +74 -0
- package/src/components/primitives/table.tsx +157 -0
- package/src/components/primitives/tabs.tsx +163 -0
- package/src/components/primitives/textarea.tsx +21 -0
- package/src/components/primitives/toast.tsx +117 -0
- package/src/components/primitives/tooltip.tsx +38 -0
- package/src/components/status/state-chip.tsx +70 -0
- package/src/components/status/state-mark.tsx +154 -0
- package/src/components/status/status-pill.tsx +153 -0
- package/src/components/status/status-tokens.ts +173 -0
- package/src/index.ts +96 -0
- package/src/lib/cn.ts +168 -0
- package/src/lib/money.ts +164 -0
- package/src/lib/omit-undefined.ts +25 -0
- package/src/styles/theme.css +381 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A labelled value in a detail drawer or summary panel — one `dt`/`dd`
|
|
6
|
+
* pair, wrapped in a `<div>` so the pair can be laid out as a unit.
|
|
7
|
+
*
|
|
8
|
+
* # Why this is shared, when four route-local copies said it should not be
|
|
9
|
+
*
|
|
10
|
+
* `providers/`, `routes/`, `sender-ids/` and `webhooks/` each carry a
|
|
11
|
+
* `components/detail-row.tsx` whose **code is byte-identical** — only the
|
|
12
|
+
* doc comments differ. Each of those comments argues, in good faith, that
|
|
13
|
+
* the component is route-local because "the identical shape repeats
|
|
14
|
+
* independently in the other three, each factored the same way in its own
|
|
15
|
+
* file". Four files justifying their own duplication by pointing at each
|
|
16
|
+
* other is the clearest possible signal the thing belongs here instead.
|
|
17
|
+
*
|
|
18
|
+
* They were written by four agents working in parallel on disjoint route
|
|
19
|
+
* groups, none of which could edit this package — a deliberate constraint,
|
|
20
|
+
* because the round before it produced two `ScreenHeader`s and two
|
|
21
|
+
* `MESSAGE_CLASSES` from agents that *could*. The trade was that
|
|
22
|
+
* cross-route duplicates get reconciled centrally afterwards. This is that.
|
|
23
|
+
*
|
|
24
|
+
* # The variants are the console's real, pre-existing treatments
|
|
25
|
+
*
|
|
26
|
+
* Nothing here is a new design. Eight sites were read first and their
|
|
27
|
+
* looks catalogued; the three variants below are exactly what was already
|
|
28
|
+
* on screen, so every converted site keeps its current appearance:
|
|
29
|
+
*
|
|
30
|
+
* - `inline` — label left, value right, on one line. The default, and what
|
|
31
|
+
* the four route-local copies rendered.
|
|
32
|
+
* - `stacked` — label above value. What those same copies rendered under
|
|
33
|
+
* their own `stacked` prop, for a value too long to sit beside its label.
|
|
34
|
+
* - `divided` — label above value, hairline rule beneath, last row
|
|
35
|
+
* unruled. What `jobs`, `opt-outs` and `workers` each hand-rolled as a
|
|
36
|
+
* private `*DetailField` helper.
|
|
37
|
+
*
|
|
38
|
+
* **One deliberate normalisation, called out rather than slipped in:**
|
|
39
|
+
* `audit-log` and the gallery used `flex justify-between gap-4` — the
|
|
40
|
+
* `inline` treatment with a slightly wider gap and no vertical centring.
|
|
41
|
+
* They map onto `inline` here, so those two drawers shift by one spacing
|
|
42
|
+
* step and gain baseline centring. That is a real, if small, visual change,
|
|
43
|
+
* and it is the point: the console should not render the same concept two
|
|
44
|
+
* ways because two people typed a different gap.
|
|
45
|
+
*/
|
|
46
|
+
export type DetailRowVariant = "inline" | "stacked" | "divided";
|
|
47
|
+
|
|
48
|
+
export interface DetailRowProps {
|
|
49
|
+
label: ReactNode;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
variant?: DetailRowVariant | undefined;
|
|
52
|
+
className?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function DetailRow({ label, children, variant = "inline", className }: DetailRowProps) {
|
|
56
|
+
if (variant === "divided") {
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex flex-col gap-0.5 border-edge-subtle border-b py-2 last:border-b-0",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<dt className="text-caption text-subtle-foreground">{label}</dt>
|
|
65
|
+
<dd className="text-body text-foreground">{children}</dd>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (variant === "stacked") {
|
|
71
|
+
return (
|
|
72
|
+
<div className={cn("flex flex-col gap-1", className)}>
|
|
73
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
74
|
+
<dd className="text-caption">{children}</dd>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn("flex items-center justify-between gap-3", className)}>
|
|
81
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
82
|
+
<dd>{children}</dd>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The `<dl>` a group of [`DetailRow`]s belongs in.
|
|
89
|
+
*
|
|
90
|
+
* Exists so a caller cannot pair the rows with the wrong container — every
|
|
91
|
+
* one of the eight original sites wrapped its rows in a `<dl>` with its own
|
|
92
|
+
* spacing (`gap-2`, `gap-3`, or none), which is the same one-decision-in-
|
|
93
|
+
* many-places problem the rows themselves had, one level up.
|
|
94
|
+
*
|
|
95
|
+
* `divided` rows carry their own vertical rhythm through padding and the
|
|
96
|
+
* hairline rule, so the list adds no gap for them — passing `gap` there
|
|
97
|
+
* would double-space rows that are already separated by a border.
|
|
98
|
+
*/
|
|
99
|
+
export function DetailList({
|
|
100
|
+
children,
|
|
101
|
+
variant = "inline",
|
|
102
|
+
className,
|
|
103
|
+
}: {
|
|
104
|
+
children: ReactNode;
|
|
105
|
+
variant?: DetailRowVariant | undefined;
|
|
106
|
+
className?: string | undefined;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<dl className={cn("flex flex-col text-body", variant !== "divided" && "gap-3", className)}>
|
|
110
|
+
{children}
|
|
111
|
+
</dl>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { CopyButton } from "./copy-button";
|
|
3
|
+
|
|
4
|
+
export interface IdDisplayProps {
|
|
5
|
+
value: string;
|
|
6
|
+
/** `table` (default): a truncated prefix plus a hover-reveal copy
|
|
7
|
+
* button — enough to disambiguate visually within one screen of rows.
|
|
8
|
+
* `full`: the whole value, selectable, with an always-visible copy —
|
|
9
|
+
* detail views only. Copy always copies the FULL value in both. */
|
|
10
|
+
variant?: "table" | "full";
|
|
11
|
+
/** How many leading characters the `table` variant shows. Seven is the
|
|
12
|
+
* default because it is what makes two 23-character CUIDs distinguishable
|
|
13
|
+
* in a list without dominating the column; an id family with a shared
|
|
14
|
+
* prefix (`ord_`, `pi_`) needs more. */
|
|
15
|
+
truncateTo?: number;
|
|
16
|
+
className?: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An opaque machine identifier — a CUID, a ULID, a provider's reference.
|
|
21
|
+
*
|
|
22
|
+
* Two rules, both learned the hard way and both enforced here rather than
|
|
23
|
+
* left to each call site:
|
|
24
|
+
*
|
|
25
|
+
* - **Never truncate in the middle.** `abc…xyz` makes two ids that differ
|
|
26
|
+
* only in the middle look identical, which is precisely the comparison
|
|
27
|
+
* a human scanning a column is making.
|
|
28
|
+
* - **Never decorate.** A displayed `msg_abc…` gets pasted into a filter
|
|
29
|
+
* that expects the bare value and returns nothing, or worse, a 400. If
|
|
30
|
+
* the prefix is part of the id, it is part of `value`.
|
|
31
|
+
*
|
|
32
|
+
* For a value that must be read in full — a config key, a role name, an
|
|
33
|
+
* enum literal — use `Code` instead. This component's truncation is wrong
|
|
34
|
+
* for anything a human is meant to read rather than match.
|
|
35
|
+
*/
|
|
36
|
+
export function IdDisplay({ value, variant = "table", truncateTo = 7, className }: IdDisplayProps) {
|
|
37
|
+
const shown = variant === "table" ? value.slice(0, truncateTo) : value;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span
|
|
41
|
+
className={cn("group inline-flex items-center gap-1.5 font-mono tabular-nums", className)}
|
|
42
|
+
>
|
|
43
|
+
<span className={variant === "full" ? "select-all" : undefined} title={value}>
|
|
44
|
+
{shown}
|
|
45
|
+
</span>
|
|
46
|
+
<CopyButton value={value} revealOnGroupHover={variant === "table"} />
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Eye, EyeOff } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { cn } from "../../lib/cn";
|
|
6
|
+
import { CopyButton } from "./copy-button";
|
|
7
|
+
|
|
8
|
+
export interface MaskedValueProps {
|
|
9
|
+
/** The full value. Present in the DOM only while revealed. */
|
|
10
|
+
value: string;
|
|
11
|
+
/** Trailing characters left visible while masked, so the value stays
|
|
12
|
+
* recognisable without being readable. `0` masks everything. */
|
|
13
|
+
reveal?: number;
|
|
14
|
+
/** Leading characters left visible — for a prefixed credential like
|
|
15
|
+
* `whsec_…` or `sk_live_…`, where the prefix says what kind of thing it
|
|
16
|
+
* is and is not itself secret. */
|
|
17
|
+
prefix?: number;
|
|
18
|
+
/** Allow revealing at all. `false` gives a permanently masked value with
|
|
19
|
+
* no toggle — for something displayed for recognition only. */
|
|
20
|
+
revealable?: boolean;
|
|
21
|
+
/** Offer a copy button. Copies the full value whether or not it is
|
|
22
|
+
* currently revealed — the point of masking is the screen, not the
|
|
23
|
+
* clipboard. */
|
|
24
|
+
copyable?: boolean;
|
|
25
|
+
/** Names the value in the toggle's and copy button's accessible names,
|
|
26
|
+
* e.g. `"signing secret"`. */
|
|
27
|
+
label?: string;
|
|
28
|
+
className?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DOT = "•";
|
|
32
|
+
|
|
33
|
+
function mask(value: string, prefix: number, reveal: number): string {
|
|
34
|
+
const head = value.slice(0, prefix);
|
|
35
|
+
const tail = reveal > 0 ? value.slice(-reveal) : "";
|
|
36
|
+
const hiddenCount = Math.max(value.length - head.length - tail.length, 0);
|
|
37
|
+
// A fixed run of dots, not one per character: the exact length of a
|
|
38
|
+
// secret is information, and padding it out to the real length leaks it
|
|
39
|
+
// to anyone looking over a shoulder.
|
|
40
|
+
const dots = DOT.repeat(hiddenCount === 0 ? 0 : Math.min(Math.max(hiddenCount, 6), 12));
|
|
41
|
+
return `${head}${dots}${tail}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A secret or sensitive value, masked by default with an explicit reveal.
|
|
46
|
+
*
|
|
47
|
+
* # What this is and is not
|
|
48
|
+
*
|
|
49
|
+
* It is a **shoulder-surfing and screen-share control**, not a security
|
|
50
|
+
* boundary. Whoever renders this already received the value over the
|
|
51
|
+
* wire; anyone with the session and dev tools can read it regardless.
|
|
52
|
+
* Withholding data that has already crossed the wire to an authorised,
|
|
53
|
+
* authenticated session would be theatre. What masking buys is real but
|
|
54
|
+
* narrow: the value does not end up in a screenshot, a recording, or the
|
|
55
|
+
* eyeline of the person sitting behind the operator.
|
|
56
|
+
*
|
|
57
|
+
* If a value must genuinely never reach the client, that is an API
|
|
58
|
+
* decision — do not send it — and no component can substitute for it.
|
|
59
|
+
*
|
|
60
|
+
* # Why it is shared
|
|
61
|
+
*
|
|
62
|
+
* Both applications this package serves had hand-rolled one: a console
|
|
63
|
+
* masking a webhook signing secret as `whsec_••••••••••<last 4>`, and a
|
|
64
|
+
* payments dashboard rendering a masked payer reference and a TOTP
|
|
65
|
+
* enrolment secret as plain text because no such component existed.
|
|
66
|
+
*/
|
|
67
|
+
export function MaskedValue({
|
|
68
|
+
value,
|
|
69
|
+
reveal = 4,
|
|
70
|
+
prefix = 0,
|
|
71
|
+
revealable = true,
|
|
72
|
+
copyable = true,
|
|
73
|
+
label = "value",
|
|
74
|
+
className,
|
|
75
|
+
}: MaskedValueProps) {
|
|
76
|
+
const [revealed, setRevealed] = useState(false);
|
|
77
|
+
const shown = revealed ? value : mask(value, prefix, reveal);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<span className={cn("group inline-flex items-center gap-1.5", className)}>
|
|
81
|
+
<span className={cn("font-mono", revealed && "select-all break-all")}>{shown}</span>
|
|
82
|
+
{revealable && (
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => setRevealed((previous) => !previous)}
|
|
86
|
+
aria-label={revealed ? `Hide ${label}` : `Reveal ${label}`}
|
|
87
|
+
aria-pressed={revealed}
|
|
88
|
+
className="shrink-0 text-subtle-foreground transition-colors hover:text-foreground"
|
|
89
|
+
>
|
|
90
|
+
{revealed ? <EyeOff size={12} strokeWidth={1.5} /> : <Eye size={12} strokeWidth={1.5} />}
|
|
91
|
+
</button>
|
|
92
|
+
)}
|
|
93
|
+
{copyable && <CopyButton value={value} label={`Copy ${label}`} />}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { type FormatMoneyOptions, formatMoney, type MinorUnits } from "../../lib/money";
|
|
3
|
+
|
|
4
|
+
export interface MoneyProps extends FormatMoneyOptions {
|
|
5
|
+
/** An integer count of the currency's smallest unit. */
|
|
6
|
+
amount: MinorUnits;
|
|
7
|
+
/** ISO 4217 code, e.g. `"XAF"`. */
|
|
8
|
+
currency: string;
|
|
9
|
+
/** Tint by sign: credits green, debits red. Off by default — in a
|
|
10
|
+
* ledger where most rows are one direction, colouring every row is
|
|
11
|
+
* noise, and colour alone is not an accessible way to convey sign. The
|
|
12
|
+
* minus sign is always rendered regardless. */
|
|
13
|
+
tone?: "none" | "signed";
|
|
14
|
+
className?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An amount of money, right-alignable and vertically comparable.
|
|
19
|
+
*
|
|
20
|
+
* `tabular-nums` is the whole reason this is a component and not a call to
|
|
21
|
+
* [`formatMoney`] in a `<span>`: without fixed-width digits, a column of
|
|
22
|
+
* amounts does not line up at the decimal point, and scanning it for an
|
|
23
|
+
* outlier — the actual job — stops working.
|
|
24
|
+
*
|
|
25
|
+
* `<data value>` rather than `<span>`: the machine-readable amount stays
|
|
26
|
+
* in the DOM in minor units, so a copy-paste or a scraper gets the exact
|
|
27
|
+
* integer rather than a locale-formatted string it would have to parse
|
|
28
|
+
* back.
|
|
29
|
+
*/
|
|
30
|
+
export function Money({ amount, currency, tone = "none", className, ...options }: MoneyProps) {
|
|
31
|
+
const formatted = formatMoney(amount, currency, options);
|
|
32
|
+
const negative = String(amount).trim().startsWith("-");
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<data
|
|
36
|
+
value={`${String(amount)} ${currency}`}
|
|
37
|
+
className={cn(
|
|
38
|
+
"font-mono tabular-nums whitespace-nowrap",
|
|
39
|
+
tone === "signed" && (negative ? "text-state-danger-fg" : "text-state-success-fg"),
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{formatted}
|
|
44
|
+
</data>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { CopyButton } from "./copy-button";
|
|
3
|
+
|
|
4
|
+
export interface PhoneDisplayProps {
|
|
5
|
+
/** E.164, e.g. `+237677123456`. Stored form, and what gets copied. */
|
|
6
|
+
value: string;
|
|
7
|
+
/**
|
|
8
|
+
* Regroups the E.164 digits for reading. Return `null` — or omit the
|
|
9
|
+
* prop — to render the value verbatim.
|
|
10
|
+
*
|
|
11
|
+
* Grouping is a per-country convention (`+237 6 77 12 34 56` in
|
|
12
|
+
* Cameroon, `+1 (415) 555-0132` in the NANP) and getting it wrong is
|
|
13
|
+
* worse than not grouping at all, because a mis-grouped number reads as
|
|
14
|
+
* a different number. This component therefore ships no default
|
|
15
|
+
* grouping and no built-in country table: a caller that knows its
|
|
16
|
+
* market passes a formatter, and everyone else gets the raw E.164,
|
|
17
|
+
* which is always correct if less pretty.
|
|
18
|
+
*/
|
|
19
|
+
format?: ((e164: string) => string | null) | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* A short mono tag after the number — a carrier, a line type, a region.
|
|
22
|
+
* Renders an em dash when absent, so a column of numbers stays aligned
|
|
23
|
+
* whether or not each row could be classified.
|
|
24
|
+
*/
|
|
25
|
+
tag?: string | undefined;
|
|
26
|
+
/** Hover text for the tag. Say where it came from and how much to trust
|
|
27
|
+
* it: a carrier inferred from a number prefix is a guess, and number
|
|
28
|
+
* portability means it is wrong for a real fraction of subscribers. */
|
|
29
|
+
tagTitle?: string | undefined;
|
|
30
|
+
className?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A phone number, never truncated.
|
|
35
|
+
*
|
|
36
|
+
* Truncation is banned rather than discouraged: a half-shown phone number
|
|
37
|
+
* is worse than useless in an investigation, and unlike an opaque id
|
|
38
|
+
* (where a prefix is enough to match rows by eye) there is no prefix of a
|
|
39
|
+
* phone number that identifies it.
|
|
40
|
+
*
|
|
41
|
+
* Copy always copies the raw E.164 — the form that pastes into a query —
|
|
42
|
+
* not the grouped form on screen.
|
|
43
|
+
*
|
|
44
|
+
* Previously this was `MsisdnDisplay`, with Cameroon's `+237` grouping and
|
|
45
|
+
* a four-entry Cameroonian carrier table compiled in. Both are now the
|
|
46
|
+
* caller's: an application knows its market, a component library does not.
|
|
47
|
+
*/
|
|
48
|
+
export function PhoneDisplay({ value, format, tag, tagTitle, className }: PhoneDisplayProps) {
|
|
49
|
+
const formatted = format?.(value) ?? value;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<span className={cn("group inline-flex items-center gap-1.5 whitespace-nowrap", className)}>
|
|
53
|
+
<span className="font-mono tabular-nums">{formatted}</span>
|
|
54
|
+
<span className="font-mono text-micro text-subtle-foreground" title={tagTitle}>
|
|
55
|
+
{tag ?? "—"}
|
|
56
|
+
</span>
|
|
57
|
+
<CopyButton value={value} revealOnGroupHover />
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import type { StatusHue } from "../status/status-tokens";
|
|
4
|
+
import { HUE_CLASSES } from "../status/status-tokens";
|
|
5
|
+
|
|
6
|
+
export interface StatTileProps {
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** The number itself. Pass a `Money` or a formatted string — this
|
|
9
|
+
* component does no formatting, because a tile has no way to know
|
|
10
|
+
* whether it is showing a count, a currency or a rate. */
|
|
11
|
+
value: ReactNode;
|
|
12
|
+
/** One line under the value: the comparison, the window, the caveat.
|
|
13
|
+
* A bare number with no denominator is the most common way a dashboard
|
|
14
|
+
* misleads, so this slot exists to make the denominator easy. */
|
|
15
|
+
caption?: ReactNode;
|
|
16
|
+
/** Tints the value. Leave unset unless the number's own colour carries
|
|
17
|
+
* meaning — a wall of coloured tiles makes the one that matters harder
|
|
18
|
+
* to find, which is the opposite of the point. */
|
|
19
|
+
tone?: StatusHue | undefined;
|
|
20
|
+
/** Top-right slot — a sparkline, a `StateChip`, a refresh button. */
|
|
21
|
+
action?: ReactNode;
|
|
22
|
+
className?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One headline number with its label and context.
|
|
27
|
+
*
|
|
28
|
+
* Borders, not shadows, and no card chrome of its own beyond a hairline:
|
|
29
|
+
* tiles appear in rows of three to six, and six shadowed boxes is visual
|
|
30
|
+
* noise where six outlined ones read as one instrument panel.
|
|
31
|
+
*
|
|
32
|
+
* `<dl>`/`<dt>`/`<dd>` rather than divs — a tile *is* a term and its
|
|
33
|
+
* definition, and the semantics make a row of them navigable rather than
|
|
34
|
+
* an undifferentiated wall of text to a screen reader.
|
|
35
|
+
*/
|
|
36
|
+
export function StatTile({ label, value, caption, tone, action, className }: StatTileProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn(
|
|
40
|
+
"relative flex flex-col rounded-box border border-edge bg-base-300 p-4",
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{/* Outside the `<dl>`, and absolutely positioned rather than a flex
|
|
45
|
+
sibling of the `<dt>`. A `<dl>` may contain only `<dt>`, `<dd>`
|
|
46
|
+
and `<div>`s that wrap dt/dd groups — an action button among
|
|
47
|
+
them is invalid markup, which axe reports as a serious
|
|
48
|
+
`definition-list` violation. Found by the a11y addon, after an
|
|
49
|
+
earlier version nested the action in a header row inside the
|
|
50
|
+
list. */}
|
|
51
|
+
{action != null && <div className="absolute top-4 right-4">{action}</div>}
|
|
52
|
+
<dl className="flex flex-col gap-1">
|
|
53
|
+
<dt className={cn("text-caption text-muted-foreground", action != null && "pr-20")}>
|
|
54
|
+
{label}
|
|
55
|
+
</dt>
|
|
56
|
+
<dd
|
|
57
|
+
className={cn(
|
|
58
|
+
"font-mono text-metric tabular-nums",
|
|
59
|
+
tone === undefined ? "text-foreground" : HUE_CLASSES[tone].fg,
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{value}
|
|
63
|
+
</dd>
|
|
64
|
+
{caption != null && <dd className="text-caption text-subtle-foreground">{caption}</dd>}
|
|
65
|
+
</dl>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
|
|
6
|
+
// A single shared 30s interval drives every `TimestampDisplay` instance's
|
|
7
|
+
// relative-time re-render (design doc §7.2: "a table of 200 rows must not
|
|
8
|
+
// run 200 timers"). Lazily started on the first mounted instance, torn
|
|
9
|
+
// down when the last one unmounts.
|
|
10
|
+
|
|
11
|
+
let tick = 0;
|
|
12
|
+
const listeners = new Set<() => void>();
|
|
13
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
14
|
+
|
|
15
|
+
function subscribe(listener: () => void): () => void {
|
|
16
|
+
listeners.add(listener);
|
|
17
|
+
timer ??= setInterval(() => {
|
|
18
|
+
tick += 1;
|
|
19
|
+
for (const l of listeners) l();
|
|
20
|
+
}, 30_000);
|
|
21
|
+
return () => {
|
|
22
|
+
listeners.delete(listener);
|
|
23
|
+
if (listeners.size === 0 && timer !== null) {
|
|
24
|
+
clearInterval(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function useSharedTick(): number {
|
|
31
|
+
return useSyncExternalStore(
|
|
32
|
+
subscribe,
|
|
33
|
+
() => tick,
|
|
34
|
+
() => 0,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pad(n: number): string {
|
|
39
|
+
return String(n).padStart(2, "0");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `2026-08-08 14:03:07` + a zone suffix — ISO-ordered so it sorts and
|
|
43
|
+
* compares visually (design doc §7.2). Only UTC is implemented (`Z`
|
|
44
|
+
* suffix, always): the Africa/Douala toggle lives on the top bar, which
|
|
45
|
+
* this screen doesn't build. "A bare local time with no zone label never
|
|
46
|
+
* appears anywhere in this product" still holds — the suffix is always
|
|
47
|
+
* present, just not yet switchable. */
|
|
48
|
+
function formatAbsolute(date: Date): string {
|
|
49
|
+
const y = date.getUTCFullYear();
|
|
50
|
+
const mo = pad(date.getUTCMonth() + 1);
|
|
51
|
+
const d = pad(date.getUTCDate());
|
|
52
|
+
const h = pad(date.getUTCHours());
|
|
53
|
+
const mi = pad(date.getUTCMinutes());
|
|
54
|
+
const s = pad(date.getUTCSeconds());
|
|
55
|
+
return `${y}-${mo}-${d} ${h}:${mi}:${s}Z`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatRelative(diffMs: number): string {
|
|
59
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
60
|
+
if (minutes < 1) return "just now";
|
|
61
|
+
if (minutes < 60) return `${minutes}m`;
|
|
62
|
+
const hours = Math.floor(minutes / 60);
|
|
63
|
+
return `${hours}h`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TimestampDisplayProps {
|
|
67
|
+
/** ISO-8601. */
|
|
68
|
+
value: string;
|
|
69
|
+
className?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Relative for anything under 24h (`2m`, `47m`, `6h`), absolute otherwise
|
|
74
|
+
* — design doc §7.2. Renders the absolute value on the server and on
|
|
75
|
+
* first client render (so server/client markup matches and Next doesn't
|
|
76
|
+
* warn/flash), then upgrades to relative once mounted, driven by the
|
|
77
|
+
* shared interval above rather than its own timer.
|
|
78
|
+
*/
|
|
79
|
+
export function TimestampDisplay({ value, className }: TimestampDisplayProps) {
|
|
80
|
+
useSharedTick();
|
|
81
|
+
const [hydrated, setHydrated] = useState(false);
|
|
82
|
+
useEffect(() => setHydrated(true), []);
|
|
83
|
+
|
|
84
|
+
const date = new Date(value);
|
|
85
|
+
const absolute = formatAbsolute(date);
|
|
86
|
+
|
|
87
|
+
if (!hydrated) {
|
|
88
|
+
return (
|
|
89
|
+
<span className={cn("font-mono text-subtle-foreground tabular-nums", className)}>
|
|
90
|
+
{absolute}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const diffMs = Date.now() - date.getTime();
|
|
96
|
+
const display = diffMs >= 0 && diffMs < 24 * 3600 * 1000 ? formatRelative(diffMs) : absolute;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
title={absolute}
|
|
101
|
+
className={cn("font-mono text-subtle-foreground tabular-nums", className)}
|
|
102
|
+
>
|
|
103
|
+
{display}
|
|
104
|
+
</span>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
// R6 (AGENTS.md): the same three banner treatments — a neutral scope/notice
|
|
5
|
+
// box, a danger error box, and a plain caption-only note — were hand-rolled
|
|
6
|
+
// inline in every screen that needed one (`rounded-sm border border-edge
|
|
7
|
+
// bg-surface-2 px-3 py-2 text-caption text-muted-foreground` for "neutral",
|
|
8
|
+
// `border-state-danger-border bg-state-danger-bg ... text-state-danger-fg`
|
|
9
|
+
// for "danger"). Classes moved here verbatim, parameterised by variant, not
|
|
10
|
+
// redesigned. `InlineEmptyState` already set the precedent for this shape
|
|
11
|
+
// of extraction (a status treatment repeated per-screen, given one home) —
|
|
12
|
+
// this is the same move applied to a different repeated block.
|
|
13
|
+
//
|
|
14
|
+
// Not to be confused with `InlineEmptyState`: that one is for "there is
|
|
15
|
+
// nothing to show here" inside a list; this one is for a standing notice or
|
|
16
|
+
// an error above/around content that *is* showing.
|
|
17
|
+
|
|
18
|
+
export interface InlineBannerProps {
|
|
19
|
+
/** `'neutral'` — a standing notice (scope, caveat). `'danger'` — an
|
|
20
|
+
* error. `'warning'` — recoverable, needs attention (a stale write).
|
|
21
|
+
* `'success'` — a positive confirmation (a verified audit chain).
|
|
22
|
+
* `'uncertain'` — degraded or unknown, neither failure nor success (a
|
|
23
|
+
* stalled live feed, a UCS-2 volume spike). `'plain'` — a caption-only
|
|
24
|
+
* note with no border or fill, for something that doesn't need the
|
|
25
|
+
* weight of a bordered box.
|
|
26
|
+
*
|
|
27
|
+
* The last three were added after the R6 factorization, not with it:
|
|
28
|
+
* four route groups had hand-rolled boxes in exactly these tones because
|
|
29
|
+
* the variant they needed did not exist, and the agents doing the
|
|
30
|
+
* factorization were deliberately barred from editing this package. Two
|
|
31
|
+
* of them flagged the gap rather than inventing a competing component,
|
|
32
|
+
* which is the behaviour the constraint was for. */
|
|
33
|
+
variant?: "neutral" | "danger" | "warning" | "success" | "uncertain" | "plain";
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function InlineBanner({ variant = "neutral", children, className }: InlineBannerProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
"text-caption",
|
|
43
|
+
variant === "neutral" &&
|
|
44
|
+
"rounded-sm border border-edge bg-surface-2 px-3 py-2 text-muted-foreground",
|
|
45
|
+
variant === "danger" &&
|
|
46
|
+
"rounded-sm border border-state-danger-border bg-state-danger-bg px-3 py-2 text-state-danger-fg",
|
|
47
|
+
variant === "warning" &&
|
|
48
|
+
"rounded-sm border border-state-warning-border bg-state-warning-bg px-3 py-2 text-state-warning-fg",
|
|
49
|
+
variant === "success" &&
|
|
50
|
+
"rounded-sm border border-state-success-border bg-state-success-bg px-3 py-2 text-state-success-fg",
|
|
51
|
+
variant === "uncertain" &&
|
|
52
|
+
"rounded-sm border border-state-uncertain-border bg-state-uncertain-bg px-3 py-2 text-state-uncertain-fg",
|
|
53
|
+
variant === "plain" && "text-subtle-foreground",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface InlineEmptyStateProps {
|
|
5
|
+
message: ReactNode;
|
|
6
|
+
action?: { label: string; onClick: () => void };
|
|
7
|
+
/** `'standalone'` is the one exception (§5.3): a screen with nothing else
|
|
8
|
+
* to do may centre a single line plus one primary action. Still no
|
|
9
|
+
* illustration, still no card. */
|
|
10
|
+
variant?: "inline" | "standalone";
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Enforces the binding rule: empty states are inline status lines, not
|
|
16
|
+
* centred placards. Use inside a table body (spanning all columns) or in
|
|
17
|
+
* the panel where the missing list would be.
|
|
18
|
+
*/
|
|
19
|
+
export function InlineEmptyState({
|
|
20
|
+
message,
|
|
21
|
+
action,
|
|
22
|
+
variant = "inline",
|
|
23
|
+
className,
|
|
24
|
+
}: InlineEmptyStateProps) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex items-center gap-3 text-body text-muted-foreground",
|
|
29
|
+
variant === "standalone" ? "justify-center py-16 text-center" : "py-3 text-left",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<span>{message}</span>
|
|
34
|
+
{action != null && (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={action.onClick}
|
|
38
|
+
className="text-foreground underline decoration-edge-strong underline-offset-2 hover:decoration-foreground"
|
|
39
|
+
>
|
|
40
|
+
{action.label}
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|