@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.
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/lib/series.d.ts +74 -0
- package/dist/lib/series.js +100 -0
- package/dist/presentation.d.ts +13 -0
- package/dist/presentation.js +14 -1
- package/dist/primitives/breadcrumb.d.ts +32 -0
- package/dist/primitives/breadcrumb.js +87 -0
- package/dist/primitives/hover-card.d.ts +16 -0
- package/dist/primitives/hover-card.js +63 -0
- package/dist/primitives/metric-chart.d.ts +47 -0
- package/dist/primitives/metric-chart.js +155 -0
- package/dist/primitives/series-sparkline.d.ts +16 -0
- package/dist/primitives/series-sparkline.js +53 -0
- package/dist/primitives.d.ts +6 -1
- package/dist/primitives.js +6 -1
- package/package.json +3 -2
- package/src/lib/series.test.ts +172 -0
- package/src/lib/series.ts +174 -0
- package/src/presentation.ts +14 -0
- package/src/primitives/breadcrumb.tsx +126 -0
- package/src/primitives/hover-card-breadcrumb.test.tsx +92 -0
- package/src/primitives/hover-card.tsx +89 -0
- package/src/primitives/index.ts +24 -0
- package/src/primitives/metric-chart.stories.tsx +55 -0
- package/src/primitives/metric-chart.test.tsx +118 -0
- package/src/primitives/metric-chart.tsx +246 -0
- package/src/primitives/series-sparkline.stories.tsx +38 -0
- package/src/primitives/series-sparkline.test.tsx +56 -0
- package/src/primitives/series-sparkline.tsx +69 -0
|
@@ -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
|
+
};
|
package/src/primitives/index.ts
CHANGED
|
@@ -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
|
+
});
|