@graph8/sdk 0.361.0 → 0.362.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.
Files changed (94) hide show
  1. package/blocks/_foundation/README.md +108 -0
  2. package/blocks/_foundation/block.json +160 -12
  3. package/blocks/_foundation/components/api-error.tsx +62 -18
  4. package/blocks/_foundation/components/empty-state.tsx +39 -7
  5. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  6. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  7. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  8. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  9. package/blocks/_foundation/components/screen/index.ts +21 -0
  10. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  11. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  12. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  13. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  14. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  15. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  16. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  17. package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
  18. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  19. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  20. package/blocks/appointments/README.md +21 -1
  21. package/blocks/appointments/block.json +39 -2
  22. package/blocks/appointments/components/event-type-related.tsx +75 -0
  23. package/blocks/appointments/components/meeting-list.tsx +8 -4
  24. package/blocks/appointments/components/meetings-screen.tsx +154 -0
  25. package/blocks/appointments/components/scheduling-screen.tsx +121 -0
  26. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  27. package/blocks/block.types.ts +9 -0
  28. package/blocks/deals/README.md +34 -3
  29. package/blocks/deals/block.json +39 -4
  30. package/blocks/deals/components/deal-detail.tsx +4 -2
  31. package/blocks/deals/components/deal-related.tsx +87 -0
  32. package/blocks/deals/components/deals-screen.tsx +293 -0
  33. package/blocks/deals/components/pipeline-board.tsx +4 -2
  34. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  35. package/blocks/deals/lib/deals-types.ts +14 -0
  36. package/blocks/forms/README.md +12 -0
  37. package/blocks/forms/block.json +9 -1
  38. package/blocks/forms/components/forms-screen.tsx +350 -0
  39. package/blocks/forms/components/submissions-table.tsx +13 -5
  40. package/blocks/lifecycle/README.md +16 -0
  41. package/blocks/lifecycle/block.json +23 -1
  42. package/blocks/lifecycle/components/account-related.tsx +66 -0
  43. package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
  44. package/blocks/newsletter/README.md +13 -0
  45. package/blocks/newsletter/block.json +9 -1
  46. package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
  47. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  48. package/blocks/nurture/README.md +12 -0
  49. package/blocks/nurture/block.json +9 -1
  50. package/blocks/nurture/components/nurture-screen.tsx +279 -0
  51. package/blocks/pages/README.md +12 -0
  52. package/blocks/pages/block.json +9 -1
  53. package/blocks/pages/components/content-library.tsx +37 -7
  54. package/blocks/pages/components/page-list.tsx +4 -2
  55. package/blocks/pages/components/pages-screen.tsx +154 -0
  56. package/blocks/quotes/README.md +17 -0
  57. package/blocks/quotes/block.json +26 -2
  58. package/blocks/quotes/components/quote-list.tsx +7 -3
  59. package/blocks/quotes/components/quote-related.tsx +120 -0
  60. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  61. package/dist/cli.js +197 -37
  62. package/dist/index.browser.js +1 -1
  63. package/dist/index.browser.js.map +1 -1
  64. package/dist/index.browser.mjs +1 -1
  65. package/dist/index.browser.mjs.map +1 -1
  66. package/dist/index.js +1 -1
  67. package/dist/index.js.map +1 -1
  68. package/dist/index.mjs +1 -1
  69. package/dist/index.mjs.map +1 -1
  70. package/dist/react.browser.js +1 -1
  71. package/dist/react.browser.js.map +1 -1
  72. package/dist/react.browser.mjs +1 -1
  73. package/dist/react.browser.mjs.map +1 -1
  74. package/dist/react.js +1 -1
  75. package/dist/react.js.map +1 -1
  76. package/dist/react.mjs +1 -1
  77. package/dist/react.mjs.map +1 -1
  78. package/dist/types/blocks/create.d.ts +15 -1
  79. package/dist/types/blocks/doctor.d.ts +2 -1
  80. package/dist/types/blocks/flows.d.ts +20 -0
  81. package/dist/types/blocks/registry.d.ts +15 -0
  82. package/dist/types/version.d.ts +2 -2
  83. package/package.json +1 -1
  84. package/stacks/stack.schema.json +6 -1
  85. package/styles/styles.css +1 -1
  86. package/templates/next-app/README.md.tmpl +16 -0
  87. package/templates/next-app/app/globals.css.tmpl +105 -0
  88. package/templates/next-app/app/layout.tsx.tmpl +11 -2
  89. package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
  90. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  91. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  92. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  93. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  94. package/templates/next-app/lib/nav.ts.tmpl +1 -1
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SplitView: list on the left, the selected record on the right. On wide screens the detail is a
5
+ * fixed-width pane with a hairline; under `breakpoint` (default 1024px, any value works) it becomes a right Sheet so the list
6
+ * keeps its full width on tablets and phones. Pair it with `useUrlState("id")` so the open record
7
+ * survives reload and Back. `expanded` turns the detail into a full-page record screen (the list
8
+ * steps aside, a Back affordance returns to it); pair it with `useUrlState("view") === "full"` so
9
+ * `?id=X&view=full` deep-links to the full page.
10
+ *
11
+ * SDK operations: none.
12
+ */
13
+ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
14
+ import { ArrowLeft } from "lucide-react";
15
+ import { Button, Sheet, SheetContent, SheetDescription, SheetTitle, cn } from "@graph8/sdk/ui";
16
+
17
+ export interface SplitViewProps {
18
+ list: ReactNode;
19
+ detail?: ReactNode;
20
+ detailOpen: boolean;
21
+ onCloseDetail: () => void;
22
+ detailWidth?: "md" | "lg" | "xl";
23
+ /** Accessible name for the sheet on narrow screens. */
24
+ detailTitle?: string;
25
+ /** Pixel width under which the detail becomes a sheet. Default 1024. */
26
+ breakpoint?: number;
27
+ /** Full-page mode: the detail takes the whole content area and the list steps aside. */
28
+ expanded?: boolean;
29
+ /** Called with false from the Back affordance; wire it to `useUrlState("view")`. */
30
+ onExpandedChange?: (expanded: boolean) => void;
31
+ /** Label of the Back affordance in full-page mode. Default "Back to list". */
32
+ backLabel?: string;
33
+ className?: string;
34
+ listClassName?: string;
35
+ detailClassName?: string;
36
+ }
37
+
38
+ const WIDTH: Record<NonNullable<SplitViewProps["detailWidth"]>, { pane: string; sheet: string }> = {
39
+ md: { pane: "lg:w-[24rem] xl:w-[26rem]", sheet: "sm:max-w-md" },
40
+ lg: { pane: "lg:w-[30rem] xl:w-[34rem]", sheet: "sm:max-w-lg" },
41
+ xl: { pane: "lg:w-[36rem] xl:w-[42rem] 2xl:w-[48rem]", sheet: "sm:max-w-2xl" },
42
+ };
43
+
44
+ /**
45
+ * Pane width with a custom `breakpoint`: the `lg:`-prefixed classes above do not apply below 1024px, so the
46
+ * pane gets a fixed width (the `lg` step) capped at 60% of the row, and the list keeps the rest.
47
+ */
48
+ const CUSTOM_PANE: Record<NonNullable<SplitViewProps["detailWidth"]>, CSSProperties> = {
49
+ md: { width: "24rem", maxWidth: "60%" },
50
+ lg: { width: "30rem", maxWidth: "60%" },
51
+ xl: { width: "36rem", maxWidth: "60%" },
52
+ };
53
+
54
+ /** SSR-safe media query: false on the server and the first client render, then live. */
55
+ export function useMediaQuery(query: string): boolean {
56
+ const [matches, setMatches] = useState(false);
57
+ useEffect(() => {
58
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
59
+ const mql = window.matchMedia(query);
60
+ const update = () => setMatches(mql.matches);
61
+ update();
62
+ mql.addEventListener?.("change", update);
63
+ return () => mql.removeEventListener?.("change", update);
64
+ }, [query]);
65
+ return matches;
66
+ }
67
+
68
+ export function SplitView({ list, detail, detailOpen, onCloseDetail, detailWidth = "lg", detailTitle = "Details", breakpoint = 1024, expanded, onExpandedChange, backLabel = "Back to list", className, listClassName, detailClassName }: SplitViewProps) {
69
+ const narrow = useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
70
+ const open = detailOpen && detail != null;
71
+ const w = WIDTH[detailWidth] ?? WIDTH.lg;
72
+ const defaultBreakpoint = breakpoint === 1024;
73
+
74
+ if (open && expanded) {
75
+ return (
76
+ <div className={cn("flex h-full min-h-0 w-full min-w-0 flex-col", className)} data-testid="split-view" data-detail-open="true" data-expanded="true">
77
+ <div className="flex shrink-0 items-center gap-2 border-b border-border bg-background px-2 py-1.5">
78
+ <Button
79
+ type="button"
80
+ variant="ghost"
81
+ size="sm"
82
+ className="h-7 gap-1.5 px-2 text-text-color-muted hover:text-foreground"
83
+ onClick={() => (onExpandedChange ? onExpandedChange(false) : onCloseDetail())}
84
+ >
85
+ <ArrowLeft className="size-4" strokeWidth={1.5} aria-hidden />
86
+ {backLabel}
87
+ </Button>
88
+ </div>
89
+ <div className={cn("flex min-h-0 flex-1 flex-col overflow-hidden", detailClassName)} data-testid="split-view-full">
90
+ {detail}
91
+ </div>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <div className={cn("flex h-full min-h-0 w-full min-w-0", className)} data-testid="split-view" data-detail-open={open ? "true" : "false"}>
98
+ <div className={cn("min-h-0 min-w-0 flex-1", listClassName)}>{list}</div>
99
+ {open && !narrow ? (
100
+ <aside
101
+ aria-label={detailTitle}
102
+ className={cn(
103
+ "min-h-0 shrink-0 flex-col overflow-hidden border-l border-border bg-background",
104
+ // The default breakpoint matches Tailwind's lg, so CSS keeps the pane hidden on narrow screens
105
+ // before the media query resolves (SSR), and the lg:/xl: width classes apply. A custom breakpoint
106
+ // relies on the media query alone and gets an explicit width so the list keeps its share.
107
+ defaultBreakpoint ? cn("hidden w-full lg:flex", w.pane) : "flex",
108
+ detailClassName,
109
+ )}
110
+ style={defaultBreakpoint ? undefined : (CUSTOM_PANE[detailWidth] ?? CUSTOM_PANE.lg)}
111
+ data-testid="split-view-detail"
112
+ >
113
+ {detail}
114
+ </aside>
115
+ ) : null}
116
+ <Sheet open={open && narrow} onOpenChange={(next) => (!next ? onCloseDetail() : undefined)}>
117
+ <SheetContent side="right" hideCloseButton className={cn("w-full gap-0 p-0 sm:w-full", w.sheet, detailClassName)} data-testid="split-view-sheet">
118
+ <SheetTitle className="sr-only">{detailTitle}</SheetTitle>
119
+ <SheetDescription className="sr-only">Selected record</SheetDescription>
120
+ <div className="flex h-full min-h-0 flex-col">{detail}</div>
121
+ </SheetContent>
122
+ </Sheet>
123
+ </div>
124
+ );
125
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ /**
4
+ * StatStrip: a row of KPI tiles (label, big number, delta, hint, sparkline) for the top of a screen.
5
+ * Numbers are ink; only a delta takes colour, and only through `tone` (good or bad FOR THE USER).
6
+ * A delta without a tone is an ink arrow and number.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import type { ReactNode } from "react";
11
+ import { ArrowDown, ArrowUp, Info } from "lucide-react";
12
+ import { Skeleton, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@graph8/sdk/ui";
13
+ import { Sparkline } from "./sparkline";
14
+ import { TONE_CLASSES, type Tone } from "./status-pill";
15
+
16
+ export interface StatItem {
17
+ label: string;
18
+ value: ReactNode;
19
+ /** Change versus the previous period, in percent (12 = up 12%). */
20
+ delta?: number;
21
+ /** Explanation shown in a tooltip on the label. */
22
+ hint?: string;
23
+ /** Colours the delta. Omit when the direction has no good or bad meaning. */
24
+ tone?: Tone;
25
+ sparkline?: number[];
26
+ /** Optional link or button rendered under the value ("View all"). */
27
+ footer?: ReactNode;
28
+ onClick?: () => void;
29
+ }
30
+
31
+ export interface StatStripProps {
32
+ items: StatItem[];
33
+ loading?: boolean;
34
+ /** Tiles per row on wide screens. Defaults to the number of items, up to 6. */
35
+ columns?: number;
36
+ className?: string;
37
+ }
38
+
39
+ export function formatDelta(delta: number): string {
40
+ const abs = Math.abs(delta);
41
+ const text = abs >= 100 ? abs.toFixed(0) : abs >= 10 ? abs.toFixed(1).replace(/\.0$/, "") : abs.toFixed(1).replace(/\.0$/, "");
42
+ return `${text}%`;
43
+ }
44
+
45
+ function Delta({ delta, tone }: { delta: number; tone?: Tone }) {
46
+ const Arrow = delta < 0 ? ArrowDown : ArrowUp;
47
+ const colour = tone ? TONE_CLASSES[tone].text : "text-foreground";
48
+ return (
49
+ <span className={cn("inline-flex items-center gap-0.5 text-xs font-medium tabular-nums", colour)} data-tone={tone ?? "ink"}>
50
+ {delta === 0 ? null : <Arrow className="size-3" strokeWidth={2} aria-hidden />}
51
+ <span className="sr-only">{delta < 0 ? "down" : delta > 0 ? "up" : "unchanged"}</span>
52
+ {formatDelta(delta)}
53
+ </span>
54
+ );
55
+ }
56
+
57
+ export function StatStrip({ items, loading, columns, className }: StatStripProps) {
58
+ const cols = Math.min(Math.max(columns ?? items.length, 1), 6);
59
+ const style = { ["--g8-stat-cols" as string]: String(cols) };
60
+ return (
61
+ <TooltipProvider delayDuration={150}>
62
+ <dl
63
+ className={cn(
64
+ "grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border",
65
+ "md:[grid-template-columns:repeat(var(--g8-stat-cols),minmax(0,1fr))]",
66
+ className,
67
+ )}
68
+ style={style}
69
+ data-testid="stat-strip"
70
+ >
71
+ {loading
72
+ ? Array.from({ length: cols }, (_, i) => (
73
+ <div key={i} className="bg-card px-4 py-3">
74
+ <Skeleton className="h-3 w-20" />
75
+ <Skeleton className="mt-3 h-7 w-16" />
76
+ </div>
77
+ ))
78
+ : items.map((item, i) => {
79
+ const Tag = item.onClick ? "button" : "div";
80
+ return (
81
+ <Tag
82
+ key={`${item.label}-${i}`}
83
+ type={item.onClick ? "button" : undefined}
84
+ onClick={item.onClick}
85
+ className={cn(
86
+ "group min-w-0 bg-card px-4 py-3 text-left",
87
+ item.onClick && "cursor-pointer transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
88
+ )}
89
+ >
90
+ <dt className="flex items-center gap-1 text-xs text-text-color-muted">
91
+ <span className="truncate">{item.label}</span>
92
+ {item.hint ? (
93
+ <Tooltip>
94
+ <TooltipTrigger asChild>
95
+ <span tabIndex={0} className="inline-flex rounded-sm text-text-color-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={item.hint}>
96
+ <Info className="size-3.5" strokeWidth={1.5} aria-hidden />
97
+ </span>
98
+ </TooltipTrigger>
99
+ <TooltipContent side="top" className="max-w-xs text-xs">
100
+ {item.hint}
101
+ </TooltipContent>
102
+ </Tooltip>
103
+ ) : null}
104
+ </dt>
105
+ <dd className="mt-1 flex items-end justify-between gap-3">
106
+ <div className="min-w-0">
107
+ <div className="flex items-baseline gap-2">
108
+ <span className="truncate text-2xl font-medium leading-none tracking-tight text-foreground tabular-nums">{item.value}</span>
109
+ {item.delta != null ? <Delta delta={item.delta} tone={item.tone} /> : null}
110
+ </div>
111
+ {item.footer ? <div className="mt-1.5 text-xs text-text-color-muted">{item.footer}</div> : null}
112
+ </div>
113
+ {item.sparkline && item.sparkline.length > 1 ? <Sparkline data={item.sparkline} tone={item.tone} className="h-8 w-20 shrink-0" /> : null}
114
+ </dd>
115
+ </Tag>
116
+ );
117
+ })}
118
+ </dl>
119
+ </TooltipProvider>
120
+ );
121
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ /**
4
+ * StatusPill: a small state badge whose colour carries meaning (positive, warning, negative) or none
5
+ * (neutral, info). Use it for record states (active, paused, bounced, won). Unknown states render as
6
+ * `warning`, never as positive: being "active" is opt-in.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import type { ReactNode } from "react";
11
+ import { cn } from "@graph8/sdk/ui";
12
+
13
+ export type Tone = "neutral" | "info" | "positive" | "warning" | "negative";
14
+
15
+ export const TONES: readonly Tone[] = ["neutral", "info", "positive", "warning", "negative"];
16
+
17
+ /** Tailwind classes per tone: the pill surface, its dot, and plain text. Shared by the kit. */
18
+ export const TONE_CLASSES: Record<Tone, { pill: string; dot: string; text: string; bar: string }> = {
19
+ neutral: {
20
+ pill: "border-border bg-muted text-foreground",
21
+ dot: "bg-text-color-muted",
22
+ text: "text-text-color-muted",
23
+ bar: "bg-foreground/15",
24
+ },
25
+ info: {
26
+ pill: "border-transparent bg-info text-info-foreground",
27
+ dot: "bg-info-foreground",
28
+ text: "text-info-foreground",
29
+ bar: "bg-info-foreground",
30
+ },
31
+ positive: {
32
+ pill: "border-transparent bg-positive text-positive-foreground",
33
+ dot: "bg-positive-foreground",
34
+ text: "text-positive-foreground",
35
+ bar: "bg-positive-foreground",
36
+ },
37
+ warning: {
38
+ pill: "border-transparent bg-warning text-warning-foreground",
39
+ dot: "bg-warning-foreground",
40
+ text: "text-warning-foreground",
41
+ bar: "bg-warning-foreground",
42
+ },
43
+ negative: {
44
+ pill: "border-transparent bg-error text-error-foreground",
45
+ dot: "bg-error-foreground",
46
+ text: "text-error-foreground",
47
+ bar: "bg-error-foreground",
48
+ },
49
+ };
50
+
51
+ /** "scheduled_for_cancellation" -> "Scheduled for cancellation". */
52
+ export function humanizeStatus(value: string | null | undefined): string {
53
+ if (!value) return "Unknown";
54
+ const text = String(value).replace(/[_-]+/g, " ").trim().toLowerCase();
55
+ return text.charAt(0).toUpperCase() + text.slice(1);
56
+ }
57
+
58
+ /**
59
+ * Map a raw API status to a tone. Only the listed good states are positive; anything unlisted is a
60
+ * warning so a new upstream value is visible instead of looking healthy.
61
+ */
62
+ export function toneForStatus(
63
+ value: string | null | undefined,
64
+ overrides: Partial<Record<string, Tone>> = {},
65
+ ): Tone {
66
+ const key = String(value ?? "").toLowerCase();
67
+ if (overrides[key]) return overrides[key] as Tone;
68
+ if (["active", "connected", "won", "completed", "complete", "succeeded", "success", "delivered", "replied", "verified", "booked", "sent", "live", "published", "enabled", "healthy", "ok"].includes(key)) return "positive";
69
+ if (["paused", "inactive", "draft", "archived", "closed", "disabled", "cancelled", "canceled", "stopped", "ended", "done", "skipped", "none"].includes(key)) return "neutral";
70
+ if (["pending", "queued", "scheduled", "running", "in_progress", "processing", "syncing", "open", "new", "trial", "sending", "calling", "ringing"].includes(key)) return "info";
71
+ if (["failed", "error", "errored", "bounced", "lost", "rejected", "expired", "blocked", "suspended", "unsubscribed", "disconnected", "invalid", "overdue", "missed", "spam"].includes(key)) return "negative";
72
+ return "warning";
73
+ }
74
+
75
+ export interface StatusPillProps {
76
+ tone: Tone;
77
+ children: ReactNode;
78
+ /** Leading dot in the tone colour: use it in dense rows where the fill would be noisy. */
79
+ dot?: boolean;
80
+ /** `dot` alone renders only the dot and the text, with no pill surface. */
81
+ quiet?: boolean;
82
+ size?: "sm" | "md";
83
+ className?: string;
84
+ title?: string;
85
+ }
86
+
87
+ export function StatusPill({ tone, children, dot, quiet, size = "sm", className, title }: StatusPillProps) {
88
+ const t = TONE_CLASSES[tone] ?? TONE_CLASSES.neutral;
89
+ return (
90
+ <span
91
+ data-tone={tone}
92
+ title={title}
93
+ className={cn(
94
+ "inline-flex max-w-full items-center gap-1.5 whitespace-nowrap rounded-full font-medium leading-none",
95
+ size === "sm" ? "h-5 px-2 text-[11px]" : "h-6 px-2.5 text-xs",
96
+ quiet ? "px-0 text-foreground" : cn("border", t.pill),
97
+ className,
98
+ )}
99
+ >
100
+ {dot || quiet ? <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", t.dot)} /> : null}
101
+ <span className="truncate">{children}</span>
102
+ </span>
103
+ );
104
+ }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TimeSeriesChart: a line, area or bar chart over time (sent, replies, meetings by day). Ink by
5
+ * default; a series takes a tone only when it means good or bad (replies positive, bounces negative).
6
+ * A `prior` series is the dashed warm-grey comparison. Chrome is recessive: hairline gridlines, muted
7
+ * axis labels, no borders. Built on recharts through the UI kit's ChartContainer.
8
+ *
9
+ * SDK operations: none. Peer: recharts.
10
+ */
11
+ import { useMemo } from "react";
12
+ import { Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
13
+ import { ChartContainer, ChartTooltip, ChartTooltipContent, cn, type ChartConfig } from "@graph8/sdk/ui";
14
+ import type { Tone } from "./status-pill";
15
+
16
+ export interface TimeSeries {
17
+ /** The field of each row to plot. */
18
+ key: string;
19
+ label?: string;
20
+ /** Ink unless the direction means good or bad. "prior" is the dashed comparison line. */
21
+ tone?: Tone | "prior";
22
+ }
23
+
24
+ export interface TimeSeriesChartProps {
25
+ data: Array<Record<string, unknown>>;
26
+ /** Field holding the x value (an ISO date, a label). */
27
+ xKey: string;
28
+ series: TimeSeries[];
29
+ kind?: "line" | "area" | "bar";
30
+ height?: number;
31
+ /** Format an x value for the axis ("2026-10-01" -> "Oct 1"). */
32
+ formatX?: (value: unknown) => string;
33
+ /** Format a y value for the axis and the tooltip. */
34
+ formatY?: (value: number) => string;
35
+ /** Stack bars/areas. */
36
+ stacked?: boolean;
37
+ className?: string;
38
+ }
39
+
40
+ const TONE_VAR: Record<NonNullable<TimeSeries["tone"]> | "ink", string> = {
41
+ ink: "var(--foreground)",
42
+ neutral: "var(--text-color-muted)",
43
+ info: "var(--info-foreground)",
44
+ positive: "var(--positive-foreground)",
45
+ warning: "var(--warning-foreground)",
46
+ negative: "var(--error-foreground)",
47
+ prior: "var(--text-color-muted)",
48
+ };
49
+
50
+ export function defaultFormatX(value: unknown): string {
51
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
52
+ const d = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
53
+ if (!Number.isNaN(d.getTime())) return d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: value.length === 10 ? "UTC" : undefined });
54
+ }
55
+ return String(value ?? "");
56
+ }
57
+
58
+ export function defaultFormatY(value: number): string {
59
+ if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
60
+ if (Math.abs(value) >= 10_000) return `${(value / 1_000).toFixed(0)}k`;
61
+ if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
62
+ return value.toLocaleString();
63
+ }
64
+
65
+ export function TimeSeriesChart({ data, xKey, series, kind = "line", height = 220, formatX = defaultFormatX, formatY = defaultFormatY, stacked, className }: TimeSeriesChartProps) {
66
+ const config = useMemo<ChartConfig>(
67
+ () => Object.fromEntries(series.map((s) => [s.key, { label: s.label ?? s.key, color: TONE_VAR[s.tone ?? "ink"] }])),
68
+ [series],
69
+ );
70
+ const axes = (
71
+ <>
72
+ <CartesianGrid vertical={false} strokeDasharray="0" stroke="var(--border)" strokeOpacity={0.9} />
73
+ <XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} tickFormatter={formatX} tick={{ fontSize: 11 }} />
74
+ <YAxis width={36} tickLine={false} axisLine={false} tickMargin={4} tickFormatter={formatY} tick={{ fontSize: 11 }} allowDecimals={false} />
75
+ <ChartTooltip cursor={{ stroke: "var(--border)" }} content={<ChartTooltipContent labelFormatter={(v) => formatX(v)} formatter={(v, name) => [`${formatY(Number(v))} `, String(config[String(name)]?.label ?? name)]} indicator="line" />} />
76
+ </>
77
+ );
78
+ const stroke = (s: TimeSeries) => ({
79
+ stroke: `var(--color-${s.key})`,
80
+ strokeWidth: s.tone === "prior" ? 1.25 : 1.5,
81
+ strokeDasharray: s.tone === "prior" ? "4 3" : undefined,
82
+ dot: false as const,
83
+ activeDot: { r: 3 },
84
+ isAnimationActive: false,
85
+ });
86
+ const style = { height } as const;
87
+
88
+ return (
89
+ <ChartContainer config={config} className={cn("aspect-auto w-full text-text-color-muted", className)} style={style} data-testid="time-series-chart">
90
+ {kind === "bar" ? (
91
+ <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }} barCategoryGap="25%">
92
+ {axes}
93
+ {series.map((s) => (
94
+ <Bar key={s.key} dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone ? 0.85 : 1} radius={[2, 2, 0, 0]} isAnimationActive={false} />
95
+ ))}
96
+ </BarChart>
97
+ ) : kind === "area" ? (
98
+ <AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
99
+ {axes}
100
+ {series.map((s) => (
101
+ <Area key={s.key} type="monotone" dataKey={s.key} name={s.key} stackId={stacked ? "a" : undefined} fill={`var(--color-${s.key})`} fillOpacity={s.tone === "prior" ? 0 : 0.06} {...stroke(s)} />
102
+ ))}
103
+ </AreaChart>
104
+ ) : (
105
+ <LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
106
+ {axes}
107
+ {series.map((s) => (
108
+ <Line key={s.key} type="monotone" dataKey={s.key} name={s.key} {...stroke(s)} />
109
+ ))}
110
+ </LineChart>
111
+ )}
112
+ </ChartContainer>
113
+ );
114
+ }
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Toolbar: the row above a list. Left: debounced search and filters. Right: actions (bulk, export, the
5
+ * primary). The search calls `onChange` after `debounceMs` of quiet typing, immediately on Enter, and
6
+ * clears on Escape or the clear button. Out-of-order results are the list's job (useG8Query does it).
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
11
+ import { Search, X } from "lucide-react";
12
+ import { Input, cn } from "@graph8/sdk/ui";
13
+
14
+ export interface ToolbarSearch {
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ placeholder?: string;
18
+ /** Milliseconds of quiet typing before `onChange` fires. 0 fires on every keystroke. */
19
+ debounceMs?: number;
20
+ /** Accessible name; defaults to the placeholder or "Search". */
21
+ label?: string;
22
+ autoFocus?: boolean;
23
+ }
24
+
25
+ export interface ToolbarProps {
26
+ search?: ToolbarSearch;
27
+ /** Filter controls (selects, chips, a Filters button), rendered after the search. */
28
+ filters?: ReactNode;
29
+ /** Right-aligned actions; the primary action goes last. */
30
+ actions?: ReactNode;
31
+ /** A second row (applied filter chips, a selection summary). */
32
+ children?: ReactNode;
33
+ /** Result count or any status text, read by screen readers politely. */
34
+ status?: ReactNode;
35
+ className?: string;
36
+ }
37
+
38
+ export function ToolbarSearchInput({ value, onChange, placeholder = "Search", debounceMs = 250, label, autoFocus }: ToolbarSearch) {
39
+ const [text, setText] = useState(value);
40
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+ const latest = useRef(onChange);
42
+ latest.current = onChange;
43
+ // Values sent to onChange that the parent has not echoed back yet (an async parent, e.g. router.push).
44
+ const sent = useRef<string[]>([]);
45
+
46
+ // The parent owns the value (URL, store): follow it when it changes from outside. A late echo of
47
+ // something we sent is ignored (it would drop characters typed since). A real outside change (a "Clear
48
+ // filters" button) wins, and cancels a pending debounce so the old text is not sent back afterwards.
49
+ useEffect(() => {
50
+ const i = sent.current.indexOf(value);
51
+ if (i !== -1) {
52
+ sent.current = sent.current.slice(i + 1);
53
+ return;
54
+ }
55
+ if (timer.current) {
56
+ clearTimeout(timer.current);
57
+ timer.current = null;
58
+ }
59
+ sent.current = [];
60
+ setText(value);
61
+ }, [value]);
62
+
63
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
64
+
65
+ const send = (next: string) => {
66
+ sent.current = [...sent.current.slice(-19), next];
67
+ latest.current(next);
68
+ };
69
+
70
+ const emit = (next: string, immediate = false) => {
71
+ if (timer.current) clearTimeout(timer.current);
72
+ if (immediate || debounceMs <= 0) {
73
+ timer.current = null;
74
+ send(next);
75
+ return;
76
+ }
77
+ timer.current = setTimeout(() => {
78
+ timer.current = null;
79
+ send(next);
80
+ }, debounceMs);
81
+ };
82
+
83
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
84
+ if (e.key === "Enter") {
85
+ e.preventDefault();
86
+ emit(text, true);
87
+ } else if (e.key === "Escape" && text) {
88
+ e.preventDefault();
89
+ setText("");
90
+ emit("", true);
91
+ }
92
+ };
93
+
94
+ return (
95
+ <div className="relative w-full min-w-0 sm:w-72">
96
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-text-color-muted" strokeWidth={1.5} aria-hidden />
97
+ <Input
98
+ type="search"
99
+ role="searchbox"
100
+ value={text}
101
+ autoFocus={autoFocus}
102
+ aria-label={label ?? placeholder}
103
+ placeholder={placeholder}
104
+ onChange={(e) => {
105
+ setText(e.target.value);
106
+ emit(e.target.value);
107
+ }}
108
+ onKeyDown={onKeyDown}
109
+ className="h-8 bg-background pl-8 pr-8 text-sm shadow-none [&::-webkit-search-cancel-button]:hidden"
110
+ />
111
+ {text ? (
112
+ <button
113
+ type="button"
114
+ aria-label="Clear search"
115
+ onClick={() => {
116
+ setText("");
117
+ emit("", true);
118
+ }}
119
+ className="absolute right-1.5 top-1/2 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-sm text-text-color-muted hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
120
+ >
121
+ <X className="size-3.5" strokeWidth={1.5} aria-hidden />
122
+ </button>
123
+ ) : null}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ export function Toolbar({ search, filters, actions, children, status, className }: ToolbarProps) {
129
+ return (
130
+ <div role="toolbar" aria-label="List tools" className={cn("flex flex-col gap-2", className)} data-testid="toolbar">
131
+ <div className="flex flex-wrap items-center gap-2">
132
+ {search ? <ToolbarSearchInput {...search} /> : null}
133
+ {filters ? <div className="flex flex-wrap items-center gap-1.5">{filters}</div> : null}
134
+ {status ? (
135
+ <span role="status" aria-live="polite" className="text-xs text-text-color-muted tabular-nums">
136
+ {status}
137
+ </span>
138
+ ) : null}
139
+ {actions ? <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div> : null}
140
+ </div>
141
+ {children ? <div className="flex flex-wrap items-center gap-1.5">{children}</div> : null}
142
+ </div>
143
+ );
144
+ }