@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.
- package/conventions/docs/design-language.md +66 -20
- package/dist/components/app/emoji-field-data.d.ts +11 -0
- package/dist/components/app/emoji-field-data.js +157 -0
- package/dist/components/app/emoji-field.d.ts +23 -0
- package/dist/components/app/emoji-field.js +30 -0
- package/dist/components/app/line-chart.d.ts +10 -1
- package/dist/components/app/line-chart.js +15 -6
- package/dist/components/app/multi-line-chart.d.ts +34 -0
- package/dist/components/app/multi-line-chart.js +75 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/line-chart-layout.d.ts +13 -0
- package/dist/lib/line-chart-layout.js +24 -0
- package/package.json +1 -1
|
@@ -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
|
|
7
|
-
- [The three badge styles](#the-three-badge-styles) · lines
|
|
8
|
-
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines
|
|
9
|
-
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines
|
|
10
|
-
- [Logos and brand marks](#logos-and-brand-marks) · lines
|
|
11
|
-
- [Categorical colour](#categorical-colour) · lines
|
|
12
|
-
- [Typography](#typography) · lines
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [
|
|
20
|
-
- [A list as
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [
|
|
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
|
-
-
|
|
648
|
-
|
|
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.
|
|
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
|
|
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(
|
|
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.
|
|
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": {
|