@softure-ai/theme-notion 0.1.0 → 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/CHANGELOG.md +47 -0
- package/README.md +80 -4
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +3 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +1 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +3 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/notion/DetailTable.d.ts +15 -0
- package/dist/notion/DetailTable.d.ts.map +1 -0
- package/dist/notion/DetailTable.js +20 -0
- package/dist/notion/DetailTable.js.map +1 -0
- package/dist/notion/FoldRow.d.ts +23 -0
- package/dist/notion/FoldRow.d.ts.map +1 -0
- package/dist/notion/FoldRow.js +14 -0
- package/dist/notion/FoldRow.js.map +1 -0
- package/dist/notion/MarkPopover.d.ts +19 -0
- package/dist/notion/MarkPopover.d.ts.map +1 -0
- package/dist/notion/MarkPopover.js +22 -0
- package/dist/notion/MarkPopover.js.map +1 -0
- package/dist/notion/PageHeader.d.ts +2 -1
- package/dist/notion/PageHeader.d.ts.map +1 -1
- package/dist/notion/PageHeader.js +2 -2
- package/dist/notion/PageHeader.js.map +1 -1
- package/dist/notion/SidePanel.d.ts +13 -0
- package/dist/notion/SidePanel.d.ts.map +1 -0
- package/dist/notion/SidePanel.js +28 -0
- package/dist/notion/SidePanel.js.map +1 -0
- package/dist/notion/Stats.d.ts +45 -0
- package/dist/notion/Stats.d.ts.map +1 -0
- package/dist/notion/Stats.js +31 -0
- package/dist/notion/Stats.js.map +1 -0
- package/dist/notion/TabBar.d.ts +15 -0
- package/dist/notion/TabBar.d.ts.map +1 -0
- package/dist/notion/TabBar.js +6 -0
- package/dist/notion/TabBar.js.map +1 -0
- package/dist/notion/tones.d.ts +8 -0
- package/dist/notion/tones.d.ts.map +1 -0
- package/dist/notion/tones.js +37 -0
- package/dist/notion/tones.js.map +1 -0
- package/package.json +2 -2
- package/src/index.ts +17 -0
- package/src/messages/en.ts +1 -0
- package/src/notion/DetailTable.tsx +46 -0
- package/src/notion/FoldRow.tsx +78 -0
- package/src/notion/MarkPopover.tsx +84 -0
- package/src/notion/PageHeader.tsx +4 -2
- package/src/notion/SidePanel.tsx +73 -0
- package/src/notion/Stats.tsx +145 -0
- package/src/notion/TabBar.tsx +47 -0
- package/src/notion/tones.ts +45 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChevronRight } from "lucide-react";
|
|
3
|
+
import { Badge } from "../ui/badge.js";
|
|
4
|
+
import { cn } from "../lib/utils.js";
|
|
5
|
+
import { ICON_STROKE_WIDTH } from "./icon-stroke.js";
|
|
6
|
+
import { TONE_COLORS, TONE_TEXT } from "./tones.js";
|
|
7
|
+
// A screen's summary figures folded under a heading, closed by default: the design system's collapsible (a ghost row
|
|
8
|
+
// with a chevron that turns when open, the content indented under it), on native `<details>` so it needs no client.
|
|
9
|
+
export function StatsFold({ title, children, className }) {
|
|
10
|
+
return (_jsxs("details", { "data-slot": "stats-fold", className: cn("group/stats-fold", className), children: [_jsxs("summary", { "data-slot": "stats-fold-summary", className: "flex h-row-height cursor-pointer list-none items-center gap-1 rounded-md px-1 text-foreground transition-colors duration-120 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [&::-webkit-details-marker]:hidden", children: [_jsx(ChevronRight, { "aria-hidden": "true", strokeWidth: ICON_STROKE_WIDTH, className: "size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/stats-fold:rotate-90" }), _jsx("h2", { className: "text-ui-medium", children: title })] }), _jsx("div", { "data-slot": "stats-fold-content", className: "flex flex-col gap-3 pt-2 pb-1 pl-6", children: children })] }));
|
|
11
|
+
}
|
|
12
|
+
function getStatCardsLayout(isFullWidth, isOneRow) {
|
|
13
|
+
if (isOneRow)
|
|
14
|
+
return "row";
|
|
15
|
+
return isFullWidth ? "full" : "grid";
|
|
16
|
+
}
|
|
17
|
+
// Summary figures as a row of cards wrapping on a narrow screen: the label, the figure in its tone's colour (never
|
|
18
|
+
// broken, tabular numbers), an optional note under it and an optional short figure on its right as a tag.
|
|
19
|
+
export function StatCards({ label, cards, isFullWidth = false, isOneRow = false }) {
|
|
20
|
+
return (_jsx("ul", { "aria-label": label, "data-slot": "stat-cards", "data-layout": getStatCardsLayout(isFullWidth, isOneRow), className: cn("grid gap-2", isFullWidth
|
|
21
|
+
? "grid-cols-[repeat(auto-fit,minmax(min(14rem,100%),1fr))]"
|
|
22
|
+
: "grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))]", isOneRow && "sm:auto-cols-fr sm:grid-flow-col sm:grid-cols-none"), children: cards.map((card) => {
|
|
23
|
+
const tone = card.tone ?? "default";
|
|
24
|
+
return (_jsxs("li", { "data-stat": card.key, className: "flex min-w-0 items-start justify-between gap-2 rounded-lg bg-card p-3 text-card-foreground shadow-card", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-section-label text-muted-foreground", children: card.label }), _jsx("div", { "data-tone": tone, className: cn("mt-1 text-heading-3 whitespace-nowrap tabular-nums", TONE_TEXT[tone]), children: card.value }), card.hint === undefined ? null : _jsx("div", { className: "mt-1 text-small text-muted-foreground", children: card.hint })] }), card.aside === undefined ? null : (_jsx(Badge, { variant: "tag", color: TONE_COLORS[card.aside.tone ?? "default"] ?? "default", "data-slot": "stat-card-aside", "data-tone": card.aside.tone ?? "default", className: "text-small", children: card.aside.text }))] }, card.key));
|
|
25
|
+
}) }));
|
|
26
|
+
}
|
|
27
|
+
// Small counts as "label **value**" tags in one row, wrapping only on a phone, each in its tone's colour.
|
|
28
|
+
export function StatPills({ label, pills }) {
|
|
29
|
+
return (_jsx("ul", { "aria-label": label, "data-slot": "stat-pills", className: "flex flex-wrap gap-1.5 sm:flex-nowrap sm:overflow-x-auto", children: pills.map((pill) => (_jsx("li", { "data-stat": pill.key, "data-tone": pill.tone ?? "default", children: _jsxs(Badge, { variant: "tag", color: TONE_COLORS[pill.tone ?? "default"] ?? "default", children: [pill.label, " ", _jsx("strong", { className: "font-medium tabular-nums", children: pill.value })] }) }, pill.key))) }));
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=Stats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Stats.js","sourceRoot":"","sources":["../../src/notion/Stats.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface TabBarItem<Key extends string> {
|
|
3
|
+
readonly key: Key;
|
|
4
|
+
readonly label: string;
|
|
5
|
+
readonly href: string;
|
|
6
|
+
readonly icon?: ReactNode;
|
|
7
|
+
readonly count?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TabBarProps<Key extends string> {
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly tabs: readonly TabBarItem<Key>[];
|
|
12
|
+
readonly current: Key;
|
|
13
|
+
}
|
|
14
|
+
export declare function TabBar<Key extends string>({ label, tabs, current }: TabBarProps<Key>): import("react").JSX.Element;
|
|
15
|
+
//# sourceMappingURL=TabBar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TabBar.d.ts","sourceRoot":"","sources":["../../src/notion/TabBar.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,MAAM,WAAW,UAAU,CAAC,GAAG,SAAS,MAAM;IAE5C,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,CAAC;IAE1B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,WAAW,CAAC,GAAG,SAAS,MAAM;IAE7C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,SAAS,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;IAC1C,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC;CACvB;AAGD,wBAAgB,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,WAAW,CAAC,GAAG,CAAC,+BAyBpF"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A screen's tabs as line tabs where each tab is a link, so the shown tab lives in the address and survives a reload.
|
|
3
|
+
export function TabBar({ label, tabs, current }) {
|
|
4
|
+
return (_jsx("nav", { "aria-label": label, "data-slot": "tab-bar", className: "overflow-x-auto border-b border-border", children: _jsx("ul", { className: "flex gap-1 pb-1", children: tabs.map((tab) => (_jsx("li", { children: _jsxs("a", { href: tab.href, className: "relative inline-flex h-control-height items-center gap-1.5 rounded-md px-2 text-ui-medium whitespace-nowrap text-muted-foreground transition-colors duration-120 after:absolute after:inset-x-0 after:-bottom-1 after:h-0.5 after:bg-foreground after:opacity-0 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-[current=page]:text-foreground aria-[current=page]:after:opacity-100 [&_svg]:size-4 [&_svg]:shrink-0", "data-tab": tab.key, "aria-current": tab.key === current ? "page" : undefined, children: [tab.icon, tab.label, tab.count === undefined ? null : (_jsx("span", { "data-slot": "tab-bar-count", className: "rounded-xs bg-accent px-1 text-small text-muted-foreground tabular-nums", children: tab.count }))] }) }, tab.key))) }) }));
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=TabBar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TabBar.js","sourceRoot":"","sources":["../../src/notion/TabBar.tsx"],"names":[],"mappings":";AAoBA,sHAAsH;AACtH,MAAM,UAAU,MAAM,CAAqB,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAoB;IACnF,OAAO,CACL,4BAAiB,KAAK,eAAY,SAAS,EAAC,SAAS,EAAC,wCAAwC,YAC5F,aAAI,SAAS,EAAC,iBAAiB,YAC5B,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CACjB,uBACE,aACE,IAAI,EAAE,GAAG,CAAC,IAAI,EACd,SAAS,EAAC,ydAAyd,cACzd,GAAG,CAAC,GAAG,kBACH,GAAG,CAAC,GAAG,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,aAErD,GAAG,CAAC,IAAI,EACR,GAAG,CAAC,KAAK,EACT,GAAG,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAChC,4BAAgB,eAAe,EAAC,SAAS,EAAC,yEAAyE,YAChH,GAAG,CAAC,KAAK,GACL,CACR,IACC,IAdG,GAAG,CAAC,GAAG,CAeX,CACN,CAAC,GACC,GACD,CACP,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PaletteColor } from "./palette.js";
|
|
2
|
+
export declare const TONES: readonly ["default", "success", "warning", "danger", "muted"];
|
|
3
|
+
export type Tone = (typeof TONES)[number];
|
|
4
|
+
export declare const TONE_COLORS: Readonly<Record<Tone, PaletteColor | null>>;
|
|
5
|
+
export declare const TONE_TEXT: Readonly<Record<Tone, string>>;
|
|
6
|
+
export declare const TONE_FILL: Readonly<Record<Tone, string>>;
|
|
7
|
+
export declare const TONE_EDGE: Readonly<Record<Tone, string>>;
|
|
8
|
+
//# sourceMappingURL=tones.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tones.d.ts","sourceRoot":"","sources":["../../src/notion/tones.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAIjD,eAAO,MAAM,KAAK,+DAAgE,CAAC;AAEnF,MAAM,MAAM,IAAI,GAAG,CAAC,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC;AAG1C,eAAO,MAAM,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC,CAMnE,CAAC;AAKF,eAAO,MAAM,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAMpD,CAAC;AAGF,eAAO,MAAM,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAMpD,CAAC;AAGF,eAAO,MAAM,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAMpD,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A figure's colour by what it means: done, to do, owed or failing, out of play, or neutral. Each app keeps its own
|
|
2
|
+
// table of which meaning takes which tone ("booked" → success); the theme owns the colours and the class names.
|
|
3
|
+
export const TONES = ["default", "success", "warning", "danger", "muted"];
|
|
4
|
+
// A tone's palette colour; `default` has none and reads in the text colour.
|
|
5
|
+
export const TONE_COLORS = {
|
|
6
|
+
default: null,
|
|
7
|
+
success: "green",
|
|
8
|
+
warning: "orange",
|
|
9
|
+
danger: "red",
|
|
10
|
+
muted: "gray",
|
|
11
|
+
};
|
|
12
|
+
// Literal class names per tone, so Tailwind generates each one (the theme's `@source` covers this folder).
|
|
13
|
+
// A figure written in its tone's colour.
|
|
14
|
+
export const TONE_TEXT = {
|
|
15
|
+
default: "text-foreground",
|
|
16
|
+
success: "text-green",
|
|
17
|
+
warning: "text-orange",
|
|
18
|
+
danger: "text-red",
|
|
19
|
+
muted: "text-gray",
|
|
20
|
+
};
|
|
21
|
+
// A drawn share (a bar segment) in its tone's colour; the default is a quiet gray.
|
|
22
|
+
export const TONE_FILL = {
|
|
23
|
+
default: "bg-gray",
|
|
24
|
+
success: "bg-green",
|
|
25
|
+
warning: "bg-orange",
|
|
26
|
+
danger: "bg-red",
|
|
27
|
+
muted: "bg-gray",
|
|
28
|
+
};
|
|
29
|
+
// A box's leading edge in its tone's colour; the default is a quiet gray.
|
|
30
|
+
export const TONE_EDGE = {
|
|
31
|
+
default: "border-l-gray",
|
|
32
|
+
success: "border-l-green",
|
|
33
|
+
warning: "border-l-orange",
|
|
34
|
+
danger: "border-l-red",
|
|
35
|
+
muted: "border-l-gray",
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=tones.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tones.js","sourceRoot":"","sources":["../../src/notion/tones.ts"],"names":[],"mappings":"AAEA,oHAAoH;AACpH,gHAAgH;AAChH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAU,CAAC;AAInF,4EAA4E;AAC5E,MAAM,CAAC,MAAM,WAAW,GAAgD;IACtE,OAAO,EAAE,IAAI;IACb,OAAO,EAAE,OAAO;IAChB,OAAO,EAAE,QAAQ;IACjB,MAAM,EAAE,KAAK;IACb,KAAK,EAAE,MAAM;CACd,CAAC;AAEF,2GAA2G;AAE3G,yCAAyC;AACzC,MAAM,CAAC,MAAM,SAAS,GAAmC;IACvD,OAAO,EAAE,iBAAiB;IAC1B,OAAO,EAAE,YAAY;IACrB,OAAO,EAAE,aAAa;IACtB,MAAM,EAAE,UAAU;IAClB,KAAK,EAAE,WAAW;CACnB,CAAC;AAEF,mFAAmF;AACnF,MAAM,CAAC,MAAM,SAAS,GAAmC;IACvD,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,UAAU;IACnB,OAAO,EAAE,WAAW;IACpB,MAAM,EAAE,QAAQ;IAChB,KAAK,EAAE,SAAS;CACjB,CAAC;AAEF,0EAA0E;AAC1E,MAAM,CAAC,MAAM,SAAS,GAAmC;IACvD,OAAO,EAAE,eAAe;IACxB,OAAO,EAAE,gBAAgB;IACzB,OAAO,EAAE,iBAAiB;IAC1B,MAAM,EAAE,cAAc;IACtB,KAAK,EAAE,eAAe;CACvB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/theme-notion",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "SOFTURE AI Notion theme: shadcn/ui components (new-york v4, Radix, Tailwind CSS v4, lucide) with the Notion design system's tokens and deltas, ready to import.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"build": "tsc -p tsconfig.build.json && softure-theme-css"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@softure-ai/theme-kit": "^0.1.
|
|
54
|
+
"@softure-ai/theme-kit": "^0.1.2",
|
|
55
55
|
"class-variance-authority": "^0.7.1",
|
|
56
56
|
"cmdk": "^1.1.1",
|
|
57
57
|
"lucide-react": "^1.61.0",
|
package/src/index.ts
CHANGED
|
@@ -43,8 +43,25 @@ export * from "./ui/textarea.js";
|
|
|
43
43
|
export * from "./ui/tooltip.js";
|
|
44
44
|
|
|
45
45
|
export { ICON_STROKE_WIDTH } from "./notion/icon-stroke.js";
|
|
46
|
+
export { DetailTable, type DetailRow, type DetailTableProps } from "./notion/DetailTable.js";
|
|
47
|
+
export { FoldRow, type FoldRowBadge, type FoldRowProps } from "./notion/FoldRow.js";
|
|
46
48
|
export { PALETTE_COLORS, type PaletteColor } from "./notion/palette.js";
|
|
49
|
+
export { MarkPopover, type MarkPopoverProps } from "./notion/MarkPopover.js";
|
|
47
50
|
export { PageFrame, type PageFrameProps } from "./notion/PageFrame.js";
|
|
48
51
|
export { PageHeader, type PageCrumb, type PageHeaderProps } from "./notion/PageHeader.js";
|
|
49
52
|
export { PropertyList, type Property, type PropertyListProps } from "./notion/PropertyList.js";
|
|
53
|
+
export { SidePanel, type SidePanelLayout, type SidePanelProps } from "./notion/SidePanel.js";
|
|
54
|
+
export {
|
|
55
|
+
StatCards,
|
|
56
|
+
StatPills,
|
|
57
|
+
StatsFold,
|
|
58
|
+
type StatCard,
|
|
59
|
+
type StatCardAside,
|
|
60
|
+
type StatCardsProps,
|
|
61
|
+
type StatPill,
|
|
62
|
+
type StatPillsProps,
|
|
63
|
+
type StatsFoldProps,
|
|
64
|
+
} from "./notion/Stats.js";
|
|
50
65
|
export { Status, type StatusProps } from "./notion/Status.js";
|
|
66
|
+
export { TabBar, type TabBarItem, type TabBarProps } from "./notion/TabBar.js";
|
|
67
|
+
export { TONES, TONE_COLORS, TONE_EDGE, TONE_FILL, TONE_TEXT, type Tone } from "./notion/tones.js";
|
package/src/messages/en.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const en = {
|
|
|
6
6
|
breadcrumb: { label: "breadcrumb", more: "More" },
|
|
7
7
|
dialog: { close: "Close" },
|
|
8
8
|
sheet: { close: "Close" },
|
|
9
|
+
sidePanel: { close: "Close" },
|
|
9
10
|
sidebar: { mobileTitle: "Sidebar", mobileDescription: "Displays the mobile sidebar.", toggle: "Toggle Sidebar" },
|
|
10
11
|
spinner: { label: "Loading" },
|
|
11
12
|
command: { title: "Command Palette", description: "Search for a command to run..." },
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface DetailRow {
|
|
4
|
+
/** Unique within the table; kept on the row as `data-detail`. */
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly label: string;
|
|
7
|
+
/** `null`, `undefined` or "": nothing to show, the row is left out. */
|
|
8
|
+
readonly value: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DetailTableProps {
|
|
12
|
+
/** The table's accessible name. */
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly rows: readonly DetailRow[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A row header in a narrow muted column, a quiet line under every row; on a phone the label sits above its value.
|
|
18
|
+
const DETAIL_TABLE_CLASS = [
|
|
19
|
+
"w-full border-collapse text-ui",
|
|
20
|
+
"[&_tr]:border-b [&_tr]:border-border",
|
|
21
|
+
"[&_th]:w-48 [&_th]:py-1.5 [&_th]:pr-3 [&_th]:text-left [&_th]:align-top [&_th]:font-normal [&_th]:text-muted-foreground",
|
|
22
|
+
"[&_td]:py-1.5 [&_td]:align-top [&_td]:wrap-anywhere",
|
|
23
|
+
"max-sm:[&_tr]:block max-sm:[&_tr]:py-1.5 max-sm:[&_th]:block max-sm:[&_th]:w-auto max-sm:[&_th]:py-0 max-sm:[&_td]:block max-sm:[&_td]:py-0",
|
|
24
|
+
].join(" ");
|
|
25
|
+
|
|
26
|
+
function hasValue(row: DetailRow): boolean {
|
|
27
|
+
return row.value !== null && row.value !== undefined && row.value !== "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// An item's details as a label and value table in the Notion table style; nothing renders when no row has a value.
|
|
31
|
+
export function DetailTable({ label, rows }: DetailTableProps) {
|
|
32
|
+
const shown = rows.filter(hasValue);
|
|
33
|
+
if (shown.length === 0) return null;
|
|
34
|
+
return (
|
|
35
|
+
<table data-slot="detail-table" className={DETAIL_TABLE_CLASS} aria-label={label}>
|
|
36
|
+
<tbody>
|
|
37
|
+
{shown.map((row) => (
|
|
38
|
+
<tr key={row.key} data-detail={row.key}>
|
|
39
|
+
<th scope="row">{row.label}</th>
|
|
40
|
+
<td>{row.value}</td>
|
|
41
|
+
</tr>
|
|
42
|
+
))}
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { Badge } from "../ui/badge.js";
|
|
5
|
+
|
|
6
|
+
import { DetailTable, type DetailRow } from "./DetailTable.js";
|
|
7
|
+
import { ICON_STROKE_WIDTH } from "./icon-stroke.js";
|
|
8
|
+
import { TONE_COLORS, type Tone } from "./tones.js";
|
|
9
|
+
|
|
10
|
+
export interface FoldRowBadge {
|
|
11
|
+
/** Unique within the row; kept on the badge as `data-badge`. */
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly tone?: Tone;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FoldRowProps {
|
|
18
|
+
readonly title: ReactNode;
|
|
19
|
+
/** Short facts after the title; left out when empty. */
|
|
20
|
+
readonly facts?: readonly ReactNode[];
|
|
21
|
+
/** Badges on the row's right, in order. */
|
|
22
|
+
readonly badges: readonly FoldRowBadge[];
|
|
23
|
+
/** The details table's accessible name. */
|
|
24
|
+
readonly detailsLabel: string;
|
|
25
|
+
readonly details: readonly DetailRow[];
|
|
26
|
+
/** More content under the table when opened. */
|
|
27
|
+
readonly children?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// An item as one folding list row: a native `<details>` that opens in place to its details, with no client state.
|
|
31
|
+
export function FoldRow({ title, facts = [], badges, detailsLabel, details, children }: FoldRowProps) {
|
|
32
|
+
return (
|
|
33
|
+
<details data-slot="fold-row" className="group/fold-row border-b border-border">
|
|
34
|
+
<summary
|
|
35
|
+
data-slot="fold-row-summary"
|
|
36
|
+
className="flex min-h-row-height cursor-pointer list-none flex-wrap items-center gap-x-2 gap-y-1 rounded-md px-1 py-1 transition-colors duration-120 group-open/fold-row:bg-accent hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [&::-webkit-details-marker]:hidden"
|
|
37
|
+
>
|
|
38
|
+
{/* The chevron and the title wrap as one: a long title folds onto lines beside its chevron. */}
|
|
39
|
+
<span data-slot="fold-row-heading" className="flex min-w-0 items-center gap-2">
|
|
40
|
+
<ChevronRight
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
strokeWidth={ICON_STROKE_WIDTH}
|
|
43
|
+
className="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/fold-row:rotate-90"
|
|
44
|
+
/>
|
|
45
|
+
<span className="min-w-0 text-ui-medium">{title}</span>
|
|
46
|
+
</span>
|
|
47
|
+
{facts.map((fact, index) => (
|
|
48
|
+
// Facts have no identity of their own; their order is fixed for one render.
|
|
49
|
+
<span key={index} data-slot="fold-row-fact" className="text-ui text-muted-foreground">
|
|
50
|
+
{fact}
|
|
51
|
+
</span>
|
|
52
|
+
))}
|
|
53
|
+
{badges.length === 0 ? null : (
|
|
54
|
+
<span data-slot="fold-row-badges" className="ml-auto inline-flex flex-wrap gap-1">
|
|
55
|
+
{badges.map((badge) => {
|
|
56
|
+
const tone = badge.tone ?? "default";
|
|
57
|
+
return (
|
|
58
|
+
<Badge
|
|
59
|
+
key={badge.key}
|
|
60
|
+
variant="tag"
|
|
61
|
+
color={TONE_COLORS[tone] ?? "default"}
|
|
62
|
+
data-badge={badge.key}
|
|
63
|
+
data-tone={tone}
|
|
64
|
+
>
|
|
65
|
+
{badge.text}
|
|
66
|
+
</Badge>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</summary>
|
|
72
|
+
<div data-slot="fold-row-content" className="flex flex-col gap-2 py-2 pr-1 pl-6 empty:hidden">
|
|
73
|
+
<DetailTable label={detailsLabel} rows={details} />
|
|
74
|
+
{children}
|
|
75
|
+
</div>
|
|
76
|
+
</details>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { LucideIcon } from "lucide-react";
|
|
4
|
+
import { useId, type MouseEvent, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { badgeVariants } from "../ui/badge.js";
|
|
7
|
+
import { buttonVariants } from "../ui/button.js";
|
|
8
|
+
import { cn } from "../lib/utils.js";
|
|
9
|
+
|
|
10
|
+
import { ICON_STROKE_WIDTH } from "./icon-stroke.js";
|
|
11
|
+
import type { PaletteColor } from "./palette.js";
|
|
12
|
+
|
|
13
|
+
export interface MarkPopoverProps {
|
|
14
|
+
/** The mark's colour: a palette colour that means the worst of what it lists. */
|
|
15
|
+
readonly color: PaletteColor;
|
|
16
|
+
readonly icon: LucideIcon;
|
|
17
|
+
readonly count: number;
|
|
18
|
+
/** The mark's accessible name: what it counts and that it opens a list. */
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly heading: string;
|
|
21
|
+
readonly closeLabel: string;
|
|
22
|
+
/** Extra `data-*` attributes kept on the button. */
|
|
23
|
+
readonly buttonData?: Readonly<Record<`data-${string}`, string | undefined>>;
|
|
24
|
+
/** The entries, as phrasing elements with `role="listitem"`. */
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A counted tag (open warnings, tickets to buy) that opens a list of what it counts. The browser's Popover API opens
|
|
29
|
+
// it on click or Enter and closes it on Escape or a click outside, with no client state; the panel sits in the top
|
|
30
|
+
// layer, so a card's overflow never clips it. Centred on a phone, under its mark where CSS anchor positioning works.
|
|
31
|
+
// Phrasing elements only, so the mark fits inside a `<summary>` or a paragraph.
|
|
32
|
+
export function MarkPopover({ color, icon: Icon, count, label, heading, closeLabel, buttonData, children }: MarkPopoverProps) {
|
|
33
|
+
const panelId = useId();
|
|
34
|
+
return (
|
|
35
|
+
<span data-slot="mark" className="inline-flex [anchor-scope:--mark]">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
className={cn(
|
|
39
|
+
badgeVariants({ variant: "tag", color }),
|
|
40
|
+
"cursor-pointer gap-1 font-medium tabular-nums [anchor-name:--mark] focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
|
41
|
+
)}
|
|
42
|
+
{...buttonData}
|
|
43
|
+
popoverTarget={panelId}
|
|
44
|
+
aria-haspopup="dialog"
|
|
45
|
+
aria-label={label}
|
|
46
|
+
>
|
|
47
|
+
<Icon aria-hidden="true" strokeWidth={ICON_STROKE_WIDTH} />
|
|
48
|
+
<span aria-hidden="true">{count}</span>
|
|
49
|
+
</button>
|
|
50
|
+
<span
|
|
51
|
+
id={panelId}
|
|
52
|
+
popover="auto"
|
|
53
|
+
role="dialog"
|
|
54
|
+
aria-labelledby={`${panelId}-heading`}
|
|
55
|
+
data-slot="mark-popover"
|
|
56
|
+
className="max-h-[min(24rem,calc(100vh-2rem))] w-[min(22rem,calc(100vw-2rem))] cursor-auto overflow-y-auto rounded-xl bg-popover p-3 text-left text-ui font-normal whitespace-normal text-popover-foreground shadow-menu backdrop:bg-transparent sm:supports-[position-anchor:--mark]:mt-1 sm:supports-[position-anchor:--mark]:mr-0 sm:supports-[position-anchor:--mark]:mb-0 sm:supports-[position-anchor:--mark]:ml-0 sm:supports-[position-anchor:--mark]:[position-anchor:--mark] sm:supports-[position-anchor:--mark]:[position-area:block-end_span-inline-start] sm:supports-[position-anchor:--mark]:[position-try-fallbacks:flip-block,flip-inline]"
|
|
57
|
+
onClick={keepEnclosingDetailsFolded}
|
|
58
|
+
>
|
|
59
|
+
<span className="mb-2 flex items-center justify-between gap-2">
|
|
60
|
+
<span id={`${panelId}-heading`} className="text-ui-medium">
|
|
61
|
+
{heading}
|
|
62
|
+
</span>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className={buttonVariants({ variant: "ghost", size: "xs" })}
|
|
66
|
+
popoverTarget={panelId}
|
|
67
|
+
popoverTargetAction="hide"
|
|
68
|
+
>
|
|
69
|
+
{closeLabel}
|
|
70
|
+
</button>
|
|
71
|
+
</span>
|
|
72
|
+
<span role="list" className="flex flex-col gap-2">
|
|
73
|
+
{children}
|
|
74
|
+
</span>
|
|
75
|
+
</span>
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A click on the panel's text would toggle an enclosing `<details>` (the mark sits in its summary); links and
|
|
81
|
+
// buttons inside keep their default.
|
|
82
|
+
function keepEnclosingDetailsFolded(event: MouseEvent<HTMLElement>): void {
|
|
83
|
+
if (event.target instanceof Element && event.target.closest("a, button") === null) event.preventDefault();
|
|
84
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface PageCrumb {
|
|
|
19
19
|
export interface PageHeaderProps {
|
|
20
20
|
title: ReactNode;
|
|
21
21
|
crumbs?: PageCrumb[];
|
|
22
|
+
// The breadcrumb's accessible name in the app's language; the built-in English one when absent.
|
|
23
|
+
crumbsLabel?: string;
|
|
22
24
|
// A lucide icon, never an emoji.
|
|
23
25
|
icon?: ReactNode;
|
|
24
26
|
description?: ReactNode;
|
|
@@ -29,11 +31,11 @@ export interface PageHeaderProps {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// The page's header, once per screen: breadcrumb, page icon, the 40 px title, what follows it, a description and actions.
|
|
32
|
-
export function PageHeader({ title, crumbs, icon, description, actions, children, className }: PageHeaderProps) {
|
|
34
|
+
export function PageHeader({ title, crumbs, crumbsLabel, icon, description, actions, children, className }: PageHeaderProps) {
|
|
33
35
|
return (
|
|
34
36
|
<header data-slot="page-header" className={cn("flex flex-col", className)}>
|
|
35
37
|
{crumbs && crumbs.length > 0 ? (
|
|
36
|
-
<Breadcrumb>
|
|
38
|
+
<Breadcrumb {...(crumbsLabel === undefined ? {} : { "aria-label": crumbsLabel })}>
|
|
37
39
|
<BreadcrumbList>
|
|
38
40
|
{crumbs.map((crumb, index) => (
|
|
39
41
|
<Fragment key={`${index}-${crumb.label}`}>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { en as messages } from "../messages/en.js";
|
|
7
|
+
import { Button } from "../ui/button.js";
|
|
8
|
+
|
|
9
|
+
import { ICON_STROKE_WIDTH } from "./icon-stroke.js";
|
|
10
|
+
|
|
11
|
+
// `over` and `left` float over the canvas's right or left side; `beside` and `list` sit next to it (after or before)
|
|
12
|
+
// and shrink it, and cover the whole canvas on a phone.
|
|
13
|
+
export type SidePanelLayout = "over" | "beside" | "left" | "list";
|
|
14
|
+
|
|
15
|
+
export interface SidePanelProps {
|
|
16
|
+
// Also the `data-testid`; the opener points at it with `aria-controls`.
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly heading: string;
|
|
19
|
+
readonly layout: SidePanelLayout;
|
|
20
|
+
readonly isOpen: boolean;
|
|
21
|
+
// The close button and Escape; the caller moves focus back to its opener.
|
|
22
|
+
readonly onClose: () => void;
|
|
23
|
+
// The close button's accessible name, from the app's message layer.
|
|
24
|
+
readonly closeLabel?: string;
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A non-modal panel at a side of a canvas (a map) that leaves the canvas usable. Its parent is a `relative` flex row
|
|
29
|
+
// holding the canvas and the panel. Hidden while closed, so its content leaves the reading order.
|
|
30
|
+
export function SidePanel({ id, heading, layout, isOpen, onClose, closeLabel = messages.sidePanel.close, children }: SidePanelProps) {
|
|
31
|
+
const panelRef = useRef<HTMLElement>(null);
|
|
32
|
+
const wasOpenRef = useRef(isOpen);
|
|
33
|
+
const headingId = `${id}-heading`;
|
|
34
|
+
|
|
35
|
+
// Focus moves in when the panel opens, not when it is open on first render.
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (isOpen && !wasOpenRef.current) panelRef.current?.focus();
|
|
38
|
+
wasOpenRef.current = isOpen;
|
|
39
|
+
}, [isOpen]);
|
|
40
|
+
|
|
41
|
+
function closeOnEscape(event: KeyboardEvent<HTMLElement>) {
|
|
42
|
+
if (event.key !== "Escape") return;
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
onClose();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<aside
|
|
49
|
+
ref={panelRef}
|
|
50
|
+
id={id}
|
|
51
|
+
tabIndex={-1}
|
|
52
|
+
data-slot="side-panel"
|
|
53
|
+
className="absolute inset-y-0 right-0 z-3 flex w-[min(24rem,100%)] flex-col border-l border-border bg-background text-foreground shadow-menu outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-[layout=beside]:relative data-[layout=beside]:w-auto data-[layout=beside]:max-w-full data-[layout=beside]:flex-[0_0_max(50%,24rem)] data-[layout=left]:right-auto data-[layout=left]:left-0 data-[layout=left]:border-r data-[layout=left]:border-l-0 max-sm:data-[layout=beside]:absolute max-sm:data-[layout=beside]:inset-0 max-sm:data-[layout=beside]:w-full max-sm:data-[layout=beside]:border-l-0 data-[layout=list]:relative data-[layout=list]:flex-none data-[layout=list]:border-r data-[layout=list]:border-l-0 max-sm:data-[layout=list]:absolute max-sm:data-[layout=list]:inset-0 max-sm:data-[layout=list]:w-full max-sm:data-[layout=list]:border-r-0 [&[hidden]]:hidden"
|
|
54
|
+
data-layout={layout}
|
|
55
|
+
hidden={!isOpen}
|
|
56
|
+
aria-labelledby={headingId}
|
|
57
|
+
data-testid={id}
|
|
58
|
+
onKeyDown={closeOnEscape}
|
|
59
|
+
>
|
|
60
|
+
<div className="flex items-center justify-between gap-2 border-b border-border py-2 pr-2 pl-4">
|
|
61
|
+
<h2 id={headingId} className="text-heading-3">
|
|
62
|
+
{heading}
|
|
63
|
+
</h2>
|
|
64
|
+
<Button type="button" variant="ghost" size="icon" aria-label={closeLabel} onClick={onClose}>
|
|
65
|
+
<X aria-hidden="true" strokeWidth={ICON_STROKE_WIDTH} />
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
<div data-slot="side-panel-content" className="grid flex-1 content-start gap-3 overflow-y-auto p-3">
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
</aside>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { ChevronRight } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { Badge } from "../ui/badge.js";
|
|
5
|
+
import { cn } from "../lib/utils.js";
|
|
6
|
+
|
|
7
|
+
import { ICON_STROKE_WIDTH } from "./icon-stroke.js";
|
|
8
|
+
import { TONE_COLORS, TONE_TEXT, type Tone } from "./tones.js";
|
|
9
|
+
|
|
10
|
+
export interface StatsFoldProps {
|
|
11
|
+
/** The section's heading. */
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly children: ReactNode;
|
|
14
|
+
readonly className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A screen's summary figures folded under a heading, closed by default: the design system's collapsible (a ghost row
|
|
18
|
+
// with a chevron that turns when open, the content indented under it), on native `<details>` so it needs no client.
|
|
19
|
+
export function StatsFold({ title, children, className }: StatsFoldProps) {
|
|
20
|
+
return (
|
|
21
|
+
<details data-slot="stats-fold" className={cn("group/stats-fold", className)}>
|
|
22
|
+
<summary
|
|
23
|
+
data-slot="stats-fold-summary"
|
|
24
|
+
className="flex h-row-height cursor-pointer list-none items-center gap-1 rounded-md px-1 text-foreground transition-colors duration-120 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [&::-webkit-details-marker]:hidden"
|
|
25
|
+
>
|
|
26
|
+
<ChevronRight
|
|
27
|
+
aria-hidden="true"
|
|
28
|
+
strokeWidth={ICON_STROKE_WIDTH}
|
|
29
|
+
className="size-4 shrink-0 text-muted-foreground transition-transform duration-150 group-open/stats-fold:rotate-90"
|
|
30
|
+
/>
|
|
31
|
+
<h2 className="text-ui-medium">{title}</h2>
|
|
32
|
+
</summary>
|
|
33
|
+
<div data-slot="stats-fold-content" className="flex flex-col gap-3 pt-2 pb-1 pl-6">
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
</details>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface StatCardAside {
|
|
41
|
+
readonly text: string;
|
|
42
|
+
readonly tone?: Tone;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface StatCard {
|
|
46
|
+
/** Unique within the row; kept as `data-stat`. */
|
|
47
|
+
readonly key: string;
|
|
48
|
+
readonly label: string;
|
|
49
|
+
readonly value: string;
|
|
50
|
+
readonly hint?: string;
|
|
51
|
+
readonly tone?: Tone;
|
|
52
|
+
readonly aside?: StatCardAside;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface StatCardsProps {
|
|
56
|
+
/** The list's accessible name. */
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly cards: readonly StatCard[];
|
|
59
|
+
/** The cards stretch across the column (min 14rem) instead of the narrow grid (min 10rem). */
|
|
60
|
+
readonly isFullWidth?: boolean;
|
|
61
|
+
/** Every card in one row from `sm` up, wrapping only on a phone. */
|
|
62
|
+
readonly isOneRow?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getStatCardsLayout(isFullWidth: boolean, isOneRow: boolean): "row" | "full" | "grid" {
|
|
66
|
+
if (isOneRow) return "row";
|
|
67
|
+
return isFullWidth ? "full" : "grid";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Summary figures as a row of cards wrapping on a narrow screen: the label, the figure in its tone's colour (never
|
|
71
|
+
// broken, tabular numbers), an optional note under it and an optional short figure on its right as a tag.
|
|
72
|
+
export function StatCards({ label, cards, isFullWidth = false, isOneRow = false }: StatCardsProps) {
|
|
73
|
+
return (
|
|
74
|
+
<ul
|
|
75
|
+
aria-label={label}
|
|
76
|
+
data-slot="stat-cards"
|
|
77
|
+
data-layout={getStatCardsLayout(isFullWidth, isOneRow)}
|
|
78
|
+
className={cn(
|
|
79
|
+
"grid gap-2",
|
|
80
|
+
isFullWidth
|
|
81
|
+
? "grid-cols-[repeat(auto-fit,minmax(min(14rem,100%),1fr))]"
|
|
82
|
+
: "grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))]",
|
|
83
|
+
isOneRow && "sm:auto-cols-fr sm:grid-flow-col sm:grid-cols-none",
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{cards.map((card) => {
|
|
87
|
+
const tone = card.tone ?? "default";
|
|
88
|
+
return (
|
|
89
|
+
<li
|
|
90
|
+
key={card.key}
|
|
91
|
+
data-stat={card.key}
|
|
92
|
+
className="flex min-w-0 items-start justify-between gap-2 rounded-lg bg-card p-3 text-card-foreground shadow-card"
|
|
93
|
+
>
|
|
94
|
+
<div className="min-w-0">
|
|
95
|
+
<div className="text-section-label text-muted-foreground">{card.label}</div>
|
|
96
|
+
<div data-tone={tone} className={cn("mt-1 text-heading-3 whitespace-nowrap tabular-nums", TONE_TEXT[tone])}>
|
|
97
|
+
{card.value}
|
|
98
|
+
</div>
|
|
99
|
+
{card.hint === undefined ? null : <div className="mt-1 text-small text-muted-foreground">{card.hint}</div>}
|
|
100
|
+
</div>
|
|
101
|
+
{card.aside === undefined ? null : (
|
|
102
|
+
<Badge
|
|
103
|
+
variant="tag"
|
|
104
|
+
color={TONE_COLORS[card.aside.tone ?? "default"] ?? "default"}
|
|
105
|
+
data-slot="stat-card-aside"
|
|
106
|
+
data-tone={card.aside.tone ?? "default"}
|
|
107
|
+
className="text-small"
|
|
108
|
+
>
|
|
109
|
+
{card.aside.text}
|
|
110
|
+
</Badge>
|
|
111
|
+
)}
|
|
112
|
+
</li>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</ul>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface StatPill {
|
|
120
|
+
readonly key: string;
|
|
121
|
+
readonly label: string;
|
|
122
|
+
readonly value: string;
|
|
123
|
+
readonly tone?: Tone;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface StatPillsProps {
|
|
127
|
+
/** The list's accessible name. */
|
|
128
|
+
readonly label: string;
|
|
129
|
+
readonly pills: readonly StatPill[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Small counts as "label **value**" tags in one row, wrapping only on a phone, each in its tone's colour.
|
|
133
|
+
export function StatPills({ label, pills }: StatPillsProps) {
|
|
134
|
+
return (
|
|
135
|
+
<ul aria-label={label} data-slot="stat-pills" className="flex flex-wrap gap-1.5 sm:flex-nowrap sm:overflow-x-auto">
|
|
136
|
+
{pills.map((pill) => (
|
|
137
|
+
<li key={pill.key} data-stat={pill.key} data-tone={pill.tone ?? "default"}>
|
|
138
|
+
<Badge variant="tag" color={TONE_COLORS[pill.tone ?? "default"] ?? "default"}>
|
|
139
|
+
{pill.label} <strong className="font-medium tabular-nums">{pill.value}</strong>
|
|
140
|
+
</Badge>
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
);
|
|
145
|
+
}
|