@tangle-network/ui 11.40.2 → 11.42.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.
@@ -0,0 +1,126 @@
1
+ import { ChevronRight } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import * as React from "react";
4
+
5
+ /**
6
+ * Where you are inside a nested surface — the workspace, its settings, the
7
+ * section you're in. Each crumb is a link except the last, which is the page.
8
+ *
9
+ * ```tsx
10
+ * <Breadcrumb>
11
+ * <BreadcrumbList>
12
+ * <BreadcrumbItem><BreadcrumbLink href="/workspaces/ws-1">Recenter demo</BreadcrumbLink></BreadcrumbItem>
13
+ * <BreadcrumbSeparator />
14
+ * <BreadcrumbItem><BreadcrumbLink href="/workspaces/ws-1/settings">Settings</BreadcrumbLink></BreadcrumbItem>
15
+ * <BreadcrumbSeparator />
16
+ * <BreadcrumbItem><BreadcrumbPage>Connections</BreadcrumbPage></BreadcrumbItem>
17
+ * </BreadcrumbList>
18
+ * </Breadcrumb>
19
+ * ```
20
+ *
21
+ * On narrow viewports the list scrolls horizontally with a fade mask rather
22
+ * than wrapping — a crumb trail reads as one line, not a paragraph.
23
+ */
24
+ const Breadcrumb = React.forwardRef<
25
+ HTMLElement,
26
+ React.HTMLAttributes<HTMLElement> & { separator?: React.ReactNode }
27
+ >(({ className, ...props }, ref) => (
28
+ <nav ref={ref} aria-label="Breadcrumb" className={cn("relative", className)} {...props} />
29
+ ));
30
+ Breadcrumb.displayName = "Breadcrumb";
31
+
32
+ const BreadcrumbList = React.forwardRef<
33
+ HTMLOListElement,
34
+ React.OlHTMLAttributes<HTMLOListElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <ol
37
+ ref={ref}
38
+ className={cn(
39
+ "flex items-center gap-1.5 text-sm text-muted-foreground overflow-x-auto",
40
+ "[mask-image:linear-gradient(to_right,transparent,black_16px,black_calc(100%-16px),transparent)]",
41
+ "[-webkit-mask-image:linear-gradient(to_right,transparent,black_16px,black_calc(100%-16px),transparent)]",
42
+ "scrollbar-none",
43
+ className
44
+ )}
45
+ {...props}
46
+ />
47
+ ));
48
+ BreadcrumbList.displayName = "BreadcrumbList";
49
+
50
+ const BreadcrumbItem = React.forwardRef<
51
+ HTMLLIElement,
52
+ React.LiHTMLAttributes<HTMLLIElement>
53
+ >(({ className, ...props }, ref) => (
54
+ <li ref={ref} className={cn("inline-flex items-center gap-1.5 shrink-0", className)} {...props} />
55
+ ));
56
+ BreadcrumbItem.displayName = "BreadcrumbItem";
57
+
58
+ const BreadcrumbLink = React.forwardRef<
59
+ HTMLAnchorElement,
60
+ React.AnchorHTMLAttributes<HTMLAnchorElement>
61
+ >(({ className, ...props }, ref) => (
62
+ <a
63
+ ref={ref}
64
+ className={cn("transition-colors hover:text-foreground", className)}
65
+ {...props}
66
+ />
67
+ ));
68
+ BreadcrumbLink.displayName = "BreadcrumbLink";
69
+
70
+ const BreadcrumbPage = React.forwardRef<
71
+ HTMLSpanElement,
72
+ React.HTMLAttributes<HTMLSpanElement>
73
+ >(({ className, ...props }, ref) => (
74
+ <span
75
+ ref={ref}
76
+ role="link"
77
+ aria-disabled="true"
78
+ aria-current="page"
79
+ className={cn("font-normal text-foreground", className)}
80
+ {...props}
81
+ />
82
+ ));
83
+ BreadcrumbPage.displayName = "BreadcrumbPage";
84
+
85
+ const BreadcrumbSeparator = React.forwardRef<
86
+ HTMLSpanElement,
87
+ React.HTMLAttributes<HTMLSpanElement>
88
+ >(({ className, children, ...props }, ref) => (
89
+ <span
90
+ ref={ref}
91
+ role="presentation"
92
+ aria-hidden="true"
93
+ className={cn("shrink-0 text-muted-foreground/60", className)}
94
+ {...props}
95
+ >
96
+ {children ?? <ChevronRight className="size-3.5" aria-hidden />}
97
+ </span>
98
+ ));
99
+ BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
100
+
101
+ const BreadcrumbEllipsis = React.forwardRef<
102
+ HTMLSpanElement,
103
+ React.HTMLAttributes<HTMLSpanElement>
104
+ >(({ className, ...props }, ref) => (
105
+ <span
106
+ ref={ref}
107
+ role="presentation"
108
+ aria-hidden="true"
109
+ className={cn("flex size-6 items-center justify-center", className)}
110
+ {...props}
111
+ >
112
+ <span className="text-muted-foreground">…</span>
113
+ <span className="sr-only">More</span>
114
+ </span>
115
+ ));
116
+ BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
117
+
118
+ export {
119
+ Breadcrumb,
120
+ BreadcrumbList,
121
+ BreadcrumbItem,
122
+ BreadcrumbLink,
123
+ BreadcrumbPage,
124
+ BreadcrumbSeparator,
125
+ BreadcrumbEllipsis,
126
+ };
@@ -0,0 +1,92 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import * as React from "react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ Breadcrumb,
7
+ BreadcrumbItem,
8
+ BreadcrumbLink,
9
+ BreadcrumbList,
10
+ BreadcrumbPage,
11
+ BreadcrumbSeparator,
12
+ } from "./breadcrumb";
13
+ import {
14
+ HoverCard,
15
+ HoverCardContent,
16
+ HoverCardDescription,
17
+ HoverCardHeader,
18
+ HoverCardTitle,
19
+ HoverCardTrigger,
20
+ } from "./hover-card";
21
+
22
+ describe("HoverCard", () => {
23
+ it("shows the trigger and hides content until hovered", () => {
24
+ render(
25
+ <HoverCard>
26
+ <HoverCardTrigger>@noa</HoverCardTrigger>
27
+ <HoverCardContent>
28
+ <HoverCardHeader>
29
+ <HoverCardTitle>Noa</HoverCardTitle>
30
+ <HoverCardDescription>Housekeeping · es</HoverCardDescription>
31
+ </HoverCardHeader>
32
+ </HoverCardContent>
33
+ </HoverCard>,
34
+ );
35
+ expect(screen.getByText("@noa")).toBeVisible();
36
+ // Portal content only renders when open
37
+ expect(screen.queryByText("Noa")).toBeNull();
38
+ });
39
+
40
+ it("renders with a custom className on content", () => {
41
+ render(
42
+ <HoverCard open>
43
+ <HoverCardTrigger>hover me</HoverCardTrigger>
44
+ <HoverCardContent className="w-80" data-testid="card">
45
+ <p>Content</p>
46
+ </HoverCardContent>
47
+ </HoverCard>,
48
+ );
49
+ const card = screen.getByTestId("card");
50
+ expect(card).toBeInTheDocument();
51
+ expect(card.className).toContain("w-80");
52
+ });
53
+ });
54
+
55
+ describe("Breadcrumb", () => {
56
+ it("renders a trail with links and a terminal page", () => {
57
+ render(
58
+ <Breadcrumb>
59
+ <BreadcrumbList>
60
+ <BreadcrumbItem>
61
+ <BreadcrumbLink href="/ws-1">Workspace</BreadcrumbLink>
62
+ </BreadcrumbItem>
63
+ <BreadcrumbSeparator />
64
+ <BreadcrumbItem>
65
+ <BreadcrumbLink href="/ws-1/settings">Settings</BreadcrumbLink>
66
+ </BreadcrumbItem>
67
+ <BreadcrumbSeparator />
68
+ <BreadcrumbItem>
69
+ <BreadcrumbPage>Connections</BreadcrumbPage>
70
+ </BreadcrumbItem>
71
+ </BreadcrumbList>
72
+ </Breadcrumb>,
73
+ );
74
+ expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible();
75
+ expect(screen.getByText("Workspace")).toHaveAttribute("href", "/ws-1");
76
+ expect(screen.getByText("Connections")).toHaveAttribute("aria-current", "page");
77
+ });
78
+
79
+ it("separates crumbs with chevrons by default", () => {
80
+ render(
81
+ <Breadcrumb>
82
+ <BreadcrumbList>
83
+ <BreadcrumbItem><BreadcrumbLink href="/a">A</BreadcrumbLink></BreadcrumbItem>
84
+ <BreadcrumbSeparator />
85
+ <BreadcrumbItem><BreadcrumbPage>B</BreadcrumbPage></BreadcrumbItem>
86
+ </BreadcrumbList>
87
+ </Breadcrumb>,
88
+ );
89
+ const separators = screen.getAllByRole("presentation", { hidden: true });
90
+ expect(separators.length).toBeGreaterThanOrEqual(1);
91
+ });
92
+ });
@@ -0,0 +1,89 @@
1
+ import { cn } from "../lib/utils";
2
+ import * as React from "react";
3
+
4
+ /**
5
+ * A rich hover preview: the face of a person, product, or record that appears
6
+ * when you pause on its trigger. Radix HoverCard under the hood, styled on the
7
+ * md3 surface ladder — the card sits on `--md3-surface-container-highest`
8
+ * (L4, the overlay plane) so it reads as a floating layer above the page.
9
+ *
10
+ * ```tsx
11
+ * <HoverCard>
12
+ * <HoverCardTrigger>@noa</HoverCardTrigger>
13
+ * <HoverCardContent>
14
+ * <HoverCardHeader>
15
+ * <HoverCardTitle>Noa</HoverCardTitle>
16
+ * <HoverCardDescription>Housekeeping · es</HoverCardDescription>
17
+ * </HoverCardHeader>
18
+ * <p>3 rooms today · 1 pending</p>
19
+ * </HoverCardContent>
20
+ * </HoverCard>
21
+ * ```
22
+ *
23
+ * The content is **lazy** — it mounts only when hovered, so a page of 50
24
+ * triggers costs nothing until the person pauses on one.
25
+ */
26
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
27
+
28
+ const HoverCard = HoverCardPrimitive.Root;
29
+
30
+ const HoverCardTrigger = React.forwardRef<
31
+ React.ElementRef<typeof HoverCardPrimitive.Trigger>,
32
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
33
+ >(({ className, ...props }, ref) => (
34
+ <HoverCardPrimitive.Trigger
35
+ ref={ref}
36
+ className={cn("cursor-pointer underline-offset-4 hover:underline", className)}
37
+ {...props}
38
+ />
39
+ ));
40
+ HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
41
+
42
+ const HoverCardContent = React.forwardRef<
43
+ React.ElementRef<typeof HoverCardPrimitive.Content>,
44
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
45
+ /** Which edge the card prefers. Defaults to bottom. */
46
+ side?: "top" | "right" | "bottom" | "left";
47
+ /** Pixel gap from the trigger. Defaults to 6. */
48
+ alignOffset?: number;
49
+ }
50
+ >(({ className, side = "bottom", alignOffset = 6, ...props }, ref) => (
51
+ <HoverCardPrimitive.Portal>
52
+ <HoverCardPrimitive.Content
53
+ ref={ref}
54
+ side={side}
55
+ alignOffset={alignOffset}
56
+ sideOffset={6}
57
+ className={cn(
58
+ "z-50 w-64 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
59
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
60
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
61
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ </HoverCardPrimitive.Portal>
67
+ ));
68
+ HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
69
+
70
+ function HoverCardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
71
+ return <div className={cn("flex flex-col gap-1 pb-2", className)} {...props} />;
72
+ }
73
+
74
+ function HoverCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
75
+ return <h4 className={cn("text-sm font-semibold leading-none", className)} {...props} />;
76
+ }
77
+
78
+ function HoverCardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
79
+ return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
80
+ }
81
+
82
+ export {
83
+ HoverCard,
84
+ HoverCardTrigger,
85
+ HoverCardContent,
86
+ HoverCardHeader,
87
+ HoverCardTitle,
88
+ HoverCardDescription,
89
+ };
@@ -234,6 +234,9 @@ export { Chip } from "./chip";
234
234
  export type { ChipProps } from "./chip";
