@ultimat3/ui 23.0.0 → 25.0.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 +119 -15
- package/CLAUDE.md +17 -8
- package/README.md +119 -33
- package/package.json +6 -6
- package/src/a11y.ts +27 -13
- 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/CommandPalette.module.scss +8 -5
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +161 -51
- 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.module.scss +3 -1
- package/src/components/Dropzone.tsx +17 -8
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- 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/Meter.tsx +3 -3
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +14 -7
- 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.module.scss +6 -0
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +11 -6
- 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 +32 -13
- 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/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/money-view.ts +2 -7
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +32 -14
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +109 -24
- package/src/index.ts +47 -15
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +104 -96
- 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 +36 -11
- package/src/toast/toast-state.ts +0 -0
- 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/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
- package/src/theme/inline-script.ts +0 -49
|
@@ -85,7 +85,7 @@ const BYTE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'] as c
|
|
|
85
85
|
const BYTE_STEP = 1000;
|
|
86
86
|
|
|
87
87
|
/** A size a human reads, in the viewer's locale — never a hand-built "1.2 MB" string. */
|
|
88
|
-
export function
|
|
88
|
+
export function formatFileSize(bytes: number, locale: string): string {
|
|
89
89
|
const safe = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
|
|
90
90
|
let value = safe;
|
|
91
91
|
let index = 0;
|
|
@@ -106,22 +106,41 @@ export interface FileTarget {
|
|
|
106
106
|
files: FileList | null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Builds the `FileList` an input's `files` accepts — the one constructor the DOM offers for it. */
|
|
110
|
+
export type FileListOf<TFile extends FileCandidate> = (files: readonly TFile[]) => FileList;
|
|
111
|
+
|
|
112
|
+
function dataTransferList<TFile extends FileCandidate>(files: readonly TFile[]): FileList {
|
|
113
|
+
const transfer = new DataTransfer();
|
|
114
|
+
for (const one of files) transfer.items.add(one as unknown as File);
|
|
115
|
+
return transfer.files;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
|
-
*
|
|
119
|
+
* Make the real `<input>` hold exactly the files the control ACCEPTED, so the form posts what the
|
|
120
|
+
* control showed and nothing it refused.
|
|
111
121
|
*
|
|
112
|
-
*
|
|
113
|
-
* then refuses to submit
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
122
|
+
* A drop never reaches the input on its own: without this a `<Dropzone name="avatar" required>`
|
|
123
|
+
* shows the accepted file and then refuses to submit. And handing it the whole drop posted the
|
|
124
|
+
* files `accept` / `maxBytes` / `maxFiles` turned away — `accept` is a hint to the picker, never a
|
|
125
|
+
* filter on `files`. So a `drop` with something accepted is adopted as the accepted list, and a
|
|
126
|
+
* drop with nothing accepted leaves an earlier pick alone, as a refused drop should. A `pick`
|
|
127
|
+
* already set `files` itself; it is narrowed only when it refused something — to an empty list if
|
|
128
|
+
* it refused everything, because the refused file is the one the form would otherwise post. A list
|
|
129
|
+
* is BUILT (`listOf`, a `DataTransfer` in a browser) only when something was refused.
|
|
117
130
|
*/
|
|
118
|
-
export function
|
|
131
|
+
export function adoptAcceptedFiles<TFile extends FileCandidate>(
|
|
119
132
|
input: FileTarget | undefined,
|
|
120
|
-
|
|
133
|
+
offered: FileList | null | undefined,
|
|
134
|
+
selection: FileSelection<TFile>,
|
|
135
|
+
origin: 'drop' | 'pick',
|
|
136
|
+
listOf: FileListOf<TFile> = dataTransferList,
|
|
121
137
|
): void {
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
138
|
+
if (input === undefined) return;
|
|
139
|
+
if (origin === 'drop' && selection.accepted.length === 0) return;
|
|
140
|
+
if (selection.rejected.length > 0) {
|
|
141
|
+
input.files = listOf(selection.accepted);
|
|
142
|
+
} else if (origin === 'drop' && offered !== null && offered !== undefined) {
|
|
143
|
+
// Nothing refused: the drop's own list, which needs no constructor at all.
|
|
144
|
+
input.files = offered;
|
|
125
145
|
}
|
|
126
|
-
input.files = dropped;
|
|
127
146
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The dial of a Gauge, apart from its markup: a 270° ring open at the bottom, and the share of it
|
|
2
|
+
// a value fills. `Gauge` takes that share from `meterShare` — one rule for "value of max", so a
|
|
3
|
+
// Gauge and a Meter beside it can never disagree about what 7 of 4 draws.
|
|
4
|
+
|
|
5
|
+
import { annularSector } from './donut-chart-view';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 200 wide, cropped to 172 tall: the dial's open quarter is at the bottom, and the box ends just
|
|
9
|
+
* under the ring's lowest point (`gauge-view.test.ts` holds that) instead of spending the strip.
|
|
10
|
+
*/
|
|
11
|
+
export const GAUGE = {
|
|
12
|
+
width: 200,
|
|
13
|
+
height: 172,
|
|
14
|
+
centre: 100,
|
|
15
|
+
outer: 92,
|
|
16
|
+
inner: 74,
|
|
17
|
+
/** Seven-thirty: -135° from twelve o'clock. */
|
|
18
|
+
from: -Math.PI * 0.75,
|
|
19
|
+
sweep: Math.PI * 1.5,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const clampShare = (share: number): number =>
|
|
23
|
+
Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0;
|
|
24
|
+
|
|
25
|
+
/** The angle where a fill of `share` stops. */
|
|
26
|
+
export function gaugeFillEnd(share: number): number {
|
|
27
|
+
return GAUGE.from + clampShare(share) * GAUGE.sweep;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GaugeArcs {
|
|
31
|
+
readonly track: string;
|
|
32
|
+
/** `''` at a share of 0 — an empty dial is the track alone, not a zero-width sliver. */
|
|
33
|
+
readonly fill: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function gaugeArcs(share: number): GaugeArcs {
|
|
37
|
+
const { centre, outer, inner, from } = GAUGE;
|
|
38
|
+
const end = gaugeFillEnd(share);
|
|
39
|
+
return {
|
|
40
|
+
track: annularSector(centre, centre, outer, inner, from, gaugeFillEnd(1)),
|
|
41
|
+
fill: annularSector(centre, centre, outer, inner, from, end),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The heading element a composite renders, written out one intrinsic per level. `<Heading>` over
|
|
2
|
+
// `headingTag(level)` is a string to the server's JSX factory and a COMPONENT to the island
|
|
3
|
+
// build's Solid transform, which calls it and throws (#488, `intrinsic-root.test.ts`). PageHeader,
|
|
4
|
+
// Section and Accordion all render through this, so the switch exists once.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import type { HeadingTag } from './heading-level';
|
|
8
|
+
|
|
9
|
+
export interface HeadingNodeAttrs {
|
|
10
|
+
readonly id?: string | undefined;
|
|
11
|
+
readonly class?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `tag` comes from `headingTag(level)`, resolved once by the caller so an off-scale level still
|
|
16
|
+
* throws `X_UI_INVALID_VALUE` there. `content` is an accessor: read inside the element, it stays
|
|
17
|
+
* reactive in an island.
|
|
18
|
+
*/
|
|
19
|
+
export function headingNode(
|
|
20
|
+
tag: HeadingTag,
|
|
21
|
+
attrs: HeadingNodeAttrs,
|
|
22
|
+
content: () => JSX.Element,
|
|
23
|
+
): JSX.Element {
|
|
24
|
+
switch (tag) {
|
|
25
|
+
case 'h1':
|
|
26
|
+
return (
|
|
27
|
+
<h1 id={attrs.id} class={attrs.class}>
|
|
28
|
+
{content()}
|
|
29
|
+
</h1>
|
|
30
|
+
);
|
|
31
|
+
case 'h2':
|
|
32
|
+
return (
|
|
33
|
+
<h2 id={attrs.id} class={attrs.class}>
|
|
34
|
+
{content()}
|
|
35
|
+
</h2>
|
|
36
|
+
);
|
|
37
|
+
case 'h3':
|
|
38
|
+
return (
|
|
39
|
+
<h3 id={attrs.id} class={attrs.class}>
|
|
40
|
+
{content()}
|
|
41
|
+
</h3>
|
|
42
|
+
);
|
|
43
|
+
case 'h4':
|
|
44
|
+
return (
|
|
45
|
+
<h4 id={attrs.id} class={attrs.class}>
|
|
46
|
+
{content()}
|
|
47
|
+
</h4>
|
|
48
|
+
);
|
|
49
|
+
case 'h5':
|
|
50
|
+
return (
|
|
51
|
+
<h5 id={attrs.id} class={attrs.class}>
|
|
52
|
+
{content()}
|
|
53
|
+
</h5>
|
|
54
|
+
);
|
|
55
|
+
case 'h6':
|
|
56
|
+
return (
|
|
57
|
+
<h6 id={attrs.id} class={attrs.class}>
|
|
58
|
+
{content()}
|
|
59
|
+
</h6>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -41,7 +41,7 @@ interface Candidate {
|
|
|
41
41
|
* byte-stable whatever order the caller built the list in. `undefined` for an
|
|
42
42
|
* empty list — the component omits the attribute rather than emitting an empty one.
|
|
43
43
|
*/
|
|
44
|
-
export function
|
|
44
|
+
export function variantSrcset(variants: readonly ImageVariant[] | undefined): string | undefined {
|
|
45
45
|
if (variants === undefined || variants.length === 0) return undefined;
|
|
46
46
|
|
|
47
47
|
const candidates = variants.map(toCandidate);
|
|
@@ -239,9 +239,9 @@ export interface ImageSourceSet {
|
|
|
239
239
|
export function sourceSetsFor(sources: ImageSources | undefined): readonly ImageSourceSet[] {
|
|
240
240
|
if (sources === undefined) return [];
|
|
241
241
|
const sets: ImageSourceSet[] = [];
|
|
242
|
-
const avif =
|
|
242
|
+
const avif = variantSrcset(sources.avif);
|
|
243
243
|
if (avif !== undefined) sets.push({ type: 'image/avif', srcset: avif });
|
|
244
|
-
const webp =
|
|
244
|
+
const webp = variantSrcset(sources.webp);
|
|
245
245
|
if (webp !== undefined) sets.push({ type: 'image/webp', srcset: webp });
|
|
246
246
|
return sets;
|
|
247
247
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// The geometry of a LineChart / AreaChart, apart from its markup: the nice value scale over every
|
|
2
|
+
// series, each point's position, the line through each unbroken run and the area under it. Pure,
|
|
3
|
+
// so a server render and an island draw one chart; `null` is a gap, never a dive to zero.
|
|
4
|
+
|
|
5
|
+
import type { ChartSeries } from './chart-frame-view';
|
|
6
|
+
import { finiteOrNull } from './chart-frame-view';
|
|
7
|
+
import type { NiceScale } from './chart-ticks-view';
|
|
8
|
+
import { niceTicks } from './chart-ticks-view';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 600 × 200, 3:1 — a dashboard row, not a hero. `pad` keeps a marker on the outermost point (and
|
|
12
|
+
* a stroke on the top grid line) inside the box. Labels are HTML outside the svg, like BarChart's
|
|
13
|
+
* (issue 494): inside a viewBox they would scale with it.
|
|
14
|
+
*/
|
|
15
|
+
export const LINE_CHART = { width: 600, height: 200, pad: 8 } as const;
|
|
16
|
+
|
|
17
|
+
/** Past this many keys a marker per point is a second, lumpier line; only the newest is marked. */
|
|
18
|
+
export const MARKERS_ON_EVERY_POINT_UP_TO = 24;
|
|
19
|
+
|
|
20
|
+
export interface LinePoint {
|
|
21
|
+
/** The key's index — a gap skips an index, so this is not the point's position in the array. */
|
|
22
|
+
readonly index: number;
|
|
23
|
+
readonly x: number;
|
|
24
|
+
readonly y: number;
|
|
25
|
+
readonly value: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LineSeriesGeometry {
|
|
29
|
+
readonly points: readonly LinePoint[];
|
|
30
|
+
/** `M … L …`, one `M` per unbroken run; `''` for a series with no value. */
|
|
31
|
+
readonly line: string;
|
|
32
|
+
/** Each run of two or more points closed down to the baseline; `''` when there is none. */
|
|
33
|
+
readonly area: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LineTick {
|
|
37
|
+
readonly value: number;
|
|
38
|
+
readonly y: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LineLayout {
|
|
42
|
+
readonly scale: NiceScale;
|
|
43
|
+
readonly ticks: readonly LineTick[];
|
|
44
|
+
/** The y of zero — or of the axis edge nearest it, for a range that does not cross zero. */
|
|
45
|
+
readonly baselineY: number;
|
|
46
|
+
/** The x of every key, gap or not — where its axis label sits. */
|
|
47
|
+
readonly keyX: readonly number[];
|
|
48
|
+
readonly series: readonly LineSeriesGeometry[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LineLayoutInput {
|
|
52
|
+
readonly keys: readonly string[];
|
|
53
|
+
readonly series: readonly ChartSeries[];
|
|
54
|
+
/** Start the axis at zero (the honest default for counts) or fit it to the data. */
|
|
55
|
+
readonly zero: boolean;
|
|
56
|
+
readonly maxTicks: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const round = (value: number): number => Math.round(value * 10) / 10;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every drawn value is a whole number — a count. Detected rather than asked for: a prop is one
|
|
63
|
+
* more thing a caller forgets, and the data already says it. One fraction anywhere turns it off.
|
|
64
|
+
*/
|
|
65
|
+
function allIntegers(input: LineLayoutInput): boolean {
|
|
66
|
+
return input.series.every((series) =>
|
|
67
|
+
input.keys.every((_, i) => {
|
|
68
|
+
const value = finiteOrNull(series.values[i]);
|
|
69
|
+
return value === null || Number.isInteger(value);
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function extent(input: LineLayoutInput): readonly [number, number] {
|
|
75
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
76
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
77
|
+
for (const series of input.series) {
|
|
78
|
+
for (let i = 0; i < input.keys.length; i += 1) {
|
|
79
|
+
const value = finiteOrNull(series.values[i]);
|
|
80
|
+
if (value === null) continue;
|
|
81
|
+
lo = Math.min(lo, value);
|
|
82
|
+
hi = Math.max(hi, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (lo > hi) return [0, 1];
|
|
86
|
+
return input.zero ? [Math.min(0, lo), Math.max(0, hi)] : [lo, hi];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runsOf(points: readonly LinePoint[]): LinePoint[][] {
|
|
90
|
+
const runs: LinePoint[][] = [];
|
|
91
|
+
let previous = -2;
|
|
92
|
+
for (const point of points) {
|
|
93
|
+
if (point.index !== previous + 1 || runs.length === 0) runs.push([]);
|
|
94
|
+
runs.at(-1)?.push(point);
|
|
95
|
+
previous = point.index;
|
|
96
|
+
}
|
|
97
|
+
return runs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const at = (point: LinePoint): string => `${point.x},${point.y}`;
|
|
101
|
+
|
|
102
|
+
export function lineLayout(input: LineLayoutInput): LineLayout {
|
|
103
|
+
const [lo, hi] = extent(input);
|
|
104
|
+
const scale = niceTicks(lo, hi, input.maxTicks, { integer: allIntegers(input) });
|
|
105
|
+
const { width, height, pad } = LINE_CHART;
|
|
106
|
+
// Halved on both sides, which leaves the ratio exact (a power-of-two scale commutes with
|
|
107
|
+
// rounding): `max - min` over the full float64 range overflows to Infinity, and the bottom
|
|
108
|
+
// tick's `Infinity / Infinity` drew `y="NaN"`.
|
|
109
|
+
const half = scale.max / 2 - scale.min / 2;
|
|
110
|
+
const yOf = (value: number): number =>
|
|
111
|
+
round(pad + ((scale.max / 2 - value / 2) / half) * (height - pad * 2));
|
|
112
|
+
const count = input.keys.length;
|
|
113
|
+
const xOf = (index: number): number =>
|
|
114
|
+
count <= 1 ? width / 2 : round(pad + (index * (width - pad * 2)) / (count - 1));
|
|
115
|
+
const baselineY = yOf(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
116
|
+
|
|
117
|
+
const series = input.series.map((one): LineSeriesGeometry => {
|
|
118
|
+
const points: LinePoint[] = [];
|
|
119
|
+
for (let index = 0; index < count; index += 1) {
|
|
120
|
+
const value = finiteOrNull(one.values[index]);
|
|
121
|
+
if (value !== null) points.push({ index, x: xOf(index), y: yOf(value), value });
|
|
122
|
+
}
|
|
123
|
+
const runs = runsOf(points);
|
|
124
|
+
const line = runs
|
|
125
|
+
.map((run) => run.map((point, i) => `${i === 0 ? 'M' : 'L'}${at(point)}`).join(' '))
|
|
126
|
+
.join(' ');
|
|
127
|
+
const area = runs
|
|
128
|
+
.filter((run) => run.length > 1)
|
|
129
|
+
.map((run) => {
|
|
130
|
+
const first = run[0] as LinePoint;
|
|
131
|
+
const last = run.at(-1) as LinePoint;
|
|
132
|
+
const top = run.map((point) => `L${at(point)}`).join(' ');
|
|
133
|
+
return `M${first.x},${baselineY} ${top} L${last.x},${baselineY} Z`;
|
|
134
|
+
})
|
|
135
|
+
.join(' ');
|
|
136
|
+
return { points, line, area };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
scale,
|
|
141
|
+
ticks: scale.ticks.map((value) => ({ value, y: yOf(value) })),
|
|
142
|
+
baselineY,
|
|
143
|
+
keyX: input.keys.map((_, index) => xOf(index)),
|
|
144
|
+
series,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The points that carry a marker: all of them on a short series, only the newest on a dense one. */
|
|
149
|
+
export function markedPoints(points: readonly LinePoint[], keyCount: number): readonly LinePoint[] {
|
|
150
|
+
if (keyCount <= MARKERS_ON_EVERY_POINT_UP_TO) return points;
|
|
151
|
+
const last = points.at(-1);
|
|
152
|
+
return last === undefined ? [] : [last];
|
|
153
|
+
}
|
|
@@ -19,7 +19,27 @@ export interface LinkTarget {
|
|
|
19
19
|
export function linkTarget(href: string, declaredExternal?: boolean | undefined): LinkTarget {
|
|
20
20
|
const safe = safeUrl(href, 'href');
|
|
21
21
|
if (safe === null) return { href: undefined, external: false };
|
|
22
|
-
return { href: safe, external: declaredExternal === true ||
|
|
22
|
+
return { href: safe, external: declaredExternal === true || namesAnotherOrigin(safe) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** An origin no real link can name: whatever resolves AWAY from it carried its own authority. */
|
|
26
|
+
const SENTINEL_ORIGIN = 'http://link-target.invalid';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decided the way a browser resolves the href, not by its spelling: `//cdn.test`, `HTTPS://…` and
|
|
30
|
+
* a leading space all leave the page's origin, and a regex over the raw string read each of them
|
|
31
|
+
* as internal — no `noopener`, no external hint. A value the parser refuses is treated as external,
|
|
32
|
+
* because the hardening is the safe side of a wrong guess.
|
|
33
|
+
*/
|
|
34
|
+
function namesAnotherOrigin(href: string): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(href, `${SENTINEL_ORIGIN}/`);
|
|
37
|
+
return (
|
|
38
|
+
(url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== SENTINEL_ORIGIN
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/**
|
|
@@ -11,3 +11,16 @@ export function meterShare(value: number, max: number): number {
|
|
|
11
11
|
export function meterWidth(share: number): string {
|
|
12
12
|
return (share * 100).toFixed(1);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `aria-valuenow` / `aria-valuemax` pair, from the SAME rule as the fill: a meter that draws
|
|
17
|
+
* full must not speak "7 of 4". With no usable maximum the bar is empty, and so is the range it
|
|
18
|
+
* speaks — 0 of 1, because `aria-valuemax` must stay above `aria-valuemin`.
|
|
19
|
+
*/
|
|
20
|
+
export function meterAria(
|
|
21
|
+
value: number,
|
|
22
|
+
max: number,
|
|
23
|
+
): { readonly now: number; readonly max: number } {
|
|
24
|
+
if (!Number.isFinite(max) || max <= 0) return { now: 0, max: 1 };
|
|
25
|
+
return { now: Number.isFinite(value) ? Math.min(max, Math.max(0, value)) : 0, max };
|
|
26
|
+
}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// Pure formatting core behind <Money>. Split out so the rule "format through the
|
|
2
2
|
// injected locale, never an ambient default" is testable without a renderer.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
type FormatMoneyOptions,
|
|
6
|
-
formatMoney,
|
|
7
|
-
type Money,
|
|
8
|
-
money as makeMoney,
|
|
9
|
-
} from '@ultimat3/money';
|
|
4
|
+
import { type FormatMoneyOptions, formatMoney, fromMinor, type Money } from '@ultimat3/money';
|
|
10
5
|
import { invalidValueError } from '../errors';
|
|
11
6
|
|
|
12
7
|
/** A bare number is minor units in the context currency. */
|
|
@@ -36,7 +31,7 @@ export function toMoney(value: MoneyInput, currency: string): Money {
|
|
|
36
31
|
throw invalidValueError('Money', value, 'an integer number of minor units');
|
|
37
32
|
}
|
|
38
33
|
// Delegated so the currency code is validated by the one authority on it.
|
|
39
|
-
return
|
|
34
|
+
return fromMinor(value, currency);
|
|
40
35
|
}
|
|
41
36
|
if (!Number.isInteger(value.minor)) {
|
|
42
37
|
throw invalidValueError('Money', value, 'an integer number of minor units');
|
|
@@ -75,28 +75,15 @@ function drawAlignmentPattern(matrix: Matrix, centerRow: number, centerCol: numb
|
|
|
75
75
|
function drawFunctionPatterns(matrix: Matrix, version: number): void {
|
|
76
76
|
const size = matrix.size;
|
|
77
77
|
|
|
78
|
-
// Three finder patterns
|
|
79
|
-
//
|
|
78
|
+
// Three finder patterns. `drawFinderPattern`'s outermost ring (`dist === 4`) IS the light
|
|
79
|
+
// separator, clipped to the matrix — drawing a second ring one further out would claim row and
|
|
80
|
+
// column 8, the format strip and two timing modules.
|
|
80
81
|
const corners: readonly (readonly [number, number])[] = [
|
|
81
82
|
[3, 3],
|
|
82
83
|
[3, size - 4],
|
|
83
84
|
[size - 4, 3],
|
|
84
85
|
];
|
|
85
86
|
for (const [row, col] of corners) drawFinderPattern(matrix, row, col);
|
|
86
|
-
// Separators: the light ring one module beyond each finder pattern, clipped to the matrix.
|
|
87
|
-
for (const [row, col] of corners) {
|
|
88
|
-
for (let d = -4; d <= 4; d++) {
|
|
89
|
-
for (const [r, c] of [
|
|
90
|
-
[row + d, col - 5],
|
|
91
|
-
[row + d, col + 5],
|
|
92
|
-
[row - 5, col + d],
|
|
93
|
-
[row + 5, col + d],
|
|
94
|
-
] as const) {
|
|
95
|
-
if (r >= 0 && c >= 0 && r < size && c < size && !matrix.isSet(r, c))
|
|
96
|
-
matrix.set(r, c, false);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
87
|
|
|
101
88
|
// Timing patterns: row 6 and column 6, alternating dark/light, skipping cells the finder
|
|
102
89
|
// patterns already claimed.
|
|
@@ -107,11 +94,14 @@ function drawFunctionPatterns(matrix: Matrix, version: number): void {
|
|
|
107
94
|
}
|
|
108
95
|
|
|
109
96
|
// Alignment pattern(s): every (row, col) pair from this version's coordinate list, except the
|
|
110
|
-
//
|
|
97
|
+
// three that would land on a finder pattern — (first, first), (first, last) and (last, first).
|
|
111
98
|
const positions = ALIGNMENT_POSITIONS[version - 1] ?? [];
|
|
99
|
+
const first = positions[0];
|
|
100
|
+
const last = positions[positions.length - 1];
|
|
112
101
|
for (const row of positions) {
|
|
113
102
|
for (const col of positions) {
|
|
114
|
-
if (row ===
|
|
103
|
+
if (row === first && (col === first || col === last)) continue;
|
|
104
|
+
if (row === last && col === first) continue;
|
|
115
105
|
drawAlignmentPattern(matrix, row, col);
|
|
116
106
|
}
|
|
117
107
|
}
|
|
@@ -158,10 +148,11 @@ function drawFormatInfo(matrix: Matrix, maskPattern: number): void {
|
|
|
158
148
|
matrix.set(8, 7, bit(8));
|
|
159
149
|
for (let i = 9; i <= 14; i++) matrix.set(8, 14 - i, bit(i));
|
|
160
150
|
|
|
161
|
-
// The
|
|
162
|
-
// column `size-
|
|
163
|
-
|
|
164
|
-
for (let i =
|
|
151
|
+
// The second copy: bits 0-7 along row 8 from column `size-1` leftwards to `size-8`, bits 8-14
|
|
152
|
+
// down column 8 from row `size-7` to `size-1` — one short of the fixed dark module at
|
|
153
|
+
// (`size-8`, 8), which belongs to no format copy.
|
|
154
|
+
for (let i = 0; i <= 7; i++) matrix.set(8, size - 1 - i, bit(i));
|
|
155
|
+
for (let i = 8; i <= 14; i++) matrix.set(size - 15 + i, 8, bit(i));
|
|
165
156
|
}
|
|
166
157
|
|
|
167
158
|
/** Whether mask `pattern` (0-7, ISO 18004 §8.8.1) flips the module at `(row, col)`. */
|
|
@@ -198,11 +189,13 @@ function drawData(matrix: Matrix, codewords: readonly number[], pattern: number)
|
|
|
198
189
|
let bitIndex = 0;
|
|
199
190
|
let upward = true;
|
|
200
191
|
for (let colPair = size - 1; colPair > 0; colPair -= 2) {
|
|
192
|
+
// The vertical timing column carries no data, so the strip that would straddle it moves one
|
|
193
|
+
// module left — which is also what makes column 0 the last strip's left half.
|
|
194
|
+
if (colPair === 6) colPair = 5;
|
|
201
195
|
for (let count = 0; count < size; count++) {
|
|
202
196
|
const row = upward ? size - 1 - count : count;
|
|
203
197
|
for (let colOffset = 0; colOffset < 2; colOffset++) {
|
|
204
198
|
const col = colPair - colOffset;
|
|
205
|
-
if (col === 6) continue; // the vertical timing column carries no data
|
|
206
199
|
if (matrix.isSet(row, col)) continue;
|
|
207
200
|
const bit = bits[bitIndex] ?? false;
|
|
208
201
|
bitIndex++;
|
|
@@ -27,16 +27,23 @@ const THRESHOLDS: readonly Threshold[] = [
|
|
|
27
27
|
{ unit: 'second', ms: 1000 },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
const unitMs = (index: number): number => (THRESHOLDS[index] as Threshold).ms;
|
|
31
|
+
const roundIn = (delta: number, index: number): number => Math.round(delta / unitMs(index));
|
|
32
|
+
|
|
30
33
|
export function relativeTimeText(options: RelativeTimeOptions): string {
|
|
31
34
|
const target = toDate(options.value).getTime();
|
|
32
35
|
const base = options.now === undefined ? Date.now() : toDate(options.now).getTime();
|
|
33
36
|
const delta = target - base;
|
|
34
37
|
const magnitude = Math.abs(delta);
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
let index = THRESHOLDS.findIndex((candidate) => magnitude >= candidate.ms);
|
|
40
|
+
if (index < 0) index = THRESHOLDS.length - 1;
|
|
41
|
+
// Promote AFTER rounding: 59.6 minutes rounds to 60, and "60 minutes ago" is "1 hour ago".
|
|
42
|
+
while (index > 0 && Math.abs(roundIn(delta, index)) * unitMs(index) >= unitMs(index - 1)) {
|
|
43
|
+
index -= 1;
|
|
44
|
+
}
|
|
45
|
+
const threshold = THRESHOLDS[index] as Threshold;
|
|
46
|
+
const value = roundIn(delta, index);
|
|
40
47
|
|
|
41
48
|
const formatter = new Intl.RelativeTimeFormat(options.locale, {
|
|
42
49
|
numeric: options.numeric ?? 'auto',
|
package/src/form/form-binding.ts
CHANGED
|
@@ -99,13 +99,19 @@ function declaredFields(fields: readonly string[]): ReadonlySet<string> {
|
|
|
99
99
|
return declared;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export function
|
|
102
|
+
export function formBinding<TValues, TResult>(
|
|
103
103
|
options: FormBindingOptions<TValues, TResult>,
|
|
104
104
|
): FormBinding<TValues, TResult> {
|
|
105
105
|
const fields = declaredFields(options.fields);
|
|
106
106
|
let state: FormState<TResult> = IDLE_FORM_STATE;
|
|
107
107
|
let touch: FormTouch = NO_FORM_TOUCH;
|
|
108
108
|
let inFlight: Promise<FormState<TResult>> | null = null;
|
|
109
|
+
/**
|
|
110
|
+
* The paths changed since the in-flight submit read the form, or `null` with none in flight.
|
|
111
|
+
* Every edit counts, a revert to `initial` included: the server now holds what was submitted,
|
|
112
|
+
* not the opening value, so the baseline cannot say whether an edit made after the read is lost.
|
|
113
|
+
*/
|
|
114
|
+
let editedInFlight: Set<string> | null = null;
|
|
109
115
|
|
|
110
116
|
/**
|
|
111
117
|
* A transition names the submit's own members; `touched`/`dirty` are added here, from the one
|
|
@@ -146,24 +152,32 @@ export function createFormBinding<TValues, TResult>(
|
|
|
146
152
|
const run = async (values: TValues): Promise<FormState<TResult>> => {
|
|
147
153
|
// Cleared, never carried: a stale message would mark a control invalid for a value the user
|
|
148
154
|
// has already changed, on the one screen where the user is watching for exactly that.
|
|
155
|
+
// Opened BEFORE the publish: `onState` is synchronous, so a subscriber can edit in reaction
|
|
156
|
+
// to `submitting` itself, and that edit already postdates what this submit read.
|
|
157
|
+
editedInFlight = new Set();
|
|
149
158
|
publish({ status: 'submitting', ...NO_FORM_ERRORS, result: undefined, issues: [] });
|
|
150
159
|
|
|
151
|
-
const schema = options.schema;
|
|
152
|
-
if (schema !== undefined) {
|
|
153
|
-
// The result's `value` is read by nothing. Deliberately: the parse below is the browser's
|
|
154
|
-
// opinion, and the only thing this file wants from it is which paths to complain about.
|
|
155
|
-
const local = issuesFromValidation(await schema['~standard'].validate(values));
|
|
156
|
-
if (local.length > 0) return failed(local);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
160
|
try {
|
|
161
|
+
// Inside the `try`: a validate that throws or rejects is a failed submit, never a `submit()`
|
|
162
|
+
// that rejects with the state left at `submitting` and the control refusing every retry.
|
|
163
|
+
const schema = options.schema;
|
|
164
|
+
if (schema !== undefined) {
|
|
165
|
+
// The result's `value` is read by nothing. Deliberately: the parse below is the browser's
|
|
166
|
+
// opinion, and the only thing this file wants from it is which paths to complain about.
|
|
167
|
+
const local = issuesFromValidation(await schema['~standard'].validate(values));
|
|
168
|
+
if (local.length > 0) return failed(local);
|
|
169
|
+
}
|
|
160
170
|
const result = await options.submit(values);
|
|
161
|
-
// The server accepted what the form held
|
|
162
|
-
//
|
|
163
|
-
|
|
171
|
+
// The server accepted what the form held WHEN IT SUBMITTED, so only what changed since is
|
|
172
|
+
// left to lose — clearing that too let a navigation guard drop an edit typed during the save.
|
|
173
|
+
// `touched` stays: the user has still visited those fields, and a hint that vanishes on save
|
|
174
|
+
// is a flicker.
|
|
175
|
+
touch = { touched: touch.touched, dirty: editedInFlight ?? NO_FORM_TOUCH.dirty };
|
|
164
176
|
return publish({ status: 'succeeded', ...NO_FORM_ERRORS, result, issues: [] });
|
|
165
177
|
} catch (rejection) {
|
|
166
178
|
return failed(issuesFromRejection(rejection));
|
|
179
|
+
} finally {
|
|
180
|
+
editedInFlight = null;
|
|
167
181
|
}
|
|
168
182
|
};
|
|
169
183
|
|
|
@@ -183,10 +197,14 @@ export function createFormBinding<TValues, TResult>(
|
|
|
183
197
|
pending: () => state.status === 'submitting',
|
|
184
198
|
firstInvalidField: () => firstInvalidField(state, fields),
|
|
185
199
|
touch: (path) => publishTouch(markTouched(touch, path)),
|
|
186
|
-
edit: (path, value) =>
|
|
187
|
-
|
|
200
|
+
edit: (path, value) => {
|
|
201
|
+
editedInFlight?.add(path);
|
|
202
|
+
publishTouch(markDirty(touch, path, !sameFieldValue(value, baseline(path))));
|
|
203
|
+
},
|
|
188
204
|
reset: () => {
|
|
189
205
|
touch = NO_FORM_TOUCH;
|
|
206
|
+
// A reset mid-flight discards what was typed before it, the in-flight window's share too.
|
|
207
|
+
if (editedInFlight !== null) editedInFlight = new Set();
|
|
190
208
|
publish(IDLE_FORM_STATE);
|
|
191
209
|
},
|
|
192
210
|
};
|
package/src/form/use-form.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// The reactive shell over `
|
|
1
|
+
// The reactive shell over `formBinding`, and the whole of what it adds: the state lands in a
|
|
2
2
|
// signal, so a `<Field error={form.errorFor('title')}>` re-renders when a submit answers.
|
|
3
3
|
//
|
|
4
4
|
// Reactivity goes through `solid()` like every other read in this package — a component that
|
|
5
5
|
// imported solid-js directly would make @ultimat3/ui depend on a runtime it deliberately does not.
|
|
6
6
|
|
|
7
7
|
import { solid } from '../theme/solid-adapter';
|
|
8
|
-
import {
|
|
8
|
+
import { type FormBinding, type FormBindingOptions, formBinding } from './form-binding';
|
|
9
9
|
import {
|
|
10
10
|
errorOf,
|
|
11
11
|
type FormState,
|
|
@@ -24,7 +24,7 @@ export function useForm<TValues, TResult>(
|
|
|
24
24
|
): FormBinding<TValues, TResult> {
|
|
25
25
|
const runtime = solid();
|
|
26
26
|
const [state, setState] = runtime.createSignal<FormState<TResult>>(IDLE_FORM_STATE);
|
|
27
|
-
const binding =
|
|
27
|
+
const binding = formBinding<TValues, TResult>({
|
|
28
28
|
...options,
|
|
29
29
|
onState: (next) => {
|
|
30
30
|
options.onState?.(next);
|
package/src/global.scss
CHANGED
|
@@ -5,3 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
@use 'tokens/theme';
|
|
7
7
|
@use 'tokens/reset';
|
|
8
|
+
|
|
9
|
+
// A held island's reveal that needs no script (`@ultimat3/render`'s `ISLAND_HOLD_REVEAL`, issue 506):
|
|
10
|
+
// its wrapper animates to this after the hold's cap, so JS off or a blocked runtime still shows
|
|
11
|
+
// the server's markup. The name is held equal to render's by `island-hold-replay.test.ts`.
|
|
12
|
+
@keyframes ultimate-hold-reveal {
|
|
13
|
+
to {
|
|
14
|
+
visibility: visible;
|
|
15
|
+
}
|
|
16
|
+
}
|