@ultimat3/ui 24.0.0 → 25.1.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/CATALOG.md +117 -13
- package/CLAUDE.md +14 -5
- package/README.md +109 -24
- package/package.json +6 -6
- package/src/a11y.ts +3 -3
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +124 -17
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.tsx +9 -1
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.tsx +2 -2
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +5 -4
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +1 -1
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/money-view.ts +2 -7
- package/src/form/form-binding.ts +23 -6
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +15 -6
- package/src/index.ts +45 -9
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +98 -109
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +1 -1
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/components/qr-reader.ts +0 -187
- package/src/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
|
@@ -5,8 +5,8 @@ import type { ComponentDoc } from './parse-component';
|
|
|
5
5
|
import { parseComponents } from './parse-component';
|
|
6
6
|
import { renderCatalog } from './render-catalog';
|
|
7
7
|
|
|
8
|
-
const COMPONENTS_DIR = new URL('../components/', import.meta.url)
|
|
9
|
-
export const CATALOG_PATH = new URL('../../CATALOG.md', import.meta.url)
|
|
8
|
+
const COMPONENTS_DIR = Bun.fileURLToPath(new URL('../components/', import.meta.url));
|
|
9
|
+
export const CATALOG_PATH = Bun.fileURLToPath(new URL('../../CATALOG.md', import.meta.url));
|
|
10
10
|
|
|
11
11
|
/** Alphabetical by file, so the page's order is the order an agent scans for a name. */
|
|
12
12
|
export async function collectComponents(): Promise<ComponentDoc[]> {
|
|
@@ -11,6 +11,7 @@ import type { AccordionSection } from './accordion-view';
|
|
|
11
11
|
import { accordionOpenIds } from './accordion-view';
|
|
12
12
|
import type { HeadingLevel } from './heading-level';
|
|
13
13
|
import { headingTag } from './heading-level';
|
|
14
|
+
import { headingNode } from './heading-node';
|
|
14
15
|
import { Icon } from './Icon';
|
|
15
16
|
|
|
16
17
|
export interface AccordionItem extends AccordionSection {
|
|
@@ -39,7 +40,8 @@ export interface AccordionProps {
|
|
|
39
40
|
export function Accordion(props: AccordionProps): JSX.Element {
|
|
40
41
|
const base = useId('accordion');
|
|
41
42
|
const open = accordionOpenIds(props.items, props.exclusive === true);
|
|
42
|
-
|
|
43
|
+
// Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
|
|
44
|
+
const heading = props.level === undefined ? undefined : headingTag(props.level);
|
|
43
45
|
|
|
44
46
|
return (
|
|
45
47
|
<div class={cx(styles['accordion'], props.class)}>
|
|
@@ -53,7 +55,11 @@ export function Accordion(props: AccordionProps): JSX.Element {
|
|
|
53
55
|
>
|
|
54
56
|
<summary class={styles['summary']}>
|
|
55
57
|
<Icon glyph={iconChevronDown} class={styles['marker']} size="sm" />
|
|
56
|
-
|
|
58
|
+
{heading === undefined ? (
|
|
59
|
+
<span class={styles['title']}>{item.title}</span>
|
|
60
|
+
) : (
|
|
61
|
+
headingNode(heading, { class: styles['title'] }, () => item.title)
|
|
62
|
+
)}
|
|
57
63
|
</summary>
|
|
58
64
|
<div class={styles['panel']}>{item.panel}</div>
|
|
59
65
|
</details>
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// writing mode is handled by the grid itself, so RTL needs no second rule.
|
|
5
5
|
.shell {
|
|
6
6
|
display: grid;
|
|
7
|
+
// `minmax(0, …)`, never the implicit `auto`: an auto track is at least its content's min-content
|
|
8
|
+
// width, so a header that did not shrink laid a 390px phone out 429px wide.
|
|
9
|
+
grid-template-columns: minmax(0, 1fr);
|
|
7
10
|
grid-template-areas: 'header' 'sidebar' 'main' 'footer';
|
|
8
11
|
grid-template-rows: auto auto 1fr auto;
|
|
9
12
|
min-block-size: 100dvh;
|
|
@@ -51,13 +54,66 @@
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
// The bar the banner sits in: the menu button (below `md`) and the app's header, side by side.
|
|
58
|
+
.top {
|
|
59
|
+
display: flex;
|
|
60
|
+
min-inline-size: 0;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: t.space(2);
|
|
63
|
+
grid-area: header;
|
|
64
|
+
padding-inline-start: t.space(2);
|
|
65
|
+
border-block-end: 1px solid t.role('line');
|
|
66
|
+
background: t.role('surface');
|
|
67
|
+
|
|
68
|
+
@include t.respond-to(md) {
|
|
69
|
+
padding-inline-start: 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.menuOnly {
|
|
74
|
+
display: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
.header {
|
|
55
78
|
@include t.row(t.space(4), center, space-between);
|
|
56
|
-
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-inline-size: 0;
|
|
57
81
|
padding-block: t.space(3);
|
|
58
82
|
padding-inline: t.space(4);
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hidden unless the engine can open the panel it names: a button that does nothing is worse than
|
|
86
|
+
// the band an engine without popovers still shows. A touch target: this bar exists for a phone.
|
|
87
|
+
.menuButton,
|
|
88
|
+
.close {
|
|
89
|
+
@include t.focus-ring;
|
|
90
|
+
@include t.touch-target;
|
|
91
|
+
display: none;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
gap: t.space(2);
|
|
95
|
+
padding-inline: t.space(3);
|
|
96
|
+
border: 0;
|
|
97
|
+
border-radius: t.radius(md);
|
|
98
|
+
background: none;
|
|
99
|
+
color: t.role('fg-strong');
|
|
100
|
+
font: inherit;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
|
|
103
|
+
&:hover {
|
|
104
|
+
background: t.role('bg-soft');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.close {
|
|
109
|
+
margin-inline-start: auto;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// On a phone the menu button is its icon; the label stays in the accessibility tree as its name.
|
|
113
|
+
.menuLabel {
|
|
114
|
+
@include t.respond-down(sm) {
|
|
115
|
+
@include t.visually-hidden;
|
|
116
|
+
}
|
|
61
117
|
}
|
|
62
118
|
|
|
63
119
|
.sticky {
|
|
@@ -66,13 +122,23 @@
|
|
|
66
122
|
inset-block-start: 0;
|
|
67
123
|
}
|
|
68
124
|
|
|
69
|
-
//
|
|
125
|
+
// From `md` the sidebar is the inline-start column; below it, the popover panel further down.
|
|
126
|
+
// Every `[popover]` gets a UA style — fixed, centred, bordered, hidden while closed — so the
|
|
127
|
+
// in-flow box undoes each, and is what an engine without popovers shows at every width.
|
|
70
128
|
.sidebar {
|
|
71
129
|
@include t.custom-scrollbar;
|
|
130
|
+
display: block;
|
|
131
|
+
position: static;
|
|
132
|
+
inset: auto;
|
|
133
|
+
inline-size: auto;
|
|
134
|
+
block-size: auto;
|
|
135
|
+
margin: 0;
|
|
72
136
|
grid-area: sidebar;
|
|
73
137
|
padding: t.space(4);
|
|
138
|
+
border: 0;
|
|
74
139
|
border-block-end: 1px solid t.role('line');
|
|
75
140
|
background: t.role('bg-soft');
|
|
141
|
+
color: t.role('fg');
|
|
76
142
|
overflow: auto;
|
|
77
143
|
|
|
78
144
|
// Deliberately not sticky: a sticky sidebar and a sticky header both anchored at 0 overlap,
|
|
@@ -108,3 +174,38 @@
|
|
|
108
174
|
color: t.role('fg-muted');
|
|
109
175
|
font-size: t.text(sm);
|
|
110
176
|
}
|
|
177
|
+
|
|
178
|
+
// --- Below `md`, where the engine has popovers: the sidebar is an edge panel ----------------------
|
|
179
|
+
// The same look as `Drawer`'s inline-start side — raised surface, line edge, scrim — so the two
|
|
180
|
+
// off-canvas surfaces in an app read as one.
|
|
181
|
+
@include t.respond-down(md) {
|
|
182
|
+
@supports selector(:popover-open) {
|
|
183
|
+
.sidebar:not(:popover-open) {
|
|
184
|
+
display: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.sidebar:popover-open {
|
|
188
|
+
@include t.scrim-backdrop;
|
|
189
|
+
position: fixed;
|
|
190
|
+
inset-block: 0;
|
|
191
|
+
inset-inline-start: 0;
|
|
192
|
+
inset-inline-end: auto;
|
|
193
|
+
inline-size: min(var(--shell-sidebar, 16rem), calc(100vw - #{t.space(12)}));
|
|
194
|
+
block-size: 100dvh;
|
|
195
|
+
max-block-size: 100dvh;
|
|
196
|
+
border-block-end: 0;
|
|
197
|
+
border-inline-end: 1px solid t.role('line');
|
|
198
|
+
background: t.role('surface-raised');
|
|
199
|
+
box-shadow: t.shadow(xl);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.menuButton,
|
|
203
|
+
.close {
|
|
204
|
+
display: inline-flex;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.menuOnly {
|
|
208
|
+
display: flex;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
// The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo.
|
|
2
|
-
// Stateless
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the
|
|
3
|
+
// inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state,
|
|
4
|
+
// Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal
|
|
5
|
+
// `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be
|
|
6
|
+
// the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.
|
|
5
7
|
|
|
6
8
|
import type { JSX } from 'solid-js';
|
|
7
9
|
import { LIVE_REGION_LEVELS, liveRegionAttrs, useId } from '../a11y';
|
|
8
10
|
import { cx } from '../cx';
|
|
9
11
|
import { UI_KEYS } from '../i18n-keys';
|
|
12
|
+
import { iconMenu } from '../icons/glyphs/menu';
|
|
13
|
+
import { iconX } from '../icons/glyphs/x';
|
|
10
14
|
import { useUi } from '../theme/context';
|
|
11
15
|
import styles from './AppShell.module.scss';
|
|
12
16
|
import { shellIds } from './app-shell-view';
|
|
17
|
+
import { Icon } from './Icon';
|
|
13
18
|
|
|
14
19
|
export interface AppShellProps {
|
|
15
20
|
/** The page. Rendered inside the one `<main>`, which is the skip link's target. */
|
|
16
21
|
children: JSX.Element;
|
|
17
22
|
header?: JSX.Element | undefined;
|
|
18
|
-
/** Rendered inside a `<nav>` landmark
|
|
23
|
+
/** Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. */
|
|
19
24
|
sidebar?: JSX.Element | undefined;
|
|
20
25
|
footer?: JSX.Element | undefined;
|
|
21
26
|
/** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
|
|
22
27
|
sidebarLabel?: string | undefined;
|
|
23
28
|
/** Skip-link text. Defaults to the translated `ui.skip`. */
|
|
24
29
|
skipLabel?: string | undefined;
|
|
25
|
-
/**
|
|
30
|
+
/** The menu button's text below `md`. Defaults to the translated `ui.menu`. */
|
|
31
|
+
menuLabel?: string | undefined;
|
|
32
|
+
/** Sidebar track width at `md` and up, and the panel's width below. Any CSS length. */
|
|
26
33
|
sidebarWidth?: string | undefined;
|
|
27
34
|
/** Keeps the header pinned while the main region scrolls. */
|
|
28
35
|
stickyHeader?: boolean | undefined;
|
|
@@ -41,13 +48,44 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
41
48
|
<a class={styles['skip']} href={ids.skipHref}>
|
|
42
49
|
{props.skipLabel ?? ui.t(UI_KEYS.skip)}
|
|
43
50
|
</a>
|
|
44
|
-
{props.header === undefined ? null : (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
{props.header === undefined && props.sidebar === undefined ? null : (
|
|
52
|
+
// The menu button sits beside the banner, not inside it: the header holds exactly what
|
|
53
|
+
// the app handed it, and the bar is what sticks.
|
|
54
|
+
<div
|
|
55
|
+
class={cx(
|
|
56
|
+
styles['top'],
|
|
57
|
+
// A bar holding only the menu button is shown only where that button is.
|
|
58
|
+
props.header === undefined && styles['menuOnly'],
|
|
59
|
+
props.stickyHeader !== false && styles['sticky'],
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{props.sidebar === undefined ? null : (
|
|
63
|
+
<button type="button" class={styles['menuButton']} popovertarget={ids.navId}>
|
|
64
|
+
<Icon glyph={iconMenu} size="md" />
|
|
65
|
+
<span class={styles['menuLabel']}>{props.menuLabel ?? ui.t(UI_KEYS.menu)}</span>
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
{props.header === undefined ? null : (
|
|
69
|
+
<header class={styles['header']}>{props.header}</header>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
48
72
|
)}
|
|
49
73
|
{props.sidebar === undefined ? null : (
|
|
50
|
-
<nav
|
|
74
|
+
<nav
|
|
75
|
+
id={ids.navId}
|
|
76
|
+
class={styles['sidebar']}
|
|
77
|
+
popover="auto"
|
|
78
|
+
aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}
|
|
79
|
+
>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class={styles['close']}
|
|
83
|
+
popovertarget={ids.navId}
|
|
84
|
+
popovertargetaction="hide"
|
|
85
|
+
aria-label={ui.t(UI_KEYS.close)}
|
|
86
|
+
>
|
|
87
|
+
<Icon glyph={iconX} size="md" />
|
|
88
|
+
</button>
|
|
51
89
|
{props.sidebar}
|
|
52
90
|
</nav>
|
|
53
91
|
)}
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
@use '../tokens' as t;
|
|
2
2
|
|
|
3
3
|
.chart {
|
|
4
|
+
display: grid;
|
|
5
|
+
gap: t.space(1);
|
|
6
|
+
inline-size: 100%;
|
|
7
|
+
// The svg does not mirror in a right-to-left page; the keyboard layer over it must not either.
|
|
8
|
+
direction: ltr;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.box {
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.plot {
|
|
4
16
|
display: block;
|
|
5
17
|
inline-size: 100%;
|
|
6
18
|
block-size: auto;
|
|
@@ -26,10 +38,15 @@
|
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
// The second series — what went wrong, stacked on what went right.
|
|
41
|
+
// The second series — what went wrong, stacked on what went right. Its dashed outline is the cue
|
|
42
|
+
// that does not depend on telling red from blue; the legend's swatch carries the same edge.
|
|
30
43
|
.secondary {
|
|
31
44
|
fill: t.role('danger');
|
|
32
45
|
opacity: 0.6;
|
|
46
|
+
stroke: t.role('fg-strong');
|
|
47
|
+
stroke-width: 1;
|
|
48
|
+
stroke-dasharray: 3 2;
|
|
49
|
+
vector-effect: non-scaling-stroke;
|
|
33
50
|
transition: opacity t.duration(fast) t.easing(out);
|
|
34
51
|
|
|
35
52
|
&:hover {
|
|
@@ -44,13 +61,34 @@
|
|
|
44
61
|
.axis {
|
|
45
62
|
@include t.data-text;
|
|
46
63
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
// HTML text, outside the viewBox: it keeps this step at every chart width (issue 494).
|
|
65
|
+
font-size: t.text(xs);
|
|
66
|
+
line-height: t.leading(tight);
|
|
67
|
+
color: t.role('fg-muted');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The busiest value, over the top grid line's end.
|
|
71
|
+
.max {
|
|
72
|
+
justify-self: end;
|
|
73
|
+
}
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
// The first and last keys, under the first and last bars.
|
|
76
|
+
.keys {
|
|
77
|
+
display: flex;
|
|
78
|
+
justify-content: space-between;
|
|
79
|
+
gap: t.space(2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Legend keys: the bar's own fill, and the second series' fill with its dashed edge.
|
|
83
|
+
.swatch {
|
|
84
|
+
display: block;
|
|
85
|
+
inline-size: t.space(3);
|
|
86
|
+
block-size: t.space(3);
|
|
87
|
+
border-radius: t.radius(sm);
|
|
88
|
+
background: t.role('accent');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.swatchSecondary {
|
|
92
|
+
border: t.stroke(hairline) dashed t.role('fg-strong');
|
|
93
|
+
background: t.role('danger', 0.6);
|
|
56
94
|
}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed
|
|
2
|
-
// on top),
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// on top): SVG bars, HTML axis labels. No charting library: the framework's own docs use a
|
|
3
|
+
// sparkline pulling one in as the cautionary example, and a route's JS budget counts raw minified
|
|
4
|
+
// bytes. `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. The
|
|
5
|
+
// labels sit outside the svg because text inside a viewBox scales with it — a speck at phone width.
|
|
6
|
+
// It stands in a `ChartFrame`: a legend for two series, and a hidden table of the same numbers.
|
|
6
7
|
|
|
7
8
|
import type { JSX } from 'solid-js';
|
|
8
9
|
import { cx } from '../cx';
|
|
10
|
+
import { useUi } from '../theme/context';
|
|
9
11
|
import styles from './BarChart.module.scss';
|
|
10
12
|
import type { ChartPoint } from './bar-chart-view';
|
|
11
13
|
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf, secondaryRects } from './bar-chart-view';
|
|
14
|
+
import type { ChartHit, ChartLegendItem } from './ChartFrame';
|
|
15
|
+
import { ChartFrame, chartHits } from './ChartFrame';
|
|
16
|
+
import type { ChartSeries } from './chart-frame-view';
|
|
17
|
+
import { chartNumberFormat, chartTable, cssPercent, hitEdge, pointLabel } from './chart-frame-view';
|
|
12
18
|
|
|
13
19
|
export interface BarChartProps {
|
|
14
20
|
/** The accessible name for the whole chart, already translated. */
|
|
@@ -18,87 +24,161 @@ export interface BarChartProps {
|
|
|
18
24
|
/** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
|
|
19
25
|
highlightLast?: boolean | undefined;
|
|
20
26
|
/**
|
|
21
|
-
* Names of the two series, already translated, read by each rect's `<title
|
|
22
|
-
* point carries `secondary`: a stacked bar with no name for its
|
|
27
|
+
* Names of the two series, already translated, read by each rect's `<title>`, the legend and
|
|
28
|
+
* the data table. Give them when a point carries `secondary`: a stacked bar with no name for its
|
|
29
|
+
* top is two numbers and no key.
|
|
23
30
|
*/
|
|
24
31
|
seriesLabels?: { readonly primary: string; readonly secondary: string } | undefined;
|
|
32
|
+
/** The key column's head in the data table, already translated — "Day". */
|
|
33
|
+
keyLabel?: string | undefined;
|
|
34
|
+
/** Formats every value. Default: `Intl.NumberFormat` in the page's locale. */
|
|
35
|
+
format?: ((value: number) => string) | undefined;
|
|
36
|
+
/** Give every bar a tab stop and a hover/focus readout. Default off: no tab stops. */
|
|
37
|
+
focusable?: boolean | undefined;
|
|
38
|
+
/** Show the caption above the plot. Default off — it names the figure either way. */
|
|
39
|
+
showCaption?: boolean | undefined;
|
|
40
|
+
/** Lands on the root `<figure>`, which holds the svg and its HTML axis labels. */
|
|
25
41
|
class?: string | undefined;
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
export function BarChart(props: BarChartProps): JSX.Element {
|
|
45
|
+
const ui = useUi();
|
|
46
|
+
const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
|
|
29
47
|
const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
|
|
30
48
|
const tops = (): ReturnType<typeof secondaryRects> => secondaryRects(props.points);
|
|
31
49
|
const named = (label: string | undefined, value: number): string =>
|
|
32
|
-
label === undefined ?
|
|
50
|
+
label === undefined ? format(value) : `${label} ${format(value)}`;
|
|
33
51
|
const lastIndex = (): number => props.points.length - 1;
|
|
34
|
-
const first = (): ChartPoint | undefined => props.points[0];
|
|
35
|
-
const last = (): ChartPoint | undefined => props.points.at(-1);
|
|
36
52
|
const highlight = (): boolean => props.highlightLast !== false;
|
|
53
|
+
const stacked = (): boolean => props.points.some((point) => (point.secondary ?? 0) > 0);
|
|
54
|
+
|
|
55
|
+
const series = (): readonly ChartSeries[] => [
|
|
56
|
+
{ label: props.seriesLabels?.primary ?? props.label, values: props.points.map((p) => p.value) },
|
|
57
|
+
...(props.seriesLabels !== undefined || stacked()
|
|
58
|
+
? [
|
|
59
|
+
{
|
|
60
|
+
label: props.seriesLabels?.secondary ?? '',
|
|
61
|
+
values: props.points.map((p) => p.secondary ?? 0),
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
: []),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const legend = (): readonly ChartLegendItem[] | undefined =>
|
|
68
|
+
props.seriesLabels === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: [
|
|
71
|
+
{ label: props.seriesLabels.primary, swatch: <span class={styles['swatch']} /> },
|
|
72
|
+
{
|
|
73
|
+
label: props.seriesLabels.secondary,
|
|
74
|
+
swatch: <span class={cx(styles['swatch'], styles['swatchSecondary'])} />,
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const hits = (): readonly ChartHit[] =>
|
|
79
|
+
props.points.flatMap((point, index) => {
|
|
80
|
+
const rect = rects()[index];
|
|
81
|
+
if (rect === undefined) return [];
|
|
82
|
+
const top = tops()[index] ?? null;
|
|
83
|
+
const x = rect.x + rect.width / 2;
|
|
84
|
+
const own = (y: number, label: string | undefined, value: number): ChartHit => ({
|
|
85
|
+
x: cssPercent(x, BAR_CHART.width),
|
|
86
|
+
y: cssPercent(y, BAR_CHART.height),
|
|
87
|
+
label: pointLabel(point.key, label, format(value)),
|
|
88
|
+
edge: hitEdge(x, BAR_CHART.width),
|
|
89
|
+
});
|
|
90
|
+
const first = own(rect.y, props.seriesLabels?.primary, point.value);
|
|
91
|
+
return top === null
|
|
92
|
+
? [first]
|
|
93
|
+
: [first, own(top.y, props.seriesLabels?.secondary, point.secondary ?? 0)];
|
|
94
|
+
});
|
|
95
|
+
|
|
37
96
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
97
|
+
<ChartFrame
|
|
98
|
+
label={props.label}
|
|
99
|
+
showCaption={props.showCaption}
|
|
100
|
+
class={props.class}
|
|
101
|
+
legend={legend()}
|
|
102
|
+
table={chartTable({
|
|
103
|
+
caption: props.label,
|
|
104
|
+
keyLabel: props.keyLabel,
|
|
105
|
+
keys: props.points.map((point) => point.key),
|
|
106
|
+
series: series(),
|
|
107
|
+
format,
|
|
108
|
+
})}
|
|
43
109
|
>
|
|
44
|
-
{
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
<div class={styles['chart']}>
|
|
111
|
+
{/* aria-hidden: the svg is the image and names itself; a bare "9" read aloud is noise. */}
|
|
112
|
+
<span class={cx(styles['axis'], styles['max'])} data-axis="max" aria-hidden="true">
|
|
113
|
+
{format(maxOf(props.points))}
|
|
114
|
+
</span>
|
|
115
|
+
<div class={styles['box']}>
|
|
116
|
+
<svg
|
|
117
|
+
role="img"
|
|
118
|
+
aria-label={props.label}
|
|
119
|
+
viewBox={`0 0 ${BAR_CHART.width} ${BAR_CHART.height}`}
|
|
120
|
+
class={styles['plot']}
|
|
121
|
+
>
|
|
122
|
+
{GRID_STEPS.map((step) => (
|
|
123
|
+
<line
|
|
124
|
+
class={styles['grid']}
|
|
125
|
+
x1={0}
|
|
126
|
+
x2={BAR_CHART.width}
|
|
127
|
+
y1={gridY(step)}
|
|
128
|
+
y2={gridY(step)}
|
|
129
|
+
/>
|
|
130
|
+
))}
|
|
131
|
+
{props.points.map((point, index) => {
|
|
132
|
+
const rect = rects()[index];
|
|
133
|
+
if (rect === undefined) return null;
|
|
134
|
+
const top = tops()[index] ?? null;
|
|
135
|
+
const last = highlight() && index === lastIndex();
|
|
136
|
+
return (
|
|
137
|
+
<g>
|
|
138
|
+
<rect
|
|
139
|
+
class={cx(styles['bar'], last && styles['last'])}
|
|
140
|
+
x={rect.x}
|
|
141
|
+
y={rect.y}
|
|
142
|
+
width={rect.width}
|
|
143
|
+
height={rect.height}
|
|
144
|
+
rx={2}
|
|
145
|
+
data-bar="true"
|
|
146
|
+
>
|
|
147
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.primary, point.value)}`}</title>
|
|
148
|
+
</rect>
|
|
149
|
+
{top === null ? null : (
|
|
150
|
+
<rect
|
|
151
|
+
class={cx(styles['secondary'], last && styles['last'])}
|
|
152
|
+
x={top.x}
|
|
153
|
+
y={top.y}
|
|
154
|
+
width={top.width}
|
|
155
|
+
height={top.height}
|
|
156
|
+
rx={2}
|
|
157
|
+
data-series="secondary"
|
|
158
|
+
>
|
|
159
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.secondary, point.secondary ?? 0)}`}</title>
|
|
160
|
+
</rect>
|
|
161
|
+
)}
|
|
162
|
+
</g>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</svg>
|
|
166
|
+
{props.focusable === true ? chartHits(hits()) : null}
|
|
167
|
+
</div>
|
|
168
|
+
{props.points.length === 0 ? null : (
|
|
169
|
+
<div class={styles['keys']} aria-hidden="true">
|
|
170
|
+
<span class={styles['axis']} data-axis="first">
|
|
171
|
+
{props.points[0]?.key}
|
|
172
|
+
</span>
|
|
173
|
+
{/* One point: its bar is both first and last, so its key is named once. */}
|
|
174
|
+
{props.points.length === 1 ? null : (
|
|
175
|
+
<span class={styles['axis']} data-axis="last">
|
|
176
|
+
{props.points.at(-1)?.key}
|
|
177
|
+
</span>
|
|
85
178
|
)}
|
|
86
|
-
</
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{maxOf(props.points)}
|
|
91
|
-
</text>
|
|
92
|
-
{first() === undefined ? null : (
|
|
93
|
-
<text class={styles['axis']} x={0} y={BAR_CHART.height - 3} text-anchor="start">
|
|
94
|
-
{first()?.key}
|
|
95
|
-
</text>
|
|
96
|
-
)}
|
|
97
|
-
{last() === undefined ? null : (
|
|
98
|
-
<text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.height - 3} text-anchor="end">
|
|
99
|
-
{last()?.key}
|
|
100
|
-
</text>
|
|
101
|
-
)}
|
|
102
|
-
</svg>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</ChartFrame>
|
|
103
183
|
);
|
|
104
184
|
}
|