235
235
 
236
236
  export { Wizard, WizardStep } from "./wizard";
237
+ export { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardHeader, HoverCardTitle, HoverCardDescription } from "./hover-card";
238
+
239
+ export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis } from "./breadcrumb";
237
240
  export type { WizardProps, WizardStepProps } from "./wizard";
238
241
 
239
242
  export { IconTile, initialsOf } from "./icon-tile";
@@ -245,5 +248,26 @@ export type { CategoryTone, Tone, ToneClasses } from "./tone";
245
248
  export { MetricStrip, Metric } from "./metric-strip";
246
249
  export type { MetricStripProps, MetricProps } from "./metric-strip";
247
250
 
251
+ export { SeriesSparkline } from "./series-sparkline";
252
+ export type { SeriesSparklineProps } from "./series-sparkline";
253
+ export { MetricChart } from "./metric-chart";
254
+ export type { ChartReferenceLine, MetricChartProps } from "./metric-chart";
255
+ export {
256
+ normalizeSeries,
257
+ periodDelta,
258
+ seriesAreaPath,
259
+ seriesExtent,
260
+ seriesLinePath,
261
+ seriesMean,
262
+ seriesScale,
263
+ } from "../lib/series";
264
+ export type {
265
+ PeriodDelta,
266
+ SeriesExtent,
267
+ SeriesPoint,
268
+ SeriesPointInput,
269
+ SeriesScale,
270
+ } from "../lib/series";
271
+
248
272
  export { Toolbar, FilterField } from "./toolbar";
