@tristan2828/ui-foundation 4.50.1 → 4.52.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.
@@ -3,24 +3,25 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [Semantic tones](#semantic-tones) · lines 33–61
7
- - [The three badge styles](#the-three-badge-styles) · lines 63–80
8
- - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 82–111
9
- - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 113–126
10
- - [Logos and brand marks](#logos-and-brand-marks) · lines 128–172
11
- - [Categorical colour](#categorical-colour) · lines 174–244
12
- - [Typography](#typography) · lines 246–337
13
- - [Written text (notes)](#written-text-notes) · lines 277–337
14
- - [Density](#density) · lines 339–396
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 398–512
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 514–583
17
- - [Notices](#notices) · lines 585–613
18
- - [A value over time](#a-value-over-time) · lines 615–648
19
- - [A list as a board](#a-list-as-a-board) · lines 650–701
20
- - [A list as cards](#a-list-as-cards) · lines 703–749
21
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 751–773
22
- - [Picking at random](#picking-at-random) · lines 775–806
23
- - [Filling in what's missing](#filling-in-whats-missing) · lines 808–858
6
+ - [Semantic tones](#semantic-tones) · lines 34–62
7
+ - [The three badge styles](#the-three-badge-styles) · lines 64–81
8
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 83–112
9
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 114–127
10
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 129–173
11
+ - [Categorical colour](#categorical-colour) · lines 175–245
12
+ - [Typography](#typography) · lines 247–338
13
+ - [Written text (notes)](#written-text-notes) · lines 278–338
14
+ - [Density](#density) · lines 340–397
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 399–513
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 515–584
17
+ - [Notices](#notices) · lines 586–614
18
+ - [A value over time](#a-value-over-time) · lines 616–694
19
+ - [Several values over time](#several-values-over-time) · lines 657–694
20
+ - [A list as a board](#a-list-as-a-board) · lines 696–747
21
+ - [A list as cards](#a-list-as-cards) · lines 749–795
22
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 797–819
23
+ - [Picking at random](#picking-at-random) · lines 821–852
24
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 854–904
24
25
 
25
26
  <!-- /index -->
26
27
 
@@ -644,8 +645,53 @@ or `'last'`) with its value.
644
645
  named by `label`, with a summary (first, last, lowest, highest, the
645
646
  target) and a table of every point. Give it the same `formatY` and
646
647
  `formatX` the page uses for those values elsewhere.
647
- - One series and a few dozen points. Several series, zoom or tooltips
648
- are a request for the foundation, not a hand-drawn chart in the app.
648
+ - **A projection** (4.51): `projection={{ label: 'Projected', points }}`
649
+ carries the line on past its last point, dashed in the same colour, for
650
+ where a value may go (a balance at a rate of return), never for what was
651
+ measured. Its points come after the last of `points`; the mark stays on
652
+ `points`, and a screen reader gets the projection's end in the summary
653
+ and its rows in the table, each named by `label`.
654
+ - One series and a few dozen points. Zoom or tooltips are a request for
655
+ the foundation, not a hand-drawn chart in the app.
656
+
657
+ ### Several values over time
658
+
659
+ `MultiLineChart` (4.51) puts several series on one plot, sharing one x
660
+ and one y range: a net worth beside its assets and what's owed, or a
661
+ balance's history beside low, middle and high projections.
662
+
663
+ ```tsx
664
+ <MultiLineChart
665
+ label="Net worth over time"
666
+ series={[
667
+ { label: 'Assets', tone: 'success', points: assets },
668
+ { label: 'Owed', tone: 'destructive', points: owed },
669
+ { label: 'Net worth', points: netWorth },
670
+ ]}
671
+ target={{ y: 1_500_000, label: 'Target' }}
672
+ formatY={usd}
673
+ formatX={month}
674
+ isLoading={isLoading}
675
+ />
676
+ ```
677
+
678
+ - **Tones, not colours:** each series takes a text tone (`info`, the
679
+ default, `success`, `warning`, `destructive` or `muted`), each clearing
680
+ 3:1 on a card in both themes. Pick by meaning where there is one (what's
681
+ owed in `destructive`); otherwise keep the series that matters most in
682
+ `info` and the rest `muted`.
683
+ - **`dashed` is for what wasn't measured:** a projection or a plan. A
684
+ projection that starts at the last measured point should repeat that
685
+ point as its first, so the lines meet.
686
+ - **A legend under the plot** names each series with its latest value,
687
+ so colour never carries the meaning alone. The target is a dashed
688
+ `success-text` line, as on `LineChart`.
689
+ - **It owns its states** as `LineChart` does: a skeleton, one quiet line
690
+ when no series has a point, a dot for a series of one point.
691
+ - **A screen reader gets a summary** (each series from first to last
692
+ value, and the target) and a table with a column per series.
693
+ - Up to four or five series. More than that, or bars and stacked areas,
694
+ are a request for the foundation.
649
695
 
650
696
  ## A list as a board
651
697
 
@@ -0,0 +1,11 @@
1
+ export type EmojiEntry = {
2
+ emoji: string;
3
+ name: string;
4
+ keywords?: string;
5
+ };
6
+ export type EmojiGroup = {
7
+ label: string;
8
+ emojis: EmojiEntry[];
9
+ };
10
+ export declare const EMOJI_GROUPS: EmojiGroup[];
11
+ export declare function filterEmojiGroups(query: string): EmojiGroup[];
@@ -0,0 +1,157 @@
1
+ export const EMOJI_GROUPS = [
2
+ {
3
+ label: 'Morning and sleep',
4
+ emojis: [
5
+ { emoji: '☀️', name: 'Sun', keywords: 'morning day bright' },
6
+ { emoji: '🌅', name: 'Sunrise', keywords: 'morning dawn' },
7
+ { emoji: '⏰', name: 'Alarm clock', keywords: 'wake time' },
8
+ { emoji: '🌙', name: 'Moon', keywords: 'night evening' },
9
+ { emoji: '😴', name: 'Sleeping face', keywords: 'sleep bed rest' },
10
+ { emoji: '🛏️', name: 'Bed', keywords: 'sleep bedroom' },
11
+ { emoji: '🌄', name: 'Sunrise over mountains', keywords: 'morning' },
12
+ { emoji: '🌃', name: 'Night with stars', keywords: 'evening' },
13
+ ],
14
+ },
15
+ {
16
+ label: 'Body and health',
17
+ emojis: [
18
+ { emoji: '🧘', name: 'Person in lotus position', keywords: 'yoga meditate breathe calm' },
19
+ { emoji: '🏃', name: 'Person running', keywords: 'run exercise workout cardio' },
20
+ { emoji: '🏋️', name: 'Person lifting weights', keywords: 'gym strength workout' },
21
+ { emoji: '🚴', name: 'Person biking', keywords: 'cycling exercise' },
22
+ { emoji: '🏊', name: 'Person swimming', keywords: 'pool exercise' },
23
+ { emoji: '🤸', name: 'Person cartwheeling', keywords: 'stretch gymnastics' },
24
+ { emoji: '💪', name: 'Flexed biceps', keywords: 'strength muscle' },
25
+ { emoji: '🦷', name: 'Tooth', keywords: 'dentist brush teeth' },
26
+ { emoji: '🪥', name: 'Toothbrush', keywords: 'brush teeth' },
27
+ { emoji: '💊', name: 'Pill', keywords: 'medicine vitamins' },
28
+ { emoji: '🩺', name: 'Stethoscope', keywords: 'doctor health' },
29
+ { emoji: '❤️', name: 'Red heart', keywords: 'love health' },
30
+ { emoji: '🫁', name: 'Lungs', keywords: 'breathe breath' },
31
+ { emoji: '🧠', name: 'Brain', keywords: 'mind think' },
32
+ ],
33
+ },
34
+ {
35
+ label: 'Self-care',
36
+ emojis: [
37
+ { emoji: '🚿', name: 'Shower', keywords: 'wash clean' },
38
+ { emoji: '🛁', name: 'Bathtub', keywords: 'bath wash' },
39
+ { emoji: '🧼', name: 'Soap', keywords: 'wash clean hands' },
40
+ { emoji: '🧴', name: 'Lotion bottle', keywords: 'skincare moisturizer sunscreen' },
41
+ { emoji: '🪒', name: 'Razor', keywords: 'shave' },
42
+ { emoji: '💇', name: 'Person getting haircut', keywords: 'hair' },
43
+ { emoji: '🧖', name: 'Person in steamy room', keywords: 'sauna spa relax' },
44
+ { emoji: '🕯️', name: 'Candle', keywords: 'calm relax' },
45
+ ],
46
+ },
47
+ {
48
+ label: 'Food and drink',
49
+ emojis: [
50
+ { emoji: '🍳', name: 'Cooking', keywords: 'breakfast egg pan' },
51
+ { emoji: '🥣', name: 'Bowl with spoon', keywords: 'cereal oatmeal breakfast' },
52
+ { emoji: '🥗', name: 'Green salad', keywords: 'lunch healthy' },
53
+ { emoji: '🍽️', name: 'Plate with cutlery', keywords: 'meal dinner eat' },
54
+ { emoji: '🍎', name: 'Red apple', keywords: 'fruit snack' },
55
+ { emoji: '🥑', name: 'Avocado', keywords: 'food' },
56
+ { emoji: '☕', name: 'Hot beverage', keywords: 'coffee tea' },
57
+ { emoji: '🍵', name: 'Teacup', keywords: 'tea' },
58
+ { emoji: '💧', name: 'Droplet', keywords: 'water drink hydrate' },
59
+ { emoji: '🥤', name: 'Cup with straw', keywords: 'drink' },
60
+ { emoji: '🛒', name: 'Shopping cart', keywords: 'groceries store' },
61
+ ],
62
+ },
63
+ {
64
+ label: 'Home and chores',
65
+ emojis: [
66
+ { emoji: '🏠', name: 'House', keywords: 'home' },
67
+ { emoji: '🧹', name: 'Broom', keywords: 'sweep clean chores' },
68
+ { emoji: '🧽', name: 'Sponge', keywords: 'scrub clean dishes' },
69
+ { emoji: '🧺', name: 'Basket', keywords: 'laundry' },
70
+ { emoji: '👕', name: 'T-shirt', keywords: 'laundry clothes' },
71
+ { emoji: '🗑️', name: 'Wastebasket', keywords: 'trash garbage' },
72
+ { emoji: '🛠️', name: 'Hammer and wrench', keywords: 'repair fix tools' },
73
+ { emoji: '🔧', name: 'Wrench', keywords: 'repair fix' },
74
+ { emoji: '🔑', name: 'Key', keywords: 'lock keys' },
75
+ { emoji: '🪴', name: 'Potted plant', keywords: 'garden water plants' },
76
+ { emoji: '🌱', name: 'Seedling', keywords: 'garden grow plant' },
77
+ { emoji: '🌿', name: 'Herb', keywords: 'garden plant' },
78
+ { emoji: '🐕', name: 'Dog', keywords: 'pet walk' },
79
+ { emoji: '🐈', name: 'Cat', keywords: 'pet' },
80
+ ],
81
+ },
82
+ {
83
+ label: 'Work and learning',
84
+ emojis: [
85
+ { emoji: '💻', name: 'Laptop', keywords: 'computer work code' },
86
+ { emoji: '📧', name: 'Email', keywords: 'mail inbox' },
87
+ { emoji: '📅', name: 'Calendar', keywords: 'date plan schedule' },
88
+ { emoji: '📝', name: 'Memo', keywords: 'notes write journal' },
89
+ { emoji: '✅', name: 'Check mark', keywords: 'done task todo' },
90
+ { emoji: '📋', name: 'Clipboard', keywords: 'list checklist' },
91
+ { emoji: '📚', name: 'Books', keywords: 'read study learn' },
92
+ { emoji: '📖', name: 'Open book', keywords: 'read' },
93
+ { emoji: '🎓', name: 'Graduation cap', keywords: 'study school learn' },
94
+ { emoji: '✏️', name: 'Pencil', keywords: 'write draw' },
95
+ { emoji: '🎯', name: 'Bullseye', keywords: 'goal focus target' },
96
+ { emoji: '💡', name: 'Light bulb', keywords: 'idea' },
97
+ { emoji: '🧾', name: 'Receipt', keywords: 'bills budget' },
98
+ { emoji: '💰', name: 'Money bag', keywords: 'finance budget savings' },
99
+ ],
100
+ },
101
+ {
102
+ label: 'Play and music',
103
+ emojis: [
104
+ { emoji: '🎵', name: 'Musical note', keywords: 'music song' },
105
+ { emoji: '🎧', name: 'Headphones', keywords: 'music podcast listen' },
106
+ { emoji: '🎸', name: 'Guitar', keywords: 'music practice' },
107
+ { emoji: '🎹', name: 'Piano keyboard', keywords: 'music practice' },
108
+ { emoji: '🎨', name: 'Artist palette', keywords: 'paint draw art' },
109
+ { emoji: '🎮', name: 'Video game', keywords: 'play controller' },
110
+ { emoji: '📺', name: 'Television', keywords: 'tv watch show' },
111
+ { emoji: '🎬', name: 'Clapper board', keywords: 'movie film' },
112
+ { emoji: '📷', name: 'Camera', keywords: 'photo picture' },
113
+ { emoji: '🧩', name: 'Puzzle piece', keywords: 'game' },
114
+ ],
115
+ },
116
+ {
117
+ label: 'Outside and travel',
118
+ emojis: [
119
+ { emoji: '🚶', name: 'Person walking', keywords: 'walk steps' },
120
+ { emoji: '🥾', name: 'Hiking boot', keywords: 'hike trail' },
121
+ { emoji: '🌳', name: 'Tree', keywords: 'park nature' },
122
+ { emoji: '⛰️', name: 'Mountain', keywords: 'hike' },
123
+ { emoji: '🌧️', name: 'Rain cloud', keywords: 'weather' },
124
+ { emoji: '❄️', name: 'Snowflake', keywords: 'winter cold' },
125
+ { emoji: '🚗', name: 'Car', keywords: 'drive commute' },
126
+ { emoji: '🚌', name: 'Bus', keywords: 'commute transit' },
127
+ { emoji: '✈️', name: 'Airplane', keywords: 'travel flight trip' },
128
+ { emoji: '🧳', name: 'Luggage', keywords: 'travel pack trip' },
129
+ ],
130
+ },
131
+ {
132
+ label: 'Symbols',
133
+ emojis: [
134
+ { emoji: '⭐', name: 'Star', keywords: 'favorite' },
135
+ { emoji: '✨', name: 'Sparkles', keywords: 'new clean' },
136
+ { emoji: '🔥', name: 'Fire', keywords: 'streak hot' },
137
+ { emoji: '🎉', name: 'Party popper', keywords: 'celebrate' },
138
+ { emoji: '🏆', name: 'Trophy', keywords: 'win goal' },
139
+ { emoji: '🔔', name: 'Bell', keywords: 'reminder notify' },
140
+ { emoji: '⏱️', name: 'Stopwatch', keywords: 'timer time' },
141
+ { emoji: '⏳', name: 'Hourglass', keywords: 'wait time' },
142
+ { emoji: '🔄', name: 'Counterclockwise arrows', keywords: 'repeat again refresh' },
143
+ { emoji: '🙏', name: 'Folded hands', keywords: 'gratitude thanks pray' },
144
+ { emoji: '😊', name: 'Smiling face', keywords: 'happy' },
145
+ { emoji: '🌈', name: 'Rainbow', keywords: 'colour' },
146
+ ],
147
+ },
148
+ ];
149
+ export function filterEmojiGroups(query) {
150
+ const q = query.trim().toLowerCase();
151
+ if (!q)
152
+ return EMOJI_GROUPS;
153
+ return EMOJI_GROUPS.map((group) => ({
154
+ ...group,
155
+ emojis: group.emojis.filter((e) => `${e.name} ${e.keywords ?? ''}`.toLowerCase().includes(q)),
156
+ })).filter((group) => group.emojis.length > 0);
157
+ }
@@ -0,0 +1,23 @@
1
+ export type EmojiFieldProps = {
2
+ /** The trigger button's id, for the field's `<FieldLabel htmlFor>`. */
3
+ id?: string;
4
+ /** The emoji, or `''` for none. */
5
+ value: string;
6
+ /** Called with the picked emoji, or `''` when cleared. */
7
+ onChange: (value: string) => void;
8
+ /** What the button says with none picked. Defaults to "Pick an emoji". */
9
+ placeholder?: string;
10
+ /** Offer Clear in the popup, for an optional field. */
11
+ clearable?: boolean;
12
+ /** The value was refused: `aria-invalid` on the button. */
13
+ invalid?: boolean;
14
+ /** Saving: the button shows the emoji but won't open. */
15
+ readOnly?: boolean;
16
+ /** Open on mount (for stories and editing in place). */
17
+ defaultOpen?: boolean;
18
+ className?: string;
19
+ 'aria-label'?: string;
20
+ 'aria-labelledby'?: string;
21
+ 'aria-describedby'?: string;
22
+ };
23
+ export declare function EmojiField({ id, value, onChange, placeholder, clearable, invalid, readOnly, defaultOpen, className, ...aria }: EmojiFieldProps): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // One emoji on a form: a routine's mark, a list's icon. The value is the
3
+ // emoji itself, or `''` for none. A button showing the choice, opening a
4
+ // Popover with a search box and a curated set of emoji in groups (not the
5
+ // whole of Unicode: `emoji-field-data.ts`). Like DateField it is one
6
+ // control for a react-hook-form `Controller`; `clearable` adds Clear.
7
+ import { useState } from 'react';
8
+ import { SmilePlus } from 'lucide-react';
9
+ import { cn } from 'cn';
10
+ import { Button } from '../ui/button.js';
11
+ import { Input } from '../ui/input.js';
12
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover.js';
13
+ import { filterEmojiGroups } from './emoji-field-data.js';
14
+ export function EmojiField({ id, value, onChange, placeholder = 'Pick an emoji', clearable = false, invalid = false, readOnly = false, defaultOpen = false, className, ...aria }) {
15
+ const [open, setOpen] = useState(defaultOpen && !readOnly);
16
+ const [query, setQuery] = useState('');
17
+ const groups = filterEmojiGroups(query);
18
+ const changeOpen = (next) => {
19
+ if (next && readOnly)
20
+ return;
21
+ setOpen(next);
22
+ if (!next)
23
+ setQuery('');
24
+ };
25
+ const pick = (next) => {
26
+ onChange(next);
27
+ changeOpen(false);
28
+ };
29
+ return (_jsxs(Popover, { open: open, onOpenChange: changeOpen, children: [_jsx(PopoverTrigger, { render: _jsx(Button, { id: id, type: "button", variant: "outline", "aria-invalid": invalid || undefined, "aria-readonly": readOnly || undefined, className: cn('w-full justify-start font-normal', !value && 'text-muted-foreground', className), ...aria }), children: value ? (_jsx("span", { className: "text-base leading-none", children: value })) : (_jsxs(_Fragment, { children: [_jsx(SmilePlus, { "aria-hidden": "true", className: "size-4" }), placeholder] })) }), _jsxs(PopoverContent, { className: "w-72 gap-2 p-2", align: "start", "data-slot": "emoji-field-popup", "aria-label": "Emoji", children: [_jsx(Input, { type: "search", autoComplete: "off", "aria-label": "Search emoji", placeholder: "Search", value: query, onChange: (event) => setQuery(event.target.value) }), _jsx("div", { className: "max-h-64 overflow-y-auto", children: groups.length === 0 ? (_jsxs("p", { className: "p-2 text-sm text-muted-foreground", children: ["No emoji match \"", query.trim(), "\"."] })) : (groups.map((group) => (_jsxs("section", { "aria-label": group.label, className: "mb-2 last:mb-0", children: [_jsx("h3", { className: "px-1 py-1 text-xs font-medium text-muted-foreground", children: group.label }), _jsx("div", { className: "grid grid-cols-7 gap-0.5", children: group.emojis.map((entry) => (_jsx("button", { type: "button", "aria-label": entry.name, title: entry.name, "aria-pressed": entry.emoji === value, onClick: () => pick(entry.emoji), className: "flex size-8 items-center justify-center rounded-md text-xl leading-none outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-pressed:bg-accent", children: entry.emoji }, entry.emoji))) })] }, group.label)))) }), clearable && value && (_jsx("div", { className: "border-t border-border pt-2", children: _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "w-full", onClick: () => pick(''), children: "Clear" }) }))] })] }));
30
+ }
@@ -10,6 +10,15 @@ export type LineChartProps = {
10
10
  y: number;
11
11
  label: string;
12
12
  };
13
+ /**
14
+ * Where the line may go next, drawn dashed on from its last point:
15
+ * `{ label: 'Projected', points }`, oldest first, each after the last of
16
+ * `points`. The mark is only ever on `points`.
17
+ */
18
+ projection?: {
19
+ label: string;
20
+ points: readonly LineChartPoint[];
21
+ };
13
22
  /** Which point to mark with a dot and its value: the lowest, the highest or the latest. */
14
23
  mark?: LineChartMark;
15
24
  /** A value as shown: `usd`. Defaults to the number as the browser writes it. */
@@ -23,4 +32,4 @@ export type LineChartProps = {
23
32
  emptyMessage?: string;
24
33
  className?: string;
25
34
  };
26
- export declare function LineChart({ label, points, target, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
35
+ export declare function LineChart({ label, points, target, projection, mark, formatY, formatX, height, isLoading, emptyMessage, className, }: LineChartProps): import("react").JSX.Element;
@@ -13,7 +13,12 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
13
13
  //
14
14
  // The picture is for the eye: a screen reader gets the figure's name,
15
15
  // a summary (first, last, lowest, highest, the target) and a table of
16
- // every point, all visually hidden. The four states: a skeleton at
16
+ // every point, all visually hidden.
17
+ //
18
+ // A `projection` (4.51) carries the line on past its last point, dashed in
19
+ // the same colour: where a balance may go, not where it went. It shares
20
+ // the plot's x and y ranges, so the target and the projection meet where
21
+ // they would. The four states: a skeleton at
17
22
  // `height` while loading, one quiet line with no points, a dot (no line)
18
23
  // for one point, and the chart.
19
24
  import { cn } from 'cn';
@@ -29,18 +34,21 @@ const PLATE = 'rounded-sm bg-card px-1';
29
34
  function besides(x) {
30
35
  return x > 0.75 ? '-translate-x-full -ml-1.5' : 'ml-1.5';
31
36
  }
32
- export function LineChart({ label, points, target, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
37
+ export function LineChart({ label, points, target, projection, mark, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
33
38
  if (isLoading) {
34
39
  return (_jsxs("div", { "data-slot": "line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
35
40
  }
36
41
  if (points.length === 0) {
37
42
  return (_jsx("figure", { "data-slot": "line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
38
43
  }
39
- const layout = chartLayout(points, target?.y, mark);
44
+ const ahead = projection?.points ?? [];
45
+ const layout = { ...chartLayout([...points, ...ahead], target?.y), markIndex: chartLayout(points, undefined, mark).markIndex };
46
+ const projected = ahead.length > 0 ? layout.places.slice(points.length - 1) : [];
47
+ const lastX = ahead.length > 0 ? ahead[ahead.length - 1].x : points[points.length - 1].x;
40
48
  const values = points.map((point) => point.y);
41
49
  const lowest = values.indexOf(Math.min(...values));
42
50
  const highest = values.indexOf(Math.max(...values));
43
- const line = layout.places.map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
51
+ const line = layout.places.slice(0, points.length).map((place) => `${place.x * 100},${place.y * 100}`).join(' ');
44
52
  const marked = layout.markIndex === undefined ? undefined : layout.places[layout.markIndex];
45
53
  const single = points.length === 1 ? layout.places[0] : undefined;
46
54
  const dot = marked ?? single;
@@ -49,11 +57,12 @@ export function LineChart({ label, points, target, mark, formatY = defaultY, for
49
57
  points.length === 1 ? `One point: ${describe(0)}` : `From ${describe(0)} to ${describe(points.length - 1)}`,
50
58
  points.length > 2 && `lowest ${describe(lowest)}`,
51
59
  points.length > 2 && `highest ${describe(highest)}`,
60
+ projection && ahead.length > 0 && `${projection.label} ${formatY(ahead[ahead.length - 1].y)} on ${formatX(lastX)}`,
52
61
  target && `${target.label} ${formatY(target.y)}`,
53
62
  ]
54
63
  .filter(Boolean)
55
64
  .join('; ');
56
- return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
65
+ return (_jsxs("figure", { "data-slot": "line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: "6 4", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: cn('absolute right-0 -translate-y-full type-caption text-success-text tabular-nums', PLATE), style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), points.length > 1 && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: line, fill: "none", strokeWidth: 2, strokeLinejoin: "round", strokeLinecap: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), projected.length > 1 && (_jsx("svg", { "data-slot": "line-chart-projection", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-info-text", children: _jsx("polyline", { points: projected.map((place) => `${place.x * 100},${place.y * 100}`).join(' '), fill: "none", strokeWidth: 2, strokeDasharray: "6 4", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke", className: "stroke-current" }) })), dot && (_jsxs(_Fragment, { children: [_jsx("span", { "data-slot": "line-chart-mark", className: "absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-info-text ring-2 ring-card", style: { left: percent(dot.x), top: percent(dot.y) } }), _jsx("span", { className: cn('absolute type-caption font-medium text-foreground tabular-nums', PLATE, besides(dot.x),
57
66
  // Under the lowest point, where the line isn't; over any other.
58
- mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), points.length > 1 && _jsx("span", { children: formatX(points[points.length - 1].x) })] }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsx("tbody", { children: points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))) })] })] }));
67
+ mark === 'min' ? 'mt-1' : '-translate-y-full -mt-1'), style: { left: percent(dot.x), top: percent(dot.y) }, children: formatY(points[marked ? layout.markIndex : 0].y) })] }))] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(points[0].x) }), (points.length > 1 || ahead.length > 0) && _jsx("span", { children: formatX(lastX) })] }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", children: "Value" })] }) }), _jsxs("tbody", { children: [points.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsx("td", { children: formatY(point.y) })] }, index))), ahead.map((point, index) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(point.x) }), _jsxs("td", { children: [formatY(point.y), " (", projection.label, ")"] })] }, `ahead-${index}`)))] })] })] }));
59
68
  }
@@ -0,0 +1,34 @@
1
+ import { type LineChartPoint } from '../../lib/line-chart-layout.js';
2
+ export type ChartTone = 'info' | 'success' | 'warning' | 'destructive' | 'muted';
3
+ export type ChartSeries = {
4
+ /** The series' name in the legend and the table: "Net worth". */
5
+ label: string;
6
+ /** Its values, oldest first. */
7
+ points: readonly LineChartPoint[];
8
+ /** Its colour, a text tone that clears 3:1 on a card. Defaults to `info`. */
9
+ tone?: ChartTone;
10
+ /** Dashed: a projection or a plan, not something measured. */
11
+ dashed?: boolean;
12
+ };
13
+ export type MultiLineChartProps = {
14
+ /** The figure's name: "Net worth over time". */
15
+ label: string;
16
+ /** The series, drawn in this order (the last on top) and listed in the legend. */
17
+ series: readonly ChartSeries[];
18
+ /** A dashed line at a value to reach: `{ y: 1_500_000, label: 'Target' }`, in `success-text`. */
19
+ target?: {
20
+ y: number;
21
+ label: string;
22
+ };
23
+ /** A value as shown: `usd`. Defaults to the number as the browser writes it. */
24
+ formatY?: (y: number) => string;
25
+ /** A point's x as shown: `formatPlainDate`. Defaults to x as given. */
26
+ formatX?: (x: LineChartPoint['x']) => string;
27
+ /** The plot's height in px; the chart fills its container's width. */
28
+ height?: number;
29
+ isLoading?: boolean;
30
+ /** What shows when no series has a point. */
31
+ emptyMessage?: string;
32
+ className?: string;
33
+ };
34
+ export declare function MultiLineChart({ label, series, target, formatY, formatX, height, isLoading, emptyMessage, className, }: MultiLineChartProps): import("react").JSX.Element;
@@ -0,0 +1,75 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Several values over time on one plot (4.51): a net worth beside its
3
+ // assets and debts, or a balance's history beside low, middle and high
4
+ // projections. LineChart's sibling, plain SVG the same way: each series is
5
+ // its own <svg> drawing in currentColor in a text tone, so the package's
6
+ // glyph check measures it like any icon, and a dashed series (a
7
+ // projection, a plan) reads apart from a solid one in either theme. A
8
+ // legend under the plot names each series with its latest value, so the
9
+ // colours never carry the meaning alone.
10
+ //
11
+ // The picture is for the eye: a screen reader gets the figure's name, a
12
+ // summary (each series from its first to its last value, the target) and
13
+ // a table with a column per series, all visually hidden. The four states:
14
+ // a skeleton at `height` while loading, one quiet line when no series has
15
+ // a point, a dot for a series of one point, and the chart.
16
+ import { cn } from 'cn';
17
+ import { Skeleton } from '../ui/skeleton.js';
18
+ import { seriesLayout } from '../../lib/line-chart-layout.js';
19
+ const TONE = {
20
+ info: 'text-info-text',
21
+ success: 'text-success-text',
22
+ warning: 'text-warning-text',
23
+ destructive: 'text-destructive-text',
24
+ muted: 'text-muted-foreground',
25
+ };
26
+ const DOT = {
27
+ info: 'bg-info-text',
28
+ success: 'bg-success-text',
29
+ warning: 'bg-warning-text',
30
+ destructive: 'bg-destructive-text',
31
+ muted: 'bg-muted-foreground',
32
+ };
33
+ const defaultY = (y) => y.toLocaleString();
34
+ const defaultX = (x) => String(x);
35
+ const percent = (fraction) => `${fraction * 100}%`;
36
+ const DASH = '6 4';
37
+ export function MultiLineChart({ label, series, target, formatY = defaultY, formatX = defaultX, height = 160, isLoading = false, emptyMessage = 'No points yet', className, }) {
38
+ if (isLoading) {
39
+ return (_jsxs("div", { "data-slot": "multi-line-chart", "data-state": "loading", "aria-busy": "true", className: cn('min-w-0', className), children: [_jsxs("span", { className: "sr-only", children: ["Loading ", label] }), _jsx(Skeleton, { style: { height }, className: "w-full" })] }));
40
+ }
41
+ const drawn = series.filter((one) => one.points.length > 0);
42
+ if (drawn.length === 0) {
43
+ return (_jsx("figure", { "data-slot": "multi-line-chart", "data-state": "empty", "aria-label": label, className: cn('min-w-0', className), children: _jsx("p", { style: { height }, className: "flex items-center justify-center rounded-md border border-dashed border-border type-body text-muted-foreground", children: emptyMessage }) }));
44
+ }
45
+ const layout = seriesLayout(drawn.map((one) => one.points), target?.y);
46
+ // The x range's two ends, by where they sit on the plot.
47
+ const ends = drawn.flatMap((one, s) => one.points.map((point, p) => ({ x: point.x, at: layout.places[s][p].x })));
48
+ const left = ends.reduce((a, b) => (b.at < a.at ? b : a));
49
+ const right = ends.reduce((a, b) => (b.at > a.at ? b : a));
50
+ // Every x once, in order along the plot, for the table's rows.
51
+ const rows = [...new Map(ends.sort((a, b) => a.at - b.at).map((end) => [String(end.x), end.x])).values()];
52
+ const summary = [
53
+ ...drawn.map((one) => {
54
+ const first = one.points[0];
55
+ const last = one.points[one.points.length - 1];
56
+ return one.points.length === 1
57
+ ? `${one.label}: ${formatY(first.y)} on ${formatX(first.x)}`
58
+ : `${one.label} from ${formatY(first.y)} on ${formatX(first.x)} to ${formatY(last.y)} on ${formatX(last.x)}`;
59
+ }),
60
+ target && `${target.label} ${formatY(target.y)}`,
61
+ ]
62
+ .filter(Boolean)
63
+ .join('; ');
64
+ return (_jsxs("figure", { "data-slot": "multi-line-chart", "data-state": "success", "aria-label": label, className: cn('flex min-w-0 flex-col gap-1.5', className), children: [_jsxs("div", { "aria-hidden": "true", className: "relative border-b border-border", style: { height }, children: [target && layout.targetY !== undefined && (_jsxs(_Fragment, { children: [_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 size-full overflow-visible text-success-text", children: _jsx("line", { x1: "0", x2: "100", y1: layout.targetY * 100, y2: layout.targetY * 100, strokeWidth: 1.5, strokeDasharray: DASH, vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }), _jsxs("span", { className: "absolute right-0 -translate-y-full rounded-sm bg-card px-1 type-caption text-success-text tabular-nums", style: { top: percent(layout.targetY) }, children: [target.label, " ", formatY(target.y)] })] })), drawn.map((one, s) => {
65
+ const tone = one.tone ?? 'info';
66
+ const places = layout.places[s];
67
+ if (places.length === 1) {
68
+ return (_jsx("span", { "data-slot": "multi-line-chart-dot", className: cn('absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ring-card', DOT[tone]), style: { left: percent(places[0].x), top: percent(places[0].y) } }, s));
69
+ }
70
+ return (_jsx("svg", { "data-slot": "multi-line-chart-series", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: cn('absolute inset-0 size-full overflow-visible', TONE[tone]), children: _jsx("polyline", { points: places.map((place) => `${place.x * 100},${place.y * 100}`).join(' '), fill: "none", strokeWidth: 2, strokeDasharray: one.dashed ? DASH : undefined, strokeLinejoin: "round", strokeLinecap: one.dashed ? undefined : 'round', vectorEffect: "non-scaling-stroke", className: "stroke-current" }) }, s));
71
+ })] }), _jsxs("div", { "aria-hidden": "true", className: "flex justify-between gap-3 type-caption text-muted-foreground tabular-nums", children: [_jsx("span", { children: formatX(left.x) }), right.at > left.at && _jsx("span", { children: formatX(right.x) })] }), _jsx("ul", { "aria-hidden": "true", "data-slot": "multi-line-chart-legend", className: "flex flex-wrap gap-x-4 gap-y-1 type-caption text-foreground", children: drawn.map((one, s) => (_jsxs("li", { className: "flex items-center gap-1.5", children: [_jsx("svg", { viewBox: "0 0 16 4", className: cn('h-1 w-4 shrink-0', TONE[one.tone ?? 'info']), children: _jsx("line", { x1: "0", x2: "16", y1: "2", y2: "2", strokeWidth: 2, strokeDasharray: one.dashed ? '4 2' : undefined, className: "stroke-current" }) }), _jsx("span", { children: one.label }), _jsx("span", { className: "font-medium tabular-nums", children: formatY(one.points[one.points.length - 1].y) })] }, s))) }), _jsx("figcaption", { className: "sr-only", children: summary }), _jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "When" }), drawn.map((one, s) => (_jsx("th", { scope: "col", children: one.label }, s)))] }) }), _jsx("tbody", { children: rows.map((x) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: formatX(x) }), drawn.map((one, s) => {
72
+ const point = one.points.find((p) => String(p.x) === String(x));
73
+ return _jsx("td", { children: point ? formatY(point.y) : '' }, s);
74
+ })] }, String(x)))) })] })] }));
75
+ }
package/dist/index.d.ts CHANGED
@@ -37,9 +37,11 @@ export { Notice, type NoticeProps, type NoticeTone } from './components/app/noti
37
37
  export { SourceCredit, type Source, type SourceCreditProps } from './components/app/source-credit.js';
