@atelic-action/ui 0.7.2 → 0.9.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,153 @@
1
+ import { Star } from "lucide-react";
2
+ import { type CSSProperties, type KeyboardEvent, useState } from "react";
3
+
4
+ const STEP = 0.5;
5
+
6
+ export interface StarRatingProps {
7
+ /** The rating, in half star steps from 0.5 up to `max`; null is unrated. */
8
+ value: number | null;
9
+ /** Called with the new rating, or null to clear it. Leave it out for a read only row. */
10
+ onChange?: (value: number | null) => void;
11
+ /** How many stars the scale runs to. */
12
+ max?: number;
13
+ /** The side of one star, in pixels. */
14
+ size?: number;
15
+ /** The color of a filled star: any CSS color. Defaults to the text color around it. */
16
+ fillColor?: string;
17
+ /** The color of a star's outline: any CSS color. Defaults to a faint tint of the text color. */
18
+ outlineColor?: string;
19
+ /** What a screen reader calls the row. */
20
+ label?: string;
21
+ className?: string;
22
+ }
23
+
24
+ /** "4.5 stars", "1 star", "Not rated": the rating as a person would say it. */
25
+ export function describeStarRating(value: number | null): string {
26
+ if (value === null) return "Not rated";
27
+ return `${value} ${value === 1 ? "star" : "stars"}`;
28
+ }
29
+
30
+ /**
31
+ * A row of stars that shows a rating in half star steps and, given `onChange`,
32
+ * takes one. Each star is two hit targets, its left half and its right, so a
33
+ * pointer lands on 3.5 or 4 without a second control. Picking the rating
34
+ * already set clears it, which is the only way back to unrated and needs no
35
+ * button of its own.
36
+ *
37
+ * To a keyboard and a screen reader an editable row is one slider: the arrows
38
+ * move half a star, Home clears, End is the top of the scale. The halves stay
39
+ * out of the tab order, so the row is one stop rather than ten. Without
40
+ * `onChange` it is a picture with the rating as its name.
41
+ *
42
+ * The two colors are the `--star-fill` and `--star-outline` custom properties.
43
+ * `fillColor` and `outlineColor` set them on this row; a site may also set
44
+ * them in its own CSS for every row at once.
45
+ */
46
+ export function StarRating({
47
+ value,
48
+ onChange,
49
+ max = 5,
50
+ size = 24,
51
+ fillColor,
52
+ outlineColor,
53
+ label = "Rating",
54
+ className,
55
+ }: StarRatingProps) {
56
+ const [hover, setHover] = useState<number | null>(null);
57
+ const shown = hover ?? value ?? 0;
58
+
59
+ const style = {
60
+ "--star-size": `${size}px`,
61
+ ...(fillColor ? { "--star-fill": fillColor } : {}),
62
+ ...(outlineColor ? { "--star-outline": outlineColor } : {}),
63
+ } as CSSProperties;
64
+ const classes = ["star-rating", className].filter(Boolean).join(" ");
65
+
66
+ const stars = Array.from({ length: max }, (_, i) => i + 1).map((star) => {
67
+ const fill = shown >= star ? "100%" : shown >= star - STEP ? "50%" : "0%";
68
+ return (
69
+ <span key={star} className="star-rating-star">
70
+ <Star aria-hidden="true" size={size} className="star-rating-outline" />
71
+ <span aria-hidden="true" className="star-rating-fill" style={{ width: fill }}>
72
+ <Star size={size} />
73
+ </span>
74
+ {onChange &&
75
+ [star - STEP, star].map((rating, half) => (
76
+ <button
77
+ key={rating}
78
+ type="button"
79
+ tabIndex={-1}
80
+ aria-label={describeStarRating(rating)}
81
+ className={`star-rating-hit ${half === 0 ? "is-left" : "is-right"}`}
82
+ onMouseEnter={() => setHover(rating)}
83
+ onClick={() => {
84
+ // Drop the preview with the pick: the pointer is still on this
85
+ // half, and a preview left up would hide a clear behind the
86
+ // very stars it just emptied.
87
+ setHover(null);
88
+ onChange(rating === value ? null : rating);
89
+ }}
90
+ />
91
+ ))}
92
+ </span>
93
+ );
94
+ });
95
+
96
+ if (!onChange) {
97
+ return (
98
+ <span
99
+ role="img"
100
+ aria-label={`${label}: ${describeStarRating(value)}`}
101
+ className={classes}
102
+ style={style}
103
+ >
104
+ {stars}
105
+ </span>
106
+ );
107
+ }
108
+
109
+ const set = (next: number | null) => {
110
+ if (next !== value) onChange(next);
111
+ };
112
+
113
+ const onKeyDown = (event: KeyboardEvent) => {
114
+ const current = value ?? 0;
115
+ switch (event.key) {
116
+ case "ArrowRight":
117
+ case "ArrowUp":
118
+ set(Math.min(max, current + STEP));
119
+ break;
120
+ case "ArrowLeft":
121
+ case "ArrowDown":
122
+ set(current - STEP > 0 ? current - STEP : null);
123
+ break;
124
+ case "Home":
125
+ set(null);
126
+ break;
127
+ case "End":
128
+ set(max);
129
+ break;
130
+ default:
131
+ return;
132
+ }
133
+ event.preventDefault();
134
+ };
135
+
136
+ return (
137
+ <span
138
+ role="slider"
139
+ tabIndex={0}
140
+ aria-label={label}
141
+ aria-valuemin={0}
142
+ aria-valuemax={max}
143
+ aria-valuenow={value ?? 0}
144
+ aria-valuetext={describeStarRating(value)}
145
+ className={`${classes} is-editable`}
146
+ style={style}
147
+ onKeyDown={onKeyDown}
148
+ onMouseLeave={() => setHover(null)}
149
+ >
150
+ {stars}
151
+ </span>
152
+ );
153
+ }
@@ -0,0 +1,96 @@
1
+ import { ArrowDown, ArrowRight, ArrowUp } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import type { Tone } from "./tone";
4
+
5
+ export type StatDirection = "up" | "down" | "flat";
6
+
7
+ /** One comparison under a figure: which way it moved, by how much, against what. */
8
+ export interface StatDelta {
9
+ direction: StatDirection;
10
+ /** How far it moved, e.g. "23%". */
11
+ change: string;
12
+ /** What it is measured against, short, e.g. "YoY". */
13
+ basis: string;
14
+ /** The basis spelled out, e.g. "year over year"; read aloud and shown on hover. */
15
+ basisTitle?: string;
16
+ /** The earlier figure, e.g. "5,157". It leads the line, small, under the current one. */
17
+ was?: string;
18
+ /** Whether the move is good or bad news; a spend falling and a cost falling read differently. */
19
+ tone?: Tone;
20
+ }
21
+
22
+ export interface StatItem {
23
+ /** The big figure, e.g. "$199", "3,948", "23%"; may embed an inline glyph. */
24
+ value: ReactNode;
25
+ /** An arrow before the figure, when the figure is itself a change. */
26
+ trend?: StatDirection;
27
+ /** What the figure counts. */
28
+ label: ReactNode;
29
+ /** The period the figure covers, e.g. "2026 Q3". */
30
+ period?: ReactNode;
31
+ /** Comparisons under the figure, each an arrow, a change, and what it is against. */
32
+ deltas?: StatDelta[];
33
+ /** A small dimmed qualifier at the foot (the source, a caveat). */
34
+ note?: ReactNode;
35
+ /** How the figure itself reads; set as data-tone. */
36
+ tone?: Tone;
37
+ }
38
+
39
+ export interface StatRowProps {
40
+ stats: StatItem[];
41
+ className?: string;
42
+ }
43
+
44
+ const arrow = { up: ArrowUp, down: ArrowDown, flat: ArrowRight } as const;
45
+ const spoken = { up: "Up", down: "Down", flat: "Unchanged" } as const;
46
+
47
+ /**
48
+ * A row of large figures. Under each sit its comparisons, one a line: what
49
+ * the figure was, an arrow, how far it moved, and against what. The label
50
+ * closes the stat, with the period it covers. The row wraps to as many
51
+ * columns as fit, and arrows are read aloud as up or down.
52
+ */
53
+ export function StatRow({ stats, className }: StatRowProps) {
54
+ return (
55
+ <div className={["stat-row", className].filter(Boolean).join(" ")}>
56
+ {stats.map((stat, i) => {
57
+ const Trend = stat.trend ? arrow[stat.trend] : null;
58
+ return (
59
+ // biome-ignore lint/suspicious/noArrayIndexKey: stats are static content in a fixed order
60
+ <div className="stat" key={i} data-tone={stat.tone}>
61
+ <div className="num">
62
+ {Trend && stat.trend && (
63
+ <Trend className="stat-trend" role="img" aria-label={spoken[stat.trend]} />
64
+ )}
65
+ {stat.value}
66
+ </div>
67
+ {stat.deltas?.map((delta) => {
68
+ const Icon = arrow[delta.direction];
69
+ return (
70
+ <div className="stat-delta" key={delta.basis}>
71
+ {delta.was && <span className="stat-delta-was">{delta.was}</span>}
72
+ <span className="stat-delta-change" data-tone={delta.tone ?? "muted"}>
73
+ <Icon size={14} role="img" aria-label={spoken[delta.direction]} />
74
+ {delta.change}
75
+ </span>
76
+ {delta.basisTitle ? (
77
+ <abbr className="stat-delta-basis" title={delta.basisTitle}>
78
+ {delta.basis}
79
+ </abbr>
80
+ ) : (
81
+ <span className="stat-delta-basis">{delta.basis}</span>
82
+ )}
83
+ </div>
84
+ );
85
+ })}
86
+ <div className="lbl">
87
+ {stat.label}
88
+ {stat.period && <span className="stat-period">{stat.period}</span>}
89
+ </div>
90
+ {stat.note && <div className="note">{stat.note}</div>}
91
+ </div>
92
+ );
93
+ })}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,89 @@
1
+ import type { Tone } from "./tone";
2
+
3
+ export interface ThresholdZone {
4
+ label: string;
5
+ /** Where the zone ends, in the reading's unit; the last zone runs to `max`. */
6
+ upTo?: number;
7
+ tone: Tone;
8
+ }
9
+
10
+ export interface ThresholdReading {
11
+ label: string;
12
+ value: number;
13
+ /** The figure as printed, e.g. "5.9 s". */
14
+ display: string;
15
+ tone: Tone;
16
+ }
17
+
18
+ export interface ThresholdScaleProps {
19
+ /** The far end of the scale, in the reading's unit. */
20
+ max: number;
21
+ /** Zones in order, e.g. good, slow, poor. */
22
+ zones: ThresholdZone[];
23
+ /** The lines between zones, labeled, e.g. "2.5 s" at 2.5. */
24
+ ticks?: Array<{ at: number; label: string }>;
25
+ /** One line a reading, each a pin on the scale. */
26
+ readings: ThresholdReading[];
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * A scale with zones and one line a reading: where a number sits against
32
+ * the lines that decide good, slow, and poor.
33
+ */
34
+ export function ThresholdScale({ max, zones, ticks, readings, className }: ThresholdScaleProps) {
35
+ let from = 0;
36
+ const sized = zones.map((zone) => {
37
+ const to = zone.upTo ?? max;
38
+ const width = ((to - from) / max) * 100;
39
+ from = to;
40
+ return { ...zone, width };
41
+ });
42
+ return (
43
+ <div className={["threshold-scale", className].filter(Boolean).join(" ")}>
44
+ {readings.map((reading) => (
45
+ <div className="threshold-scale-row" key={reading.label}>
46
+ <span className="threshold-scale-label">{reading.label}</span>
47
+ <span
48
+ className="threshold-scale-track"
49
+ role="img"
50
+ aria-label={`${reading.label}: ${reading.display}`}
51
+ >
52
+ {sized.map((zone) => (
53
+ <span
54
+ className="threshold-scale-zone"
55
+ key={zone.label}
56
+ data-tone={zone.tone}
57
+ style={{ width: `${zone.width}%` }}
58
+ />
59
+ ))}
60
+ <span
61
+ className="threshold-scale-pin"
62
+ data-tone={reading.tone}
63
+ style={{ left: `${Math.min(reading.value / max, 1) * 100}%` }}
64
+ />
65
+ </span>
66
+ <b className="threshold-scale-value" data-tone={reading.tone}>
67
+ {reading.display}
68
+ </b>
69
+ </div>
70
+ ))}
71
+ <div className="threshold-scale-row is-axis" aria-hidden="true">
72
+ <span className="threshold-scale-label" />
73
+ <span className="threshold-scale-axis">
74
+ {sized.map((zone) => (
75
+ <span key={zone.label} data-tone={zone.tone} style={{ width: `${zone.width}%` }}>
76
+ {zone.label}
77
+ </span>
78
+ ))}
79
+ {ticks?.map((tick) => (
80
+ <i key={tick.label} style={{ left: `${(tick.at / max) * 100}%` }}>
81
+ {tick.label}
82
+ </i>
83
+ ))}
84
+ </span>
85
+ <span className="threshold-scale-value" />
86
+ </div>
87
+ </div>
88
+ );
89
+ }
@@ -1 +1,35 @@
1
+ export { Callout, type CalloutProps } from "./Callout";
2
+ export { Checklist, type ChecklistItem, type ChecklistProps } from "./Checklist";
3
+ export { Collapsible, type CollapsibleProps } from "./Collapsible";
4
+ export {
5
+ ColumnChart,
6
+ type ColumnChartBar,
7
+ type ColumnChartGroup,
8
+ type ColumnChartProps,
9
+ } from "./ColumnChart";
10
+ export {
11
+ DataTable,
12
+ type DataTableCell,
13
+ type DataTableColumn,
14
+ type DataTableProps,
15
+ type DataTableRow,
16
+ type DataTableValueCell,
17
+ type SortDirection,
18
+ } from "./DataTable";
1
19
  export { NotFound, type NotFoundProps } from "./NotFound";
20
+ export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
21
+ export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
22
+ export {
23
+ type StatDelta,
24
+ type StatDirection,
25
+ type StatItem,
26
+ StatRow,
27
+ type StatRowProps,
28
+ } from "./StatRow";
29
+ export {
30
+ type ThresholdReading,
31
+ ThresholdScale,
32
+ type ThresholdScaleProps,
33
+ type ThresholdZone,
34
+ } from "./ThresholdScale";
35
+ export type { Tone } from "./tone";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * How a figure, a mark, or a bar reads: good, a warning, broken, plain
3
+ * information, or quiet. A component sets it as `data-tone`, and
4
+ * `styles/components.css` turns it into `--tone`, `--tone-fill`, and
5
+ * `--tone-bg` for whatever sits inside.
6
+ */
7
+ export type Tone = "good" | "warn" | "bad" | "info" | "muted";