@hackcove/ui-react 0.10.4 → 0.10.5

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.
@@ -9,6 +9,8 @@ import { type TextTone } from "./typography.js";
9
9
  * five `TextTone` names; a chart with more than five parts is why these two live here.
10
10
  */
11
11
  export type VizTone = TextTone | "neutral" | "violet";
12
+ /** Tone → text utility, painted as a fill. The five `TextTone` colours are shared; the two extra ones are remapped by every theme. */
13
+ export declare const VIZ_TONE: Record<VizTone, string>;
12
14
  /** One part of a `StackedBar`: its `label`, its `value` (its share is `value` over the sum) and its colour. */
13
15
  export type StackedSegment = {
14
16
  label: string;
@@ -3,7 +3,7 @@ import { cx } from "./cx.js";
3
3
  import { announcedPercent, BAR_TRACK, sharePercent } from "./ProgressBar.js";
4
4
  import { TEXT_TONE } from "./typography.js";
5
5
  /** Tone → text utility, painted as a fill. The five `TextTone` colours are shared; the two extra ones are remapped by every theme. */
6
- const VIZ_TONE = {
6
+ export const VIZ_TONE = {
7
7
  ...TEXT_TONE,
8
8
  neutral: "text-neutral-500",
9
9
  violet: "text-purple-700",
@@ -5,9 +5,10 @@ export type StatTileTone = "neutral" | "positive" | "warn" | "negative";
5
5
  /**
6
6
  * One headline figure: an uppercase caption over a large tabular-nums value, with an optional
7
7
  * hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
8
- * tone. Pass the value already formatted.
8
+ * tone. `trend` draws a small line under the value in the same colour. Pass the value already
9
+ * formatted.
9
10
  */
10
- export declare function StatTile({ label, value, hint, tone, accent, ...rest }: {
11
+ export declare function StatTile({ label, value, hint, tone, accent, trend, ...rest }: {
11
12
  /** The small uppercase caption over the value. */
12
13
  label: ReactNode;
13
14
  /** The figure, already formatted, large and in tabular figures. */
@@ -18,4 +19,9 @@ export declare function StatTile({ label, value, hint, tone, accent, ...rest }:
18
19
  tone?: StatTileTone;
19
20
  /** A bar across the top edge in the tone's colour. */
20
21
  accent?: boolean;
22
+ /**
23
+ * The figure over time, oldest first: a sparkline under the value, in its colour, on a scale
24
+ * from 0. It is decoration, so say the trend in `hint` as well.
25
+ */
26
+ trend?: readonly number[];
21
27
  } & Unstyled<Omit<HTMLAttributes<HTMLDivElement>, "children">>): ReactElement;
package/dist/StatTile.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "./cx.js";
3
3
  import { BAR_FILL } from "./ProgressBar.js";
4
+ import { drawn, largest, plotY, runs, scaleMax } from "./trend.js";
4
5
  import { Overline } from "./typography.js";
5
6
  const VALUE = {
6
7
  neutral: "text-slate-900",
@@ -17,8 +18,18 @@ const BAR = {
17
18
  /**
18
19
  * One headline figure: an uppercase caption over a large tabular-nums value, with an optional
19
20
  * hint line beneath. `tone` colours the value; `accent` adds a bar across the top edge in the same
20
- * tone. Pass the value already formatted.
21
+ * tone. `trend` draws a small line under the value in the same colour. Pass the value already
22
+ * formatted.
21
23
  */
22
- export function StatTile({ label, value, hint, tone = "neutral", accent = false, ...rest }) {
23
- return (_jsxs("div", { ...rest, className: "relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx(Overline, { as: "p", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
24
+ export function StatTile({ label, value, hint, tone = "neutral", accent = false, trend, ...rest }) {
25
+ return (_jsxs("div", { ...rest, className: "relative overflow-hidden rounded-xl border border-slate-200/70 bg-white p-5 shadow-sm", children: [accent && (_jsx("span", { "aria-hidden": "true", className: cx("absolute inset-x-0 top-0 h-1", BAR[tone]) })), _jsx(Overline, { as: "p", children: label }), _jsx("p", { className: cx("mt-2 text-3xl font-bold tabular-nums", VALUE[tone]), children: value }), trend !== undefined && trend.length > 0 && _jsx(Sparkline, { values: trend, tone: tone }), hint != null && _jsx("p", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
26
+ }
27
+ /** The tile's trend line: edge to edge, `h-8`, in the value's colour. Hidden from assistive tech. */
28
+ function Sparkline({ values, tone, }) {
29
+ const max = scaleMax(largest([values]));
30
+ const step = values.length > 1 ? 100 / (values.length - 1) : 0;
31
+ return (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: cx("mt-3 block h-8 w-full overflow-visible", VALUE[tone]), children: runs(values.map((value, i) => {
32
+ const y = drawn(value);
33
+ return y === null ? null : { x: values.length > 1 ? step * i : 50, y: plotY(y, max) };
34
+ })).map((points) => (_jsx("polyline", { points: points, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" }, points))) }));
24
35
  }
@@ -0,0 +1,34 @@
1
+ import type { ReactElement } from "react";
2
+ import type { Passthrough } from "./props.js";
3
+ import { type VizTone } from "./StackedBar.js";
4
+ /**
5
+ * One series of a `TrendChart`: its `label` (the table's column, the Legend's entry), its colour,
6
+ * and one value per bucket in the buckets' order. A `null`, or a list shorter than the buckets, is
7
+ * a missing value.
8
+ */
9
+ export type TrendSeries = {
10
+ label: string;
11
+ tone: VizTone;
12
+ values: readonly (number | null)[];
13
+ };
14
+ /**
15
+ * A chart of one or two series over ordered buckets (weeks, months), as bars or a line, on a
16
+ * y-axis from 0. The y-axis is labelled 0, half and a rounded maximum; the x-axis names the
17
+ * buckets. A screen reader gets the same figures as a table.
18
+ */
19
+ export declare function TrendChart({ label, buckets, series, kind, ...rest }: {
20
+ /** What the chart shows: its accessible name and the caption of its table. */
21
+ label: string;
22
+ /** The x-axis labels, in order (`"W12"`, `"Mar"`). */
23
+ buckets: readonly string[];
24
+ /**
25
+ * One or two series, each `{ label, tone, values }`. A `null` value, or a `values` list shorter
26
+ * than `buckets`, is missing. Two series draw a Legend under the chart.
27
+ */
28
+ series: readonly [TrendSeries] | readonly [TrendSeries, TrendSeries];
29
+ /**
30
+ * `bar` for a count in each bucket, `line` for a level over time.
31
+ * @default bar
32
+ */
33
+ kind?: "bar" | "line";
34
+ } & Passthrough): ReactElement;
@@ -0,0 +1,51 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Legend, VIZ_TONE } from "./StackedBar.js";
3
+ import { drawn, largest, plotY, round, runs, scaleMax } from "./trend.js";
4
+ /** The most x labels drawn; past it, every k-th label is, starting with the first. */
5
+ const MAX_X_LABELS = 8;
6
+ /** Share of a bucket's width the bars of one bucket take, centred; the rest is the gap between buckets. */
7
+ const BAR_SPAN = 0.7;
8
+ const FORMAT = new Intl.NumberFormat("en-US");
9
+ /** A value as the table says it: as given, or blank when missing. */
10
+ const spoken = (value) => value == null || !Number.isFinite(value) ? null : FORMAT.format(value);
11
+ /**
12
+ * A chart of one or two series over ordered buckets (weeks, months), as bars or a line, on a
13
+ * y-axis from 0. The y-axis is labelled 0, half and a rounded maximum; the x-axis names the
14
+ * buckets. A screen reader gets the same figures as a table.
15
+ */
16
+ export function TrendChart({ label, buckets, series, kind = "bar", ...rest }) {
17
+ const values = series.map((s) => buckets.map((_, i) => drawn(s.values[i])));
18
+ const max = scaleMax(largest(values));
19
+ const ticks = [max, max / 2, 0];
20
+ return (_jsxs("figure", { ...rest, "aria-label": label, className: "flex flex-col gap-3", children: [_jsxs("div", { "aria-hidden": "true", className: "flex gap-2 pt-2 text-xs tabular-nums text-slate-500", children: [_jsx(YAxis, { ticks: ticks, max: max }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsxs("div", { className: "relative h-48", children: [ticks.map((tick) => (_jsx("span", { className: "absolute inset-x-0 border-t border-slate-200", style: { top: `${plotY(tick, max)}%` } }, tick))), _jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible", children: kind === "bar" ? (_jsx(Bars, { series: series, values: values, max: max })) : (_jsx(Lines, { series: series, values: values, max: max })) })] }), _jsx(XAxis, { buckets: buckets })] })] }), series.length === 2 && (_jsx(Legend, { items: series.map((s) => ({ label: s.label, tone: s.tone })) })), _jsx(FigureTable, { label: label, buckets: buckets, series: series })] }));
21
+ }
22
+ /** The y labels, each centred on its gridline; the widest (the top) sets the column's width. */
23
+ function YAxis({ ticks, max }) {
24
+ return (_jsxs("div", { className: "relative h-48 shrink-0 text-right", children: [_jsx("span", { className: "invisible block", children: FORMAT.format(max) }), ticks.map((tick) => (_jsx("span", { className: "absolute right-0 -translate-y-1/2", style: { top: `${plotY(tick, max)}%` }, children: FORMAT.format(tick) }, tick)))] }));
25
+ }
26
+ /** One equal column per bucket; past 8 buckets, every k-th label from the first. */
27
+ function XAxis({ buckets }) {
28
+ const every = Math.max(1, Math.ceil(buckets.length / MAX_X_LABELS));
29
+ return (_jsx("div", { className: "grid text-center", style: { gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))` }, children: buckets.map((bucket, i) => (_jsx("span", { className: "whitespace-nowrap", children: i % every === 0 ? bucket : null }, `${bucket}-${i}`))) }));
30
+ }
31
+ /** A rect per present value, the series side by side in each bucket. */
32
+ function Bars({ series, values, max }) {
33
+ const width = 100 / Math.max(1, values[0]?.length ?? 1);
34
+ const bar = (width * BAR_SPAN) / series.length;
35
+ const inset = (width * (1 - BAR_SPAN)) / 2;
36
+ return (_jsx(_Fragment, { children: series.map((s, si) => values[si]?.map((value, i) => {
37
+ if (value === null)
38
+ return null;
39
+ const y = plotY(value, max);
40
+ return (_jsx("rect", { "data-bucket": i, className: VIZ_TONE[s.tone], fill: "currentColor", x: round(width * i + inset + bar * si), y: round(y), width: round(bar), height: round(100 - y) }, `${si}-${i}`));
41
+ })) }));
42
+ }
43
+ /** A polyline per run of present values, through the middle of each bucket. */
44
+ function Lines({ series, values, max }) {
45
+ const width = 100 / Math.max(1, values[0]?.length ?? 1);
46
+ return (_jsx(_Fragment, { children: series.map((s, si) => runs((values[si] ?? []).map((value, i) => value === null ? null : { x: width * (i + 0.5), y: plotY(value, max) })).map((points) => (_jsx("polyline", { className: VIZ_TONE[s.tone], points: points, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" }, `${si}-${points}`)))) }));
47
+ }
48
+ /** The chart's figures for a screen reader: a row per bucket, a column per series. */
49
+ function FigureTable({ label, buckets, series, }) {
50
+ return (_jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", {}), series.map((s, si) => (_jsx("th", { scope: "col", children: s.label }, si)))] }) }), _jsx("tbody", { children: buckets.map((bucket, i) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: bucket }), series.map((s, si) => (_jsx("td", { children: spoken(s.values[i]) }, si)))] }, `${bucket}-${i}`))) })] }));
51
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { StatTile, type StatTileTone } from "./StatTile.js";
6
6
  export { ProgressBar, type BarSize, type BarTone } from "./ProgressBar.js";
7
7
  export { SplitBar } from "./SplitBar.js";
8
8
  export { Legend, StackedBar, type LegendItem, type StackedSegment, type VizTone, } from "./StackedBar.js";
9
+ export { TrendChart, type TrendSeries } from "./TrendChart.js";
9
10
  export { Markdown } from "./Markdown.js";
10
11
  export { AppShell, AppShellBrand, type AppShellBreakpoint, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
11
12
  export { Container } from "./Container.js";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { StatTile } from "./StatTile.js";
6
6
  export { ProgressBar } from "./ProgressBar.js";
7
7
  export { SplitBar } from "./SplitBar.js";
8
8
  export { Legend, StackedBar, } from "./StackedBar.js";
9
+ export { TrendChart } from "./TrendChart.js";
9
10
  export { Markdown } from "./Markdown.js";
10
11
  export { AppShell, AppShellBrand, AppShellHideBelow, appShellRootClassFor, } from "./AppShell.js";
11
12
  export { Container } from "./Container.js";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The scale shared by `TrendChart` and the `StatTile` sparkline. Internal: not exported from
3
+ * `index.ts`.
4
+ */
5
+ /**
6
+ * The top of a y-axis that starts at 0: the smallest of 2, 4 or 10 times a whole power of ten
7
+ * (1, 10, 100, …) that is at least `top`. Its half is then a whole number: 1 gives 2, 3 gives
8
+ * 4, 5 gives 10, 13 gives 20. Nothing above 0 gives 2.
9
+ */
10
+ export declare function scaleMax(top: number): number;
11
+ /** A value as drawn: a negative one is 0, and a missing or non-finite one is `null`. */
12
+ export declare function drawn(value: number | null | undefined): number | null;
13
+ /** The largest drawn value of every list, or 0. */
14
+ export declare function largest(lists: readonly (readonly (number | null)[])[]): number;
15
+ /** `value` as a height on a 0 to 100 plot, measured down from the top (SVG's y). */
16
+ export declare function plotY(value: number, max: number): number;
17
+ /**
18
+ * The polylines of a line through `points`, broken at every missing one: one `points` string per
19
+ * run of present values. A run of one point repeats it, so a round cap draws it as a dot.
20
+ */
21
+ export declare function runs(points: readonly ({
22
+ x: number;
23
+ y: number;
24
+ } | null)[]): string[];
25
+ /** Four decimals, so the markup stays short and stable. */
26
+ export declare function round(n: number): number;
package/dist/trend.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The scale shared by `TrendChart` and the `StatTile` sparkline. Internal: not exported from
3
+ * `index.ts`.
4
+ */
5
+ /**
6
+ * The top of a y-axis that starts at 0: the smallest of 2, 4 or 10 times a whole power of ten
7
+ * (1, 10, 100, …) that is at least `top`. Its half is then a whole number: 1 gives 2, 3 gives
8
+ * 4, 5 gives 10, 13 gives 20. Nothing above 0 gives 2.
9
+ */
10
+ export function scaleMax(top) {
11
+ for (let power = 1;; power *= 10) {
12
+ for (const step of [2, 4, 10]) {
13
+ if (step * power >= top)
14
+ return step * power;
15
+ }
16
+ }
17
+ }
18
+ /** A value as drawn: a negative one is 0, and a missing or non-finite one is `null`. */
19
+ export function drawn(value) {
20
+ if (value == null || !Number.isFinite(value))
21
+ return null;
22
+ return Math.max(0, value);
23
+ }
24
+ /** The largest drawn value of every list, or 0. */
25
+ export function largest(lists) {
26
+ let top = 0;
27
+ for (const list of lists) {
28
+ for (const value of list)
29
+ top = Math.max(top, drawn(value) ?? 0);
30
+ }
31
+ return top;
32
+ }
33
+ /** `value` as a height on a 0 to 100 plot, measured down from the top (SVG's y). */
34
+ export function plotY(value, max) {
35
+ return 100 - (value / max) * 100;
36
+ }
37
+ /**
38
+ * The polylines of a line through `points`, broken at every missing one: one `points` string per
39
+ * run of present values. A run of one point repeats it, so a round cap draws it as a dot.
40
+ */
41
+ export function runs(points) {
42
+ const out = [];
43
+ let run = [];
44
+ const close = () => {
45
+ const [only] = run;
46
+ if (run.length === 1 && only !== undefined)
47
+ run.push(only);
48
+ if (run.length > 0)
49
+ out.push(run.join(" "));
50
+ run = [];
51
+ };
52
+ for (const point of points) {
53
+ if (point === null)
54
+ close();
55
+ else
56
+ run.push(`${round(point.x)},${round(point.y)}`);
57
+ }
58
+ close();
59
+ return out;
60
+ }
61
+ /** Four decimals, so the markup stays short and stable. */
62
+ export function round(n) {
63
+ return Math.round(n * 10000) / 10000;
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hackcove/ui-react",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "description": "Hack Cove React components: primitives, typography roles and a safe Markdown renderer, written against Tailwind v4 utilities. Pair with a theme from @hackcove/themes.",
5
5
  "keywords": [
6
6
  "components",