38
38
  export { ProgressRing, type ProgressRingProps } from './components/app/progress-ring.js';
39
39
  export { LineChart, type LineChartMark, type LineChartPoint, type LineChartProps, } from './components/app/line-chart.js';
40
+ export { MultiLineChart, type ChartSeries, type ChartTone, type MultiLineChartProps, } from './components/app/multi-line-chart.js';
40
41
  export { StepBar, type StepBarProps } from './components/app/step-bar.js';
41
42
  export { RatingStar, type RatingStarProps, type RatingStarValue } from './components/app/rating-star.js';
42
43
  export { DateField, type DateFieldProps } from './components/app/date-field.js';
44
+ export { EmojiField, type EmojiFieldProps } from './components/app/emoji-field.js';
43
45
  export { ImageField, type ImageFieldProps } from './components/app/image-field.js';
44
46
  export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
45
47
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
package/dist/index.js CHANGED
@@ -40,9 +40,11 @@ export { Notice } from './components/app/notice.js';
40
40
  export { SourceCredit } from './components/app/source-credit.js';
41
41
  export { ProgressRing } from './components/app/progress-ring.js';
42
42
  export { LineChart, } from './components/app/line-chart.js';
43
+ export { MultiLineChart, } from './components/app/multi-line-chart.js';
43
44
  export { StepBar } from './components/app/step-bar.js';
