@siyahi/ui 0.1.9-next.20 → 0.1.9-next.21
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/DESIGN.md +30 -0
- package/README.md +3 -3
- package/agents/examples/alert.tsx +4 -6
- package/agents/examples/bar-list-sides.tsx +11 -3
- package/agents/examples/button-states.tsx +23 -0
- package/agents/examples/callout.tsx +33 -0
- package/agents/examples/change-bar.tsx +104 -0
- package/agents/examples/chart-area.tsx +5 -4
- package/agents/examples/chart-bar-flows.tsx +3 -2
- package/agents/examples/chart-bar-sectors.tsx +2 -0
- package/agents/examples/data-table-compact.tsx +95 -0
- package/agents/examples/heatmap-calendar.tsx +17 -7
- package/agents/examples/heatmap-status.tsx +17 -11
- package/agents/examples/heatmap-weekday.tsx +33 -11
- package/agents/examples/input-states.tsx +33 -0
- package/agents/examples/metric-strip.tsx +9 -3
- package/agents/examples/overview-dashboard.tsx +18 -0
- package/agents/examples/page-hero.tsx +38 -0
- package/agents/examples/range-position-table.tsx +101 -0
- package/agents/examples/range-position.tsx +53 -0
- package/agents/examples/section-cards-select.tsx +8 -5
- package/agents/examples/surfaces.tsx +3 -2
- package/agents/examples/target-rows.tsx +70 -0
- package/agents/examples/treemap.tsx +2 -1
- package/agents/manifest.json +153 -3
- package/dist/components/registry/charts/area-chart-card.js +25 -7
- package/dist/components/registry/charts/bar-chart-card.d.ts +0 -1
- package/dist/components/registry/charts/bar-chart-card.js +11 -4
- package/dist/components/registry/charts/line-chart-card.js +2 -2
- package/dist/components/registry/dashboardcn/bar-list.js +11 -5
- package/dist/components/registry/dashboardcn/kpi-row-card.js +2 -2
- package/dist/components/registry/dashboardcn/sparkline.d.ts +5 -1
- package/dist/components/registry/dashboardcn/sparkline.js +40 -18
- package/dist/components/registry/heatmap/calendar-heatmap.d.ts +4 -2
- package/dist/components/registry/heatmap/calendar-heatmap.js +67 -33
- package/dist/components/registry/heatmap/status-heatmap.d.ts +5 -3
- package/dist/components/registry/heatmap/status-heatmap.js +30 -15
- package/dist/components/registry/heatmap/weekday-heatmap.d.ts +4 -2
- package/dist/components/registry/heatmap/weekday-heatmap.js +28 -12
- package/dist/components/registry/niko-table/core/data-table-context.d.ts +16 -1
- package/dist/components/registry/niko-table/core/data-table-context.js +5 -20
- package/dist/components/registry/niko-table/core/data-table-root.js +39 -4
- package/dist/components/registry/niko-table/core/data-table-structure.js +2 -0
- package/dist/components/registry/niko-table/lib/styles.js +1 -1
- package/dist/components/registry/niko-table/types/index.d.ts +9 -0
- package/dist/components/registry/sidebar-07/nav-main.d.ts +1 -0
- package/dist/components/registry/sidebar-07/nav-main.js +9 -2
- package/dist/components/siyahi/callout.d.ts +24 -0
- package/dist/components/siyahi/callout.js +40 -0
- package/dist/components/siyahi/change-bar.d.ts +21 -0
- package/dist/components/siyahi/change-bar.js +35 -0
- package/dist/components/siyahi/data-table-compact-columns-toggle.d.ts +4 -0
- package/dist/components/siyahi/data-table-compact-columns-toggle.js +19 -0
- package/dist/components/siyahi/data-table-footnote.d.ts +12 -0
- package/dist/components/siyahi/data-table-footnote.js +35 -0
- package/dist/components/siyahi/default-sidebar-open.d.ts +8 -0
- package/dist/components/siyahi/default-sidebar-open.js +13 -0
- package/dist/components/siyahi/page-hero.d.ts +25 -0
- package/dist/components/siyahi/page-hero.js +41 -0
- package/dist/components/siyahi/page-toc.d.ts +20 -0
- package/dist/components/siyahi/page-toc.js +76 -0
- package/dist/components/siyahi/quote.d.ts +12 -0
- package/dist/components/siyahi/quote.js +23 -0
- package/dist/components/siyahi/range-position.d.ts +24 -0
- package/dist/components/siyahi/range-position.js +105 -0
- package/dist/components/siyahi/siyahi-provider.d.ts +1 -1
- package/dist/components/siyahi/siyahi-provider.js +1 -1
- package/dist/components/siyahi/target-rows.d.ts +34 -0
- package/dist/components/siyahi/target-rows.js +97 -0
- package/dist/components/siyahi/treemap.d.ts +4 -1
- package/dist/components/siyahi/treemap.js +33 -25
- package/dist/components/siyahi/warning-alert.d.ts +10 -0
- package/dist/components/siyahi/warning-alert.js +20 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.js +12 -1
- package/dist/lib/chart-frame.d.ts +12 -2
- package/dist/lib/chart-frame.js +51 -7
- package/dist/lib/chart-series.d.ts +5 -2
- package/dist/lib/compact-columns.d.ts +19 -0
- package/dist/lib/compact-columns.js +55 -0
- package/dist/lib/format.d.ts +7 -2
- package/dist/lib/format.js +24 -5
- package/dist/lib/treemap.js +6 -1
- package/dist/lib/use-element-width.d.ts +5 -0
- package/dist/lib/use-element-width.js +15 -0
- package/package.json +2 -1
- package/report.css +26 -25
- package/styles/siyahi.css +159 -43
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/components/siyahi/page-hero.d.ts
|
|
3
|
+
export interface PageHeroProps {
|
|
4
|
+
/** A short line above the title in the link colour, such as a package or a section name. */
|
|
5
|
+
eyebrow?: ReactNode;
|
|
6
|
+
/** The page's h1, set large. */
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
/** What the page is, in a sentence or two, in the body colour. */
|
|
9
|
+
lead?: ReactNode;
|
|
10
|
+
/** Muted detail under the lead: scope, sources, what is left out. */
|
|
11
|
+
detail?: ReactNode;
|
|
12
|
+
/** The one or two places a reader goes next, usually a primary Button and an outline one. */
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* A large decorative mark drawn faintly in the accent behind the top right, such as the product's logo as an
|
|
16
|
+
* SVG that paints with currentColor. It is hidden from assistive technology.
|
|
17
|
+
*/
|
|
18
|
+
mark?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The introduction at the top of a front page or a long explainer: a large title, what the page is, and where to
|
|
22
|
+
* go next, on a card with an optional watermark. One per page, above everything else; a section uses PageSection.
|
|
23
|
+
*/
|
|
24
|
+
export declare function PageHero({ eyebrow, title, lead, detail, actions, mark }: PageHeroProps): import("react").JSX.Element;
|
|
25
|
+
//#endregion
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/components/siyahi/page-hero.tsx
|
|
3
|
+
/**
|
|
4
|
+
* The introduction at the top of a front page or a long explainer: a large title, what the page is, and where to
|
|
5
|
+
* go next, on a card with an optional watermark. One per page, above everything else; a section uses PageSection.
|
|
6
|
+
*/
|
|
7
|
+
function PageHero({ eyebrow, title, lead, detail, actions, mark }) {
|
|
8
|
+
return /* @__PURE__ */ jsxs("header", {
|
|
9
|
+
"data-slot": "page-hero",
|
|
10
|
+
className: "relative isolate flex flex-col gap-4 overflow-hidden rounded-2xl border bg-card px-6 py-8 sm:px-10 sm:py-10",
|
|
11
|
+
children: [
|
|
12
|
+
mark && /* @__PURE__ */ jsx("div", {
|
|
13
|
+
"aria-hidden": true,
|
|
14
|
+
className: "pointer-events-none absolute -top-8 -right-10 -z-10 size-80 text-chart-1 opacity-10 sm:-top-12 sm:right-4 [&>svg]:size-full",
|
|
15
|
+
children: mark
|
|
16
|
+
}),
|
|
17
|
+
eyebrow && /* @__PURE__ */ jsx("p", {
|
|
18
|
+
className: "font-mono text-xs text-link",
|
|
19
|
+
children: eyebrow
|
|
20
|
+
}),
|
|
21
|
+
/* @__PURE__ */ jsx("h1", {
|
|
22
|
+
className: "text-5xl font-semibold tracking-tighter text-balance sm:text-6xl",
|
|
23
|
+
children: title
|
|
24
|
+
}),
|
|
25
|
+
lead && /* @__PURE__ */ jsx("div", {
|
|
26
|
+
className: "max-w-xl text-lg text-pretty",
|
|
27
|
+
children: lead
|
|
28
|
+
}),
|
|
29
|
+
detail && /* @__PURE__ */ jsx("div", {
|
|
30
|
+
className: "max-w-xl text-pretty text-muted-foreground",
|
|
31
|
+
children: detail
|
|
32
|
+
}),
|
|
33
|
+
actions && /* @__PURE__ */ jsx("div", {
|
|
34
|
+
className: "mt-2 flex flex-wrap gap-2",
|
|
35
|
+
children: actions
|
|
36
|
+
})
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { PageHero };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/components/siyahi/page-toc.d.ts
|
|
2
|
+
export interface PageTocItem {
|
|
3
|
+
/** The `id` of the PageSection it jumps to. */
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PageTocProps {
|
|
8
|
+
items: PageTocItem[];
|
|
9
|
+
/** Names the navigation for screen readers. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Layout only, such as `top-14` under a sticky page header. */
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A long page's sections as one sticky "On this page" index. It stays at the top while the reader scrolls,
|
|
16
|
+
* underlines the section in view in the link colour, and scrolls sideways on a phone with the current link kept
|
|
17
|
+
* in sight.
|
|
18
|
+
*/
|
|
19
|
+
export declare function PageToc({ items, label, className }: PageTocProps): import("react").JSX.Element;
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cn } from "cn";
|
|
2
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
//#region src/components/siyahi/page-toc.tsx
|
|
6
|
+
/**
|
|
7
|
+
* A long page's sections as one sticky "On this page" index. It stays at the top while the reader scrolls,
|
|
8
|
+
* underlines the section in view in the link colour, and scrolls sideways on a phone with the current link kept
|
|
9
|
+
* in sight.
|
|
10
|
+
*/
|
|
11
|
+
function PageToc({ items, label = "On this page", className }) {
|
|
12
|
+
const nav = useRef(null);
|
|
13
|
+
const active = useActiveSection(items, nav);
|
|
14
|
+
const strip = useRef(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const link = strip.current?.querySelector(`a[href="#${CSS.escape(active ?? "")}"]`);
|
|
17
|
+
const box = strip.current;
|
|
18
|
+
if (!link || !box) return;
|
|
19
|
+
const left = link.offsetLeft - box.clientWidth / 2 + link.offsetWidth / 2;
|
|
20
|
+
box.scrollTo({
|
|
21
|
+
left,
|
|
22
|
+
behavior: "smooth"
|
|
23
|
+
});
|
|
24
|
+
}, [active]);
|
|
25
|
+
return /* @__PURE__ */ jsx("nav", {
|
|
26
|
+
ref: nav,
|
|
27
|
+
"aria-label": label,
|
|
28
|
+
"data-slot": "page-toc",
|
|
29
|
+
className: cn("sticky top-0 z-10 -mx-4 border-b bg-background px-4 sm:mx-0 sm:px-0", className),
|
|
30
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
31
|
+
ref: strip,
|
|
32
|
+
className: "flex items-baseline gap-x-4 overflow-x-auto py-2.5 mask-r-from-90% [scrollbar-width:none] sm:mask-none",
|
|
33
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
34
|
+
"aria-hidden": true,
|
|
35
|
+
className: "flex shrink-0 cursor-default items-center gap-1 self-center text-[0.6875rem] font-medium tracking-wider text-muted-foreground uppercase select-none",
|
|
36
|
+
children: [label, /* @__PURE__ */ jsx(ChevronRightIcon, { className: "size-3.5" })]
|
|
37
|
+
}), items.map((item) => /* @__PURE__ */ jsx("a", {
|
|
38
|
+
href: `#${item.id}`,
|
|
39
|
+
"aria-current": item.id === active ? "location" : void 0,
|
|
40
|
+
onClick: (event) => {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
document.getElementById(item.id)?.scrollIntoView({ behavior: "smooth" });
|
|
43
|
+
},
|
|
44
|
+
className: "shrink-0 text-sm whitespace-nowrap text-muted-foreground decoration-2 underline-offset-[6px] transition-colors hover:text-foreground hover:underline hover:decoration-border aria-[current=location]:font-medium aria-[current=location]:text-link aria-[current=location]:underline aria-[current=location]:decoration-current",
|
|
45
|
+
children: item.title
|
|
46
|
+
}, item.id))]
|
|
47
|
+
})
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function useActiveSection(items, strip) {
|
|
51
|
+
const [active, setActive] = useState(items[0]?.id);
|
|
52
|
+
const ids = items.map((item) => item.id).join(" ");
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const sections = ids.split(" ").flatMap((id) => document.getElementById(id) ?? []);
|
|
55
|
+
if (sections.length === 0) return;
|
|
56
|
+
const update = () => {
|
|
57
|
+
const stripBottom = strip.current?.getBoundingClientRect().bottom ?? 0;
|
|
58
|
+
const line = (section) => Math.max(stripBottom, Number.parseFloat(getComputedStyle(section).scrollMarginTop) || 0, innerHeight * .3);
|
|
59
|
+
const reached = sections.filter((section) => section.getBoundingClientRect().top <= line(section) + 1);
|
|
60
|
+
setActive((reached.at(-1) ?? sections[0]).id);
|
|
61
|
+
};
|
|
62
|
+
update();
|
|
63
|
+
addEventListener("scroll", update, {
|
|
64
|
+
passive: true,
|
|
65
|
+
capture: true
|
|
66
|
+
});
|
|
67
|
+
addEventListener("resize", update);
|
|
68
|
+
return () => {
|
|
69
|
+
removeEventListener("scroll", update, { capture: true });
|
|
70
|
+
removeEventListener("resize", update);
|
|
71
|
+
};
|
|
72
|
+
}, [ids, strip]);
|
|
73
|
+
return active;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { PageToc };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ComponentProps, ReactNode } from "react";
|
|
2
|
+
//#region src/components/siyahi/quote.d.ts
|
|
3
|
+
export interface QuoteProps extends ComponentProps<"blockquote"> {
|
|
4
|
+
/** Who said it, or where it is from, set under the quote. */
|
|
5
|
+
source?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A quotation set apart from the prose on a muted panel, with its source under it. The panel is the whole box, not a
|
|
9
|
+
* stripe down one side.
|
|
10
|
+
*/
|
|
11
|
+
export declare function Quote({ source, className, children, ...props }: QuoteProps): import("react").JSX.Element;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cn } from "cn";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
//#region src/components/siyahi/quote.tsx
|
|
4
|
+
/**
|
|
5
|
+
* A quotation set apart from the prose on a muted panel, with its source under it. The panel is the whole box, not a
|
|
6
|
+
* stripe down one side.
|
|
7
|
+
*/
|
|
8
|
+
function Quote({ source, className, children, ...props }) {
|
|
9
|
+
return /* @__PURE__ */ jsxs("figure", {
|
|
10
|
+
"data-slot": "quote",
|
|
11
|
+
className: cn("flex flex-col gap-2 rounded-lg bg-muted/60 px-4 py-3", className),
|
|
12
|
+
children: [/* @__PURE__ */ jsx("blockquote", {
|
|
13
|
+
className: "text-pretty italic",
|
|
14
|
+
...props,
|
|
15
|
+
children
|
|
16
|
+
}), source && /* @__PURE__ */ jsx("figcaption", {
|
|
17
|
+
className: "text-sm text-muted-foreground",
|
|
18
|
+
children: source
|
|
19
|
+
})]
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
export { Quote };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/components/siyahi/range-position.d.ts
|
|
3
|
+
export interface RangePositionProps {
|
|
4
|
+
low: number;
|
|
5
|
+
high: number;
|
|
6
|
+
value: number;
|
|
7
|
+
/** Names the low end: "52-week low". */
|
|
8
|
+
lowLabel?: string;
|
|
9
|
+
highLabel?: string;
|
|
10
|
+
/** Follows the percentage in the full variant: "of the way up the 52-week range". */
|
|
11
|
+
caption?: ReactNode;
|
|
12
|
+
/** Writes low, high and value; useFormat().money by default. */
|
|
13
|
+
valueFormatter?: (value: number) => string;
|
|
14
|
+
/** compact fits a table cell: the ends' figures either side of the track, nothing above it. */
|
|
15
|
+
variant?: "full" | "compact";
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* How far a value sits between a low and a high, such as a price in its 52-week range: a track filled to the value
|
|
20
|
+
* with a hollow thumb on it, the ends labelled, and in the full variant the share written large with the value over
|
|
21
|
+
* the thumb.
|
|
22
|
+
*/
|
|
23
|
+
export declare function RangePosition({ low, high, value, lowLabel, highLabel, caption, valueFormatter, variant, className }: RangePositionProps): import("react").JSX.Element;
|
|
24
|
+
//#endregion
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useFormat } from "../../lib/format.js";
|
|
2
|
+
import { cn } from "cn";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/components/siyahi/range-position.tsx
|
|
5
|
+
/**
|
|
6
|
+
* How far a value sits between a low and a high, such as a price in its 52-week range: a track filled to the value
|
|
7
|
+
* with a hollow thumb on it, the ends labelled, and in the full variant the share written large with the value over
|
|
8
|
+
* the thumb.
|
|
9
|
+
*/
|
|
10
|
+
function RangePosition({ low, high, value, lowLabel = "Low", highLabel = "High", caption = "of the way up the range", valueFormatter, variant = "full", className }) {
|
|
11
|
+
const f = useFormat();
|
|
12
|
+
const write = valueFormatter ?? f.money;
|
|
13
|
+
const share = high > low ? Math.min(1, Math.max(0, (value - low) / (high - low))) : 0;
|
|
14
|
+
const at = `${(share * 100).toFixed(2)}%`;
|
|
15
|
+
const track = /* @__PURE__ */ jsxs("div", {
|
|
16
|
+
role: "meter",
|
|
17
|
+
"aria-label": `${lowLabel} to ${highLabel}`,
|
|
18
|
+
"aria-valuemin": low,
|
|
19
|
+
"aria-valuemax": high,
|
|
20
|
+
"aria-valuenow": value,
|
|
21
|
+
"aria-valuetext": `${write(value)}, ${f.pct(share, 0)} of the way from ${write(low)} to ${write(high)}`,
|
|
22
|
+
className: "relative h-1.5 min-w-12 flex-1 rounded-full bg-muted",
|
|
23
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
24
|
+
"data-slot": "range-fill",
|
|
25
|
+
className: "absolute inset-y-0 left-0 rounded-full bg-foreground",
|
|
26
|
+
style: { width: at }
|
|
27
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
28
|
+
"data-slot": "range-thumb",
|
|
29
|
+
className: cn("absolute top-1/2 -translate-1/2 rounded-full border-2 border-foreground bg-card", variant === "full" ? "size-3.5" : "size-2.5"),
|
|
30
|
+
style: { left: at }
|
|
31
|
+
})]
|
|
32
|
+
});
|
|
33
|
+
if (variant === "compact") return /* @__PURE__ */ jsxs("div", {
|
|
34
|
+
"data-slot": "range-position",
|
|
35
|
+
className: cn("flex items-center gap-2 text-xs text-muted-foreground tabular-nums", className),
|
|
36
|
+
children: [
|
|
37
|
+
/* @__PURE__ */ jsx("span", {
|
|
38
|
+
className: "min-w-12 text-right",
|
|
39
|
+
children: write(low)
|
|
40
|
+
}),
|
|
41
|
+
track,
|
|
42
|
+
/* @__PURE__ */ jsx("span", {
|
|
43
|
+
className: "min-w-12",
|
|
44
|
+
children: write(high)
|
|
45
|
+
})
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
49
|
+
"data-slot": "range-position",
|
|
50
|
+
className: cn("grid gap-3", className),
|
|
51
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
52
|
+
className: "flex flex-wrap items-baseline gap-x-2",
|
|
53
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
54
|
+
className: "text-4xl font-semibold tracking-tight tabular-nums",
|
|
55
|
+
children: f.pct(share, 0)
|
|
56
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
57
|
+
className: "text-sm text-muted-foreground",
|
|
58
|
+
children: caption
|
|
59
|
+
})]
|
|
60
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
61
|
+
className: "grid gap-1.5",
|
|
62
|
+
children: [
|
|
63
|
+
/* @__PURE__ */ jsx("div", {
|
|
64
|
+
className: "relative h-5",
|
|
65
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
66
|
+
"data-slot": "range-value",
|
|
67
|
+
className: "absolute text-sm font-semibold whitespace-nowrap tabular-nums",
|
|
68
|
+
style: {
|
|
69
|
+
left: at,
|
|
70
|
+
transform: `translateX(-${at})`
|
|
71
|
+
},
|
|
72
|
+
children: write(value)
|
|
73
|
+
})
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ jsx("div", {
|
|
76
|
+
className: "flex",
|
|
77
|
+
children: track
|
|
78
|
+
}),
|
|
79
|
+
/* @__PURE__ */ jsxs("div", {
|
|
80
|
+
className: "mt-1 flex justify-between gap-4 text-xs text-muted-foreground",
|
|
81
|
+
children: [/* @__PURE__ */ jsxs("span", { children: [
|
|
82
|
+
lowLabel,
|
|
83
|
+
" ",
|
|
84
|
+
/* @__PURE__ */ jsx("span", {
|
|
85
|
+
className: "text-foreground tabular-nums",
|
|
86
|
+
children: write(low)
|
|
87
|
+
})
|
|
88
|
+
] }), /* @__PURE__ */ jsxs("span", {
|
|
89
|
+
className: "text-right",
|
|
90
|
+
children: [
|
|
91
|
+
highLabel,
|
|
92
|
+
" ",
|
|
93
|
+
/* @__PURE__ */ jsx("span", {
|
|
94
|
+
className: "text-foreground tabular-nums",
|
|
95
|
+
children: write(high)
|
|
96
|
+
})
|
|
97
|
+
]
|
|
98
|
+
})]
|
|
99
|
+
})
|
|
100
|
+
]
|
|
101
|
+
})]
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { RangePosition };
|
|
@@ -11,7 +11,7 @@ export interface SiyahiProviderProps {
|
|
|
11
11
|
locale?: Partial<SiyahiLocale>;
|
|
12
12
|
/**
|
|
13
13
|
* Set it on a nested provider for one part of a page. Left out, a provider keeps the surface around it, so a
|
|
14
|
-
* provider that only changes the locale changes nothing else; the outermost default is "
|
|
14
|
+
* provider that only changes the locale changes nothing else; the outermost default is "card".
|
|
15
15
|
*/
|
|
16
16
|
surface?: Surface;
|
|
17
17
|
children: ReactNode;
|
|
@@ -4,7 +4,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
4
4
|
import { createContext, use, useMemo } from "react";
|
|
5
5
|
//#region src/components/siyahi/siyahi-provider.tsx
|
|
6
6
|
const LocaleContext = createContext(defaultLocale);
|
|
7
|
-
const SurfaceContext = createContext("
|
|
7
|
+
const SurfaceContext = createContext("card");
|
|
8
8
|
/** Wrap the app once. Carries the locale, the surface and the tooltip provider shadcn asks for. */
|
|
9
9
|
function SiyahiProvider({ locale, surface, children }) {
|
|
10
10
|
const { numberLocale, currency, timeZone } = {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/components/siyahi/target-rows.d.ts
|
|
3
|
+
/** Where a row stands against its target. A warning is for a real problem, not merely a miss. */
|
|
4
|
+
export interface TargetStatus {
|
|
5
|
+
kind: "met" | "missed" | "warning";
|
|
6
|
+
/** A few words: "On target", "2.1 pts under", "Rebalance". */
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TargetRow {
|
|
10
|
+
label: ReactNode;
|
|
11
|
+
/** A muted line under the label, such as the asset class. */
|
|
12
|
+
detail?: ReactNode;
|
|
13
|
+
value: number;
|
|
14
|
+
target: number;
|
|
15
|
+
/** Qualitative ranges behind the bar as [from, to]; each later band draws a grey step darker, so nest them. */
|
|
16
|
+
bands?: [number, number][];
|
|
17
|
+
status?: TargetStatus;
|
|
18
|
+
/** Tells rows apart when labels are not plain unique text. */
|
|
19
|
+
key?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface TargetRowsProps {
|
|
22
|
+
rows: TargetRow[];
|
|
23
|
+
/** The scale every row shares; by default 0 to the largest value, target or band edge. */
|
|
24
|
+
domain?: [number, number];
|
|
25
|
+
/** Writes the value and the target; useFormat().count by default. */
|
|
26
|
+
valueFormatter?: (value: number) => string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Bullet rows: each row's label, value and status beside a track where the value is a solid bar, the target a tall
|
|
31
|
+
* tick and the qualitative ranges grey bands. On a narrow card the label and value stack above the track.
|
|
32
|
+
*/
|
|
33
|
+
export declare function TargetRows({ rows, domain, valueFormatter, className }: TargetRowsProps): import("react").JSX.Element;
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useFormat } from "../../lib/format.js";
|
|
2
|
+
import { cn } from "cn";
|
|
3
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/components/siyahi/target-rows.tsx
|
|
5
|
+
const GLYPH = {
|
|
6
|
+
met: "✓",
|
|
7
|
+
missed: "—",
|
|
8
|
+
warning: "⚠"
|
|
9
|
+
};
|
|
10
|
+
const BAND_DEPTH = [
|
|
11
|
+
14,
|
|
12
|
+
26,
|
|
13
|
+
38
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Bullet rows: each row's label, value and status beside a track where the value is a solid bar, the target a tall
|
|
17
|
+
* tick and the qualitative ranges grey bands. On a narrow card the label and value stack above the track.
|
|
18
|
+
*/
|
|
19
|
+
function TargetRows({ rows, domain, valueFormatter, className }) {
|
|
20
|
+
const f = useFormat();
|
|
21
|
+
const write = valueFormatter ?? f.count;
|
|
22
|
+
const [low, high] = domain ?? [0, Math.max(...rows.flatMap((row) => [
|
|
23
|
+
row.value,
|
|
24
|
+
row.target,
|
|
25
|
+
...row.bands?.flat() ?? []
|
|
26
|
+
]))];
|
|
27
|
+
const at = (value) => `${(Math.min(1, Math.max(0, (value - low) / (high - low || 1))) * 100).toFixed(2)}%`;
|
|
28
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
29
|
+
"data-slot": "target-rows",
|
|
30
|
+
className: cn("@container/target divide-y", className),
|
|
31
|
+
children: rows.map((row, index) => /* @__PURE__ */ jsxs("li", {
|
|
32
|
+
"data-slot": "target-row",
|
|
33
|
+
className: "flex flex-col gap-3 py-3 @lg/target:flex-row @lg/target:items-center @lg/target:gap-6",
|
|
34
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
35
|
+
className: "flex min-w-0 flex-col text-sm @lg/target:w-64 @lg/target:shrink-0",
|
|
36
|
+
children: [
|
|
37
|
+
/* @__PURE__ */ jsxs("div", {
|
|
38
|
+
className: "flex items-baseline justify-between gap-3",
|
|
39
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
40
|
+
className: "truncate font-medium",
|
|
41
|
+
children: row.label
|
|
42
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
43
|
+
className: "shrink-0 text-base font-semibold tabular-nums",
|
|
44
|
+
children: write(row.value)
|
|
45
|
+
})]
|
|
46
|
+
}),
|
|
47
|
+
/* @__PURE__ */ jsxs("div", {
|
|
48
|
+
className: "flex justify-between gap-3 text-muted-foreground",
|
|
49
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
50
|
+
className: "truncate",
|
|
51
|
+
children: row.detail
|
|
52
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
53
|
+
className: "shrink-0 tabular-nums",
|
|
54
|
+
children: ["Target ", write(row.target)]
|
|
55
|
+
})]
|
|
56
|
+
}),
|
|
57
|
+
row.status && /* @__PURE__ */ jsxs("span", {
|
|
58
|
+
"data-slot": "target-status",
|
|
59
|
+
"data-kind": row.status.kind,
|
|
60
|
+
className: "mt-1 flex gap-1.5",
|
|
61
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
62
|
+
"aria-hidden": "true",
|
|
63
|
+
className: cn("w-4 text-center", row.status.kind === "warning" && "text-warning", row.status.kind === "missed" && "text-muted-foreground"),
|
|
64
|
+
children: GLYPH[row.status.kind]
|
|
65
|
+
}), row.status.label]
|
|
66
|
+
})
|
|
67
|
+
]
|
|
68
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
69
|
+
"aria-hidden": "true",
|
|
70
|
+
className: "relative h-5 rounded-sm bg-muted @lg/target:flex-1",
|
|
71
|
+
children: [
|
|
72
|
+
row.bands?.map(([from, to], step) => /* @__PURE__ */ jsx("div", {
|
|
73
|
+
"data-slot": "target-band",
|
|
74
|
+
className: "absolute inset-y-0",
|
|
75
|
+
style: {
|
|
76
|
+
left: at(Math.min(from, to)),
|
|
77
|
+
right: `calc(100% - ${at(Math.max(from, to))})`,
|
|
78
|
+
background: `color-mix(in oklab, var(--foreground) ${BAND_DEPTH[Math.min(step, BAND_DEPTH.length - 1)]}%, var(--muted))`
|
|
79
|
+
}
|
|
80
|
+
}, `${from}:${to}`)),
|
|
81
|
+
/* @__PURE__ */ jsx("div", {
|
|
82
|
+
"data-slot": "target-value",
|
|
83
|
+
className: "absolute inset-y-1.5 left-0 bg-foreground",
|
|
84
|
+
style: { width: at(row.value) }
|
|
85
|
+
}),
|
|
86
|
+
/* @__PURE__ */ jsx("div", {
|
|
87
|
+
"data-slot": "target-tick",
|
|
88
|
+
className: "absolute -inset-y-1 w-0.5 -translate-x-1/2 bg-foreground ring-1 ring-card",
|
|
89
|
+
style: { left: at(row.target) }
|
|
90
|
+
})
|
|
91
|
+
]
|
|
92
|
+
})]
|
|
93
|
+
}, row.key ?? (typeof row.label === "string" ? row.label : index)))
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { TargetRows };
|
|
@@ -15,7 +15,10 @@ export interface TreemapProps<T extends TreemapItem> {
|
|
|
15
15
|
height: number;
|
|
16
16
|
/** ±range: the change, as a fraction, that takes the deepest heat step. Default 0.05 (±5%); a range that is not a positive number uses the default. */
|
|
17
17
|
range?: number;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Gets the item under a mouse, and null when it moves onto a group's name or leaves the map. A tap fires no hover:
|
|
20
|
+
* on a touch screen it selects, so the page shows the tile in place rather than in a card at the finger.
|
|
21
|
+
*/
|
|
19
22
|
onHover?: (item: T | null) => void;
|
|
20
23
|
/** The selected item, matched by key, whose tile carries a ring. Pass back what onSelect gave. */
|
|
21
24
|
selected?: T | null;
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
import { useFormat } from "../../lib/format.js";
|
|
2
|
+
import { useElementWidth } from "../../lib/use-element-width.js";
|
|
2
3
|
import { heat, heatFill, heatRange, layout } from "../../lib/treemap.js";
|
|
3
4
|
import { cn } from "cn";
|
|
4
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
-
import { useEffect, useEffectEvent,
|
|
6
|
+
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
|
6
7
|
//#region src/components/siyahi/treemap.tsx
|
|
7
8
|
/** Holdings heat map: area by weight, colour by signed change, optionally grouped; hand-drawn SVG. */
|
|
8
9
|
function Treemap({ items, height, range, onHover, selected, onSelect }) {
|
|
9
|
-
const ref =
|
|
10
|
-
const [width, setWidth] = useState(0);
|
|
10
|
+
const [ref, width] = useElementWidth();
|
|
11
11
|
const [focusedKey, setFocusedKey] = useState(null);
|
|
12
12
|
const selectedKey = selected ? selected.key ?? selected.name : null;
|
|
13
13
|
const { pct } = useFormat();
|
|
14
|
-
useLayoutEffect(() => {
|
|
15
|
-
const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
|
|
16
|
-
if (ref.current) observer.observe(ref.current);
|
|
17
|
-
return () => observer.disconnect();
|
|
18
|
-
}, []);
|
|
19
14
|
const clearOutside = useEffectEvent((event) => {
|
|
20
15
|
const tile = event.target instanceof Element ? event.target.closest("[data-slot=\"treemap-tile\"]") : null;
|
|
21
16
|
if (!ref.current?.contains(tile)) onSelect?.(null);
|
|
@@ -43,22 +38,26 @@ function Treemap({ items, height, range, onHover, selected, onSelect }) {
|
|
|
43
38
|
role: onSelect ? "group" : "img",
|
|
44
39
|
"aria-label": "Treemap of holdings by weight and change",
|
|
45
40
|
className: "text-xs",
|
|
46
|
-
onPointerLeave: () => onHover?.(null),
|
|
41
|
+
onPointerLeave: (event) => event.pointerType === "mouse" && onHover?.(null),
|
|
47
42
|
children: [groups.map(({ group, ...box }) => /* @__PURE__ */ jsxs("svg", {
|
|
48
43
|
...box,
|
|
49
44
|
"data-slot": "treemap-group",
|
|
50
|
-
onPointerEnter: () => onHover?.(null),
|
|
51
|
-
children: [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
onPointerEnter: (event) => event.pointerType === "mouse" && onHover?.(null),
|
|
46
|
+
children: [
|
|
47
|
+
/* @__PURE__ */ jsx("rect", {
|
|
48
|
+
width: "100%",
|
|
49
|
+
height: "100%",
|
|
50
|
+
fill: "none",
|
|
51
|
+
pointerEvents: "all"
|
|
52
|
+
}),
|
|
53
|
+
/* @__PURE__ */ jsx("title", { children: group }),
|
|
54
|
+
textWidth(group) <= box.width - 4 && /* @__PURE__ */ jsx("text", {
|
|
55
|
+
x: 2,
|
|
56
|
+
y: 12,
|
|
57
|
+
className: "fill-muted-foreground font-medium",
|
|
58
|
+
children: group
|
|
59
|
+
})
|
|
60
|
+
]
|
|
62
61
|
}, group)), tiles.map(({ item, ...box }, index) => {
|
|
63
62
|
const key = item.key ?? item.name;
|
|
64
63
|
return /* @__PURE__ */ jsxs("svg", {
|
|
@@ -101,7 +100,7 @@ function Treemap({ items, height, range, onHover, selected, onSelect }) {
|
|
|
101
100
|
ref.current?.querySelectorAll("[data-slot=\"treemap-tile\"]")[next]?.focus();
|
|
102
101
|
}
|
|
103
102
|
},
|
|
104
|
-
onPointerEnter: () => onHover?.(item),
|
|
103
|
+
onPointerEnter: (event) => event.pointerType === "mouse" && onHover?.(item),
|
|
105
104
|
onClick: () => onSelect?.(key === selectedKey ? null : item),
|
|
106
105
|
children: [
|
|
107
106
|
/* @__PURE__ */ jsx("rect", {
|
|
@@ -120,14 +119,14 @@ function Treemap({ items, height, range, onHover, selected, onSelect }) {
|
|
|
120
119
|
strokeWidth: 2,
|
|
121
120
|
className: "stroke-foreground"
|
|
122
121
|
}),
|
|
123
|
-
box.width
|
|
122
|
+
box.width >= MIN_LABEL_WIDTH && box.height >= MIN_LABEL_HEIGHT && /* @__PURE__ */ jsxs("text", {
|
|
124
123
|
x: 8,
|
|
125
124
|
y: 18,
|
|
126
125
|
className: "fill-foreground",
|
|
127
126
|
children: [/* @__PURE__ */ jsx("tspan", {
|
|
128
127
|
className: "font-medium",
|
|
129
|
-
children: item.name
|
|
130
|
-
}), /* @__PURE__ */ jsx("tspan", {
|
|
128
|
+
children: fit(item.name, box.width - 16)
|
|
129
|
+
}), box.height >= TWO_LINE_HEIGHT && /* @__PURE__ */ jsx("tspan", {
|
|
131
130
|
x: 8,
|
|
132
131
|
dy: 16,
|
|
133
132
|
children: change(item.change)
|
|
@@ -139,6 +138,15 @@ function Treemap({ items, height, range, onHover, selected, onSelect }) {
|
|
|
139
138
|
})
|
|
140
139
|
});
|
|
141
140
|
}
|
|
141
|
+
const CHAR_PX = 6.6;
|
|
142
|
+
const textWidth = (text) => text.length * CHAR_PX;
|
|
143
|
+
function fit(text, width) {
|
|
144
|
+
const room = Math.floor(width / CHAR_PX);
|
|
145
|
+
return text.length <= room ? text : `${text.slice(0, Math.max(room - 1, 1)).trimEnd()}…`;
|
|
146
|
+
}
|
|
147
|
+
const MIN_LABEL_WIDTH = 56;
|
|
148
|
+
const MIN_LABEL_HEIGHT = 28;
|
|
149
|
+
const TWO_LINE_HEIGHT = 44;
|
|
142
150
|
function Swatch({ label, fill }) {
|
|
143
151
|
return /* @__PURE__ */ jsxs("svg", {
|
|
144
152
|
"data-slot": "treemap-legend-swatch",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Alert } from "../ui/alert.js";
|
|
2
|
+
import { ComponentProps } from "react";
|
|
3
|
+
//#region src/components/siyahi/warning-alert.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* An Alert for a caution the reader must not miss, such as withdrawn results or incomplete data: the whole card
|
|
6
|
+
* takes a faint amber wash inside an amber border, with the warning triangle. Never mark a warning with a single
|
|
7
|
+
* coloured edge; the tint is the card. Put an AlertTitle and an AlertDescription inside.
|
|
8
|
+
*/
|
|
9
|
+
export declare function WarningAlert({ className, children, ...props }: ComponentProps<typeof Alert>): import("react").JSX.Element;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Alert } from "../ui/alert.js";
|
|
2
|
+
import { cn } from "cn";
|
|
3
|
+
import { TriangleAlertIcon } from "lucide-react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
//#region src/components/siyahi/warning-alert.tsx
|
|
6
|
+
/**
|
|
7
|
+
* An Alert for a caution the reader must not miss, such as withdrawn results or incomplete data: the whole card
|
|
8
|
+
* takes a faint amber wash inside an amber border, with the warning triangle. Never mark a warning with a single
|
|
9
|
+
* coloured edge; the tint is the card. Put an AlertTitle and an AlertDescription inside.
|
|
10
|
+
*/
|
|
11
|
+
function WarningAlert({ className, children, ...props }) {
|
|
12
|
+
return /* @__PURE__ */ jsxs(Alert, {
|
|
13
|
+
"data-tone": "warning",
|
|
14
|
+
className: cn("gap-x-3 border-warning/45 bg-warning/10 px-4 py-3 [&>svg]:text-warning!", className),
|
|
15
|
+
...props,
|
|
16
|
+
children: [/* @__PURE__ */ jsx(TriangleAlertIcon, {}), children]
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { WarningAlert };
|