249
273
  export type { ToolbarProps, FilterFieldProps } from "./toolbar";
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { MetricChart } from "./metric-chart";
3
+
4
+ const meta = {
5
+ title: "Primitives/MetricChart",
6
+ component: MetricChart,
7
+ parameters: { layout: "padded" },
8
+ } satisfies Meta<typeof MetricChart>;
9
+ export default meta;
10
+ type Story = StoryObj<typeof meta>;
11
+
12
+ const DAY = 86_400_000;
13
+ const t0 = Date.parse("2026-08-24T07:00:00.000Z");
14
+
15
+ const bloodPressure = Array.from({ length: 12 }, (_, index) => ({
16
+ at: t0 + index * 5 * DAY,
17
+ value: 118 + ((index * 7) % 9),
18
+ value2: 74 + ((index * 5) % 8),
19
+ }));
20
+
21
+ const steps = Array.from({ length: 12 }, (_, index) => ({
22
+ at: t0 + index * 5 * DAY,
23
+ value: 6_200 + ((index * 1_937) % 4_800),
24
+ }));
25
+
26
+ export const DualSeries: Story = {
27
+ args: {
28
+ title: "Blood pressure",
29
+ unit: "mmHg",
30
+ decimals: 0,
31
+ points: bloodPressure,
32
+ referenceLines: [
33
+ { value: 120, label: "120 systolic" },
34
+ { value: 80, label: "80 diastolic", series: "secondary" },
35
+ ],
36
+ },
37
+ };
38
+
39
+ export const SingleSeries: Story = {
40
+ args: {
41
+ title: "Steps",
42
+ unit: "steps",
43
+ decimals: 0,
44
+ points: steps,
45
+ },
46
+ };
47
+
48
+ export const SingleReading: Story = {
49
+ args: {
50
+ title: "Weight",
51
+ unit: "kg",
52
+ decimals: 1,
53
+ points: [{ at: t0, value: 71.4 }],
54
+ },
55
+ };
@@ -0,0 +1,118 @@
1
+ import * as React from "react";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { MetricChart } from "./metric-chart";
5
+
6
+ afterEach(cleanup);
7
+
8
+ const DAY = 86_400_000;
9
+ const t0 = Date.parse("2026-09-01T07:00:00.000Z");
10
+ const points = Array.from({ length: 10 }, (_, index) => ({
11
+ at: t0 + index * 5 * DAY,
12
+ value: 118 + (index % 3) * 4,
13
+ value2: 76 + (index % 2) * 4,
14
+ }));
15
+
16
+ describe("MetricChart", () => {
17
+ it("renders an accessible svg with a generated summary description", () => {
18
+ const { container } = render(
19
+ <MetricChart points={points} title="Blood pressure" unit="mmHg" decimals={0} />,
20
+ );
21
+ const svg = screen.getByRole("img", { name: "Blood pressure" });
22
+ expect(svg.querySelector("title")!.textContent).toBe("Blood pressure");
23
+ const desc = svg.querySelector("desc")!.textContent!;
24
+ expect(desc).toContain("10 readings");
25
+ expect(desc).toContain("min 118");
26
+ expect(desc).toContain("max 126");
27
+ expect(desc).toContain("mmHg");
28
+ // Primary line, area and dotted secondary series.
29
+ expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(3);
30
+ });
31
+
32
+ it("honours a caller-provided description", () => {
33
+ const { container } = render(
34
+ <MetricChart points={points} title="Blood pressure" description="Custom summary." />,
35
+ );
36
+ expect(container.querySelector("desc")!.textContent).toBe("Custom summary.");
37
+ });
38
+
39
+ it("draws gridlines, date ticks and labelled reference lines on muted ink", () => {
40
+ const { container } = render(
41
+ <MetricChart
42
+ points={points}
43
+ title="Blood pressure"
44
+ referenceLines={[
45
+ { value: 120, label: "120 systolic" },
46
+ { value: 80, label: "80 diastolic", series: "secondary" },
47
+ ]}
48
+ />,
49
+ );
50
+ expect(screen.getByText("120 systolic")).toBeInTheDocument();
51
+ expect(screen.getByText("80 diastolic")).toBeInTheDocument();
52
+ // Four interior y ticks plus three x date ticks.
53
+ expect(screen.getByText("Sep 1")).toBeInTheDocument();
54
+ const dashed = Array.from(container.querySelectorAll("line")).filter(
55
+ (line) => line.getAttribute("stroke-dasharray") === "5 4",
56
+ );
57
+ expect(dashed).toHaveLength(2);
58
+ for (const line of dashed) expect(line.closest("g")!.getAttribute("class")).toContain("text-muted-foreground");
59
+ });
60
+
61
+ it("drops secondary reference lines when the series has no second reading", () => {
62
+ const single = points.map(({ at, value }) => ({ at, value }));
63
+ const { container } = render(
64
+ <MetricChart
65
+ points={single}
66
+ title="Heart rate"
67
+ referenceLines={[{ value: 80, label: "80 diastolic", series: "secondary" }]}
68
+ />,
69
+ );
70
+ expect(screen.queryByText("80 diastolic")).toBeNull();
71
+ expect(
72
+ Array.from(container.querySelectorAll("line")).filter(
73
+ (line) => line.getAttribute("stroke-dasharray") === "5 4",
74
+ ),
75
+ ).toHaveLength(0);
76
+ });
77
+
78
+ it("marks a single reading and renders axes only when data exists", () => {
79
+ const { container, rerender } = render(
80
+ <MetricChart points={[points[0]!]} title="Blood pressure" />,
81
+ );
82
+ expect(container.querySelectorAll("circle")).toHaveLength(1);
83
+ rerender(<MetricChart points={[]} title="Blood pressure" />);
84
+ expect(container.querySelectorAll("line")).toHaveLength(0);
85
+ expect(container.querySelectorAll("path")).toHaveLength(0);
86
+ expect(screen.getByRole("img", { name: "Blood pressure" })).toBeInTheDocument();
87
+ });
88
+
89
+ it("keeps every stroke and fill on currentColor", () => {
90
+ const { container } = render(<MetricChart points={points} title="Blood pressure" />);
91
+ for (const element of Array.from(container.querySelectorAll("[stroke], [fill]"))) {
92
+ const paint = element.getAttribute("stroke") ?? element.getAttribute("fill");
93
+ expect(["currentColor", "none"]).toContain(paint);
94
+ }
95
+ });
96
+
97
+ it("supports caller value and time formatting", () => {
98
+ render(
99
+ <MetricChart
100
+ points={points}
101
+ title="Blood pressure"
102
+ formatValue={(value) => `${value.toFixed(0)}!`}
103
+ formatTime={() => "sometime"}
104
+ />,
105
+ );
106
+ expect(screen.getAllByText("sometime")).toHaveLength(3);
107
+ expect(screen.getAllByText(/!$/).length).toBeGreaterThan(0);
108
+ });
109
+
110
+ it("preserves ref, caller attributes and caller classes", () => {
111
+ const ref = React.createRef<SVGSVGElement>();
112
+ const { container } = render(
113
+ <MetricChart ref={ref} points={points} title="Blood pressure" data-testid="bp" className="max-w-md" />,
114
+ );
115
+ expect(ref.current).toBe(container.querySelector("svg"));
116
+ expect(screen.getByTestId("bp").getAttribute("class")).toContain("max-w-md");
117
+ });
118
+ });