44
45
  export { RatingStar } from './components/app/rating-star.js';
45
46
  export { DateField } from './components/app/date-field.js';
47
+ export { EmojiField } from './components/app/emoji-field.js';
46
48
  export { ImageField } from './components/app/image-field.js';
47
49
  export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
48
50
  export { formatPlainDate } from './lib/plain-date.js';
@@ -21,3 +21,16 @@ export type ChartLayout = {
21
21
  markIndex?: number;
22
22
  };
23
23
  export declare function chartLayout(points: readonly LineChartPoint[], target?: number, mark?: LineChartMark): ChartLayout;
24
+ export type SeriesLayout = {
25
+ /** Each series' places, in the order given, each in its points' order. */
26
+ places: {
27
+ x: number;
28
+ y: number;
29
+ }[][];
30
+ domain: {
31
+ min: number;
32
+ max: number;
33
+ };
34
+ targetY?: number;
35
+ };
36
+ export declare function seriesLayout(series: readonly (readonly LineChartPoint[])[], target?: number): SeriesLayout;
@@ -42,3 +42,27 @@ export function chartLayout(points, target, mark) {
42
42
  markIndex,
43
43
  };
44
44
  }
45
+ // Several series on one plot (MultiLineChart): one y range over all their
46
+ // values and the target, and one x range over all their points, so a
47
+ // series that starts later starts further right. When an x isn't a time
48
+ // or a number, each series' points are evenly spaced over the longest's.
49
+ export function seriesLayout(series, target) {
50
+ const shared = chartLayout(series.flat(), target);
51
+ const { min, max } = shared.domain;
52
+ const height = (y) => (max - y) / (max - min);
53
+ const times = series.flat().map((point) => along(point.x));
54
+ const timed = times.every(Number.isFinite) && new Set(times).size > 1;
55
+ const first = Math.min(...times);
56
+ const span = Math.max(...times) - first;
57
+ const longest = Math.max(...series.map((points) => points.length - 1));
58
+ const across = (x, index) => {
59
+ if (timed)
60
+ return (along(x) - first) / span;
61
+ return longest === 0 ? 0.5 : index / longest;
62
+ };
63
+ return {
64
+ places: series.map((points) => points.map((point, index) => ({ x: across(point.x, index), y: height(point.y) }))),
65
+ domain: shared.domain,
66
+ targetY: shared.targetY,
67
+ };
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.50.1",
3
+ "version": "4.52.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {