@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
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a
|
|
6
6
|
// table and a card list cannot disagree about what "loading with stale rows" looks like. Only the
|
|
7
7
|
// PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
|
|
8
|
+
//
|
|
9
|
+
// A narrow table is the SAME table restyled as labelled cards (`DataTable.module.scss`), never a
|
|
10
|
+
// second copy: a duplicate is read twice or, hidden, is links the reader is told are not there.
|
|
8
11
|
|
|
9
12
|
import { type AsyncState, finiteCount } from '@ultimat3/core';
|
|
10
13
|
import type { JSX } from 'solid-js';
|
|
@@ -14,6 +17,14 @@ import { UI_KEYS } from '../i18n-keys';
|
|
|
14
17
|
import { useUi } from '../theme/context';
|
|
15
18
|
import { type AsyncBranch, asyncBranch, isBusyBranch } from './async-branch';
|
|
16
19
|
import styles from './DataTable.module.scss';
|
|
20
|
+
import {
|
|
21
|
+
type ColumnPriority,
|
|
22
|
+
columnPriority,
|
|
23
|
+
type MoreColumn,
|
|
24
|
+
moreColumn,
|
|
25
|
+
priorityAttr,
|
|
26
|
+
TABLE_ROLES,
|
|
27
|
+
} from './data-table-view';
|
|
17
28
|
import { EmptyState } from './EmptyState';
|
|
18
29
|
import { ErrorState } from './ErrorState';
|
|
19
30
|
import { Pagination } from './Pagination';
|
|
@@ -31,8 +42,18 @@ export interface Column<Row> {
|
|
|
31
42
|
/** Right-aligns in LTR and left-aligns in RTL via `text-align: end`. */
|
|
32
43
|
numeric?: boolean | undefined;
|
|
33
44
|
width?: string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* 1 (default) is a cell at every width; 2 hides the cell while the TABLE is narrower than `md`,
|
|
47
|
+
* 3 while it is narrower than `lg`. A hidden value moves into the row's "more" disclosure, so it
|
|
48
|
+
* is never lost — which renders `cell` a second time: give a priority to columns that SHOW a
|
|
49
|
+
* value, never to one holding a form control or a fixed `id`.
|
|
50
|
+
*/
|
|
51
|
+
priority?: ColumnPriority | undefined;
|
|
34
52
|
}
|
|
35
53
|
|
|
54
|
+
/** Below `sm`, `cards` restacks each row as a labelled card; `scroll` keeps the scrolling table. */
|
|
55
|
+
export type DataTableNarrow = 'cards' | 'scroll';
|
|
56
|
+
|
|
36
57
|
interface DataTableBaseProps<Row> {
|
|
37
58
|
caption: string;
|
|
38
59
|
columns: readonly Column<Row>[];
|
|
@@ -49,6 +70,10 @@ interface DataTableBaseProps<Row> {
|
|
|
49
70
|
nextCursor?: string | undefined;
|
|
50
71
|
prevCursor?: string | undefined;
|
|
51
72
|
stickyHeader?: boolean | undefined;
|
|
73
|
+
/** Pins the first column while the table scrolls sideways. */
|
|
74
|
+
stickyFirstColumn?: boolean | undefined;
|
|
75
|
+
/** What a table narrower than `sm` becomes. Default `cards`. */
|
|
76
|
+
narrow?: DataTableNarrow | undefined;
|
|
52
77
|
density?: 'comfortable' | 'compact' | undefined;
|
|
53
78
|
/** Placeholder row count while loading. Match the usual page size. */
|
|
54
79
|
skeletonRows?: number | undefined;
|
|
@@ -108,6 +133,43 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
108
133
|
|
|
109
134
|
const branch = (): AsyncBranch<readonly Row[]> => asyncBranch(state());
|
|
110
135
|
|
|
136
|
+
const cards = (): boolean => (props.narrow ?? 'cards') === 'cards';
|
|
137
|
+
/** Read through the validator on every render, so a bad priority is refused, never shown. */
|
|
138
|
+
const priorityOf = (column: Column<Row>): string | undefined =>
|
|
139
|
+
priorityAttr(columnPriority(column.priority));
|
|
140
|
+
|
|
141
|
+
/** The card label rides inside the cell and is `aria-hidden`: the header already names it. */
|
|
142
|
+
const cellContent = (column: Column<Row>, row: Row): JSX.Element =>
|
|
143
|
+
cards()
|
|
144
|
+
? [
|
|
145
|
+
// No class: the attribute is the stylesheet's hook, and this span is on every cell.
|
|
146
|
+
<span data-label="" aria-hidden="true">
|
|
147
|
+
{column.header}
|
|
148
|
+
</span>,
|
|
149
|
+
column.cell(row),
|
|
150
|
+
]
|
|
151
|
+
: column.cell(row);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The hidden columns' values, each entry shown only over the widths its own cell is hidden —
|
|
155
|
+
* a `<details>`, so it opens with scripting off.
|
|
156
|
+
*/
|
|
157
|
+
const disclosure = (more: MoreColumn<Column<Row>>, row: Row): JSX.Element => (
|
|
158
|
+
<td role={TABLE_ROLES.cell} class={styles['more']} data-more={more.until}>
|
|
159
|
+
<details class={styles['disclosure']}>
|
|
160
|
+
<summary class={styles['summary']}>{ui.t(UI_KEYS.more)}</summary>
|
|
161
|
+
<dl class={styles['entries']}>
|
|
162
|
+
{more.columns.map((column) => (
|
|
163
|
+
<div class={styles['entry']} data-priority={priorityOf(column)}>
|
|
164
|
+
<dt>{column.header}</dt>
|
|
165
|
+
<dd>{column.cell(row)}</dd>
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</dl>
|
|
169
|
+
</details>
|
|
170
|
+
</td>
|
|
171
|
+
);
|
|
172
|
+
|
|
111
173
|
const body = (): JSX.Element => {
|
|
112
174
|
if (branch().kind === 'pending') {
|
|
113
175
|
// `Array.from({ length: NaN })` is `[]`, so a busy tbody with no placeholders in it is the
|
|
@@ -116,44 +178,37 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
116
178
|
// neither, because `NaN` is not nullish. 0 stays legal: a loading table with no placeholders
|
|
117
179
|
// is a choice, and it is a visible one.
|
|
118
180
|
const placeholders = finiteCount('DataTable', 'skeletonRows', props.skeletonRows ?? 5, 0);
|
|
181
|
+
const more = moreColumn(props.columns);
|
|
119
182
|
return Array.from({ length: placeholders }, () => (
|
|
120
|
-
<tr>
|
|
121
|
-
{props.columns.map(() => (
|
|
122
|
-
<td>
|
|
183
|
+
<tr role={TABLE_ROLES.row} class={styles['row']}>
|
|
184
|
+
{props.columns.map((column) => (
|
|
185
|
+
<td role={TABLE_ROLES.cell} class={styles['cell']} data-priority={priorityOf(column)}>
|
|
123
186
|
<Skeleton height="1.1em" />
|
|
124
187
|
</td>
|
|
125
188
|
))}
|
|
189
|
+
{more === undefined ? null : (
|
|
190
|
+
<td role={TABLE_ROLES.cell} class={styles['more']} data-more={more.until} />
|
|
191
|
+
)}
|
|
126
192
|
</tr>
|
|
127
193
|
));
|
|
128
194
|
}
|
|
195
|
+
const more = moreColumn(props.columns);
|
|
129
196
|
return props.rows.map((row) => (
|
|
130
|
-
<tr data-row={props.rowKey(row)}>
|
|
197
|
+
<tr role={TABLE_ROLES.row} class={styles['row']} data-row={props.rowKey(row)}>
|
|
131
198
|
{props.columns.map((column) => (
|
|
132
|
-
<td
|
|
133
|
-
{
|
|
199
|
+
<td
|
|
200
|
+
role={TABLE_ROLES.cell}
|
|
201
|
+
class={cx(styles['cell'], column.numeric === true && styles['numeric'])}
|
|
202
|
+
data-priority={priorityOf(column)}
|
|
203
|
+
>
|
|
204
|
+
{cellContent(column, row)}
|
|
134
205
|
</td>
|
|
135
206
|
))}
|
|
207
|
+
{more === undefined ? null : disclosure(more, row)}
|
|
136
208
|
</tr>
|
|
137
209
|
));
|
|
138
210
|
};
|
|
139
211
|
|
|
140
|
-
const decided = branch();
|
|
141
|
-
if (decided.kind === 'failed') {
|
|
142
|
-
return <ErrorState error={decided.error} onRetry={props.onRetry} class={props.class} />;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two ifs:
|
|
146
|
-
// "No results" under a first page in flight is the bug that ordering used to be all that stopped.
|
|
147
|
-
if (decided.kind === 'empty') {
|
|
148
|
-
return (
|
|
149
|
-
<EmptyState
|
|
150
|
-
title={props.emptyTitle}
|
|
151
|
-
description={props.emptyDescription}
|
|
152
|
-
class={props.class}
|
|
153
|
-
/>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
212
|
/** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
|
|
158
213
|
const pager = (): JSX.Element =>
|
|
159
214
|
props.hrefFor === undefined ? (
|
|
@@ -185,6 +240,10 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
185
240
|
* linked, a button that calls back when it is not. A linked table with no `sortHrefFor` gets the
|
|
186
241
|
* header text alone — a `<button>` on a page that never hydrates is a control that does nothing.
|
|
187
242
|
*/
|
|
243
|
+
/** Whether a header renders a control at all — the card view shows only those headers. */
|
|
244
|
+
const hasSortControl = (column: Column<Row>): boolean =>
|
|
245
|
+
column.sortable === true && (props.hrefFor === undefined || props.sortHrefFor !== undefined);
|
|
246
|
+
|
|
188
247
|
const sortControl = (column: Column<Row>): JSX.Element => {
|
|
189
248
|
if (column.sortable !== true) return column.header;
|
|
190
249
|
const label = `${column.header}: ${sortLabel(column.key)}`;
|
|
@@ -216,35 +275,86 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
216
275
|
);
|
|
217
276
|
};
|
|
218
277
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
278
|
+
// An array, not a fragment: this package's `.tsx` compiles to two factories (Solid's and render's
|
|
279
|
+
// `h`), and only an array means the same sibling list to both.
|
|
280
|
+
const header = (): JSX.Element => {
|
|
281
|
+
const more = moreColumn(props.columns);
|
|
282
|
+
return (
|
|
283
|
+
<thead
|
|
284
|
+
role={TABLE_ROLES.rowgroup}
|
|
285
|
+
class={styles['head']}
|
|
286
|
+
data-sortable={props.columns.some(hasSortControl) ? '' : undefined}
|
|
225
287
|
>
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
288
|
+
<tr role={TABLE_ROLES.row}>
|
|
289
|
+
{props.columns.map((column) => (
|
|
290
|
+
<th
|
|
291
|
+
scope="col"
|
|
292
|
+
role={TABLE_ROLES.columnheader}
|
|
293
|
+
style={column.width === undefined ? undefined : { 'inline-size': column.width }}
|
|
294
|
+
aria-sort={ariaSortFor(props.sort, column.key)}
|
|
295
|
+
class={cx(styles['heading'], column.numeric === true && styles['numeric'])}
|
|
296
|
+
data-priority={priorityOf(column)}
|
|
297
|
+
data-sort-control={hasSortControl(column) ? '' : undefined}
|
|
298
|
+
>
|
|
299
|
+
{sortControl(column)}
|
|
300
|
+
</th>
|
|
301
|
+
))}
|
|
302
|
+
{more === undefined ? null : (
|
|
303
|
+
<th
|
|
304
|
+
scope="col"
|
|
305
|
+
role={TABLE_ROLES.columnheader}
|
|
306
|
+
class={styles['more']}
|
|
307
|
+
data-more={more.until}
|
|
308
|
+
>
|
|
309
|
+
{ui.t(UI_KEYS.more)}
|
|
310
|
+
</th>
|
|
311
|
+
)}
|
|
312
|
+
</tr>
|
|
313
|
+
</thead>
|
|
314
|
+
);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
|
|
318
|
+
<Table
|
|
319
|
+
caption={props.caption}
|
|
320
|
+
stickyHeader={props.stickyHeader !== false}
|
|
321
|
+
stickyFirstColumn={props.stickyFirstColumn === true}
|
|
322
|
+
explicitRoles
|
|
323
|
+
density={props.density ?? 'comfortable'}
|
|
324
|
+
>
|
|
325
|
+
{header()}
|
|
326
|
+
<tbody
|
|
327
|
+
role={TABLE_ROLES.rowgroup}
|
|
328
|
+
class={cx(styles['body'], decided.kind === 'ready' && decided.busy && styles['stale'])}
|
|
329
|
+
aria-busy={ariaBool(isBusyBranch(decided))}
|
|
330
|
+
>
|
|
331
|
+
{body()}
|
|
332
|
+
</tbody>
|
|
333
|
+
</Table>,
|
|
334
|
+
props.nextCursor === undefined && props.prevCursor === undefined ? null : pager(),
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The branch is decided INSIDE the returned JSX, as `AsyncRegion` does: a component body runs
|
|
339
|
+
* once, so a decision taken there is the decision for the island's whole life — a table that
|
|
340
|
+
* first rendered its error state kept it after the query recovered.
|
|
341
|
+
*/
|
|
342
|
+
const content = (): JSX.Element => {
|
|
343
|
+
const decided = branch();
|
|
344
|
+
if (decided.kind === 'failed') {
|
|
345
|
+
return <ErrorState error={decided.error} onRetry={props.onRetry} />;
|
|
346
|
+
}
|
|
347
|
+
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two
|
|
348
|
+
// ifs: "No results" under a first page in flight is the bug that ordering used to stop.
|
|
349
|
+
if (decided.kind === 'empty') {
|
|
350
|
+
return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
|
|
351
|
+
}
|
|
352
|
+
return table(decided);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div class={cx(styles['wrap'], props.class)} data-narrow={props.narrow ?? 'cards'}>
|
|
357
|
+
{content()}
|
|
248
358
|
</div>
|
|
249
359
|
);
|
|
250
360
|
}
|
|
@@ -25,9 +25,7 @@ export function Divider(props: DividerProps): JSX.Element {
|
|
|
25
25
|
// ARIA does not give a value and does not put in the tab order. `<hr>` is void, so the
|
|
26
26
|
// caption has to live in a container that carries the role itself.
|
|
27
27
|
// biome-ignore lint/a11y/useSemanticElements: <hr> cannot hold the caption
|
|
28
|
-
// biome-ignore lint/a11y/useFocusableInteractive: a captioned rule is not a splitter
|
|
29
28
|
<div
|
|
30
|
-
// biome-ignore lint/a11y/useAriaPropsForRole: aria-valuenow is the splitter flavour's
|
|
31
29
|
role="separator"
|
|
32
30
|
aria-orientation={orientation()}
|
|
33
31
|
class={cx(styles['labelled'], props.class)}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
// A square box, centred, never wider than a phone column needs: the centre label is HTML laid
|
|
4
|
+
// over the ring, so it keeps its type step at every size (issue 494's rule).
|
|
5
|
+
.donut {
|
|
6
|
+
position: relative;
|
|
7
|
+
display: grid;
|
|
8
|
+
place-items: center;
|
|
9
|
+
inline-size: 100%;
|
|
10
|
+
max-inline-size: t.rem(224px);
|
|
11
|
+
margin-inline: auto;
|
|
12
|
+
aspect-ratio: 1;
|
|
13
|
+
// The ring is drawn clockwise from twelve in either direction; its overlay must agree.
|
|
14
|
+
direction: ltr;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.ring {
|
|
18
|
+
grid-area: 1 / 1;
|
|
19
|
+
display: block;
|
|
20
|
+
inline-size: 100%;
|
|
21
|
+
block-size: 100%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.track {
|
|
25
|
+
fill: t.role('line', 0.35);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.segment {
|
|
29
|
+
fill: var(--series);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The marker is cut out of the segment in the surface colour, so its SHAPE names the series.
|
|
33
|
+
.marker {
|
|
34
|
+
fill: t.role('surface');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.centre {
|
|
38
|
+
grid-area: 1 / 1;
|
|
39
|
+
display: grid;
|
|
40
|
+
justify-items: center;
|
|
41
|
+
gap: t.space(1);
|
|
42
|
+
max-inline-size: 55%;
|
|
43
|
+
margin: 0;
|
|
44
|
+
text-align: center;
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.total {
|
|
49
|
+
@include t.data-text;
|
|
50
|
+
font-size: t.text(xl);
|
|
51
|
+
font-weight: t.weight(semibold);
|
|
52
|
+
line-height: t.leading(tight);
|
|
53
|
+
color: t.role('fg-strong');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.caption {
|
|
57
|
+
@include t.label-caps;
|
|
58
|
+
color: t.role('fg-muted');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.key {
|
|
62
|
+
display: block;
|
|
63
|
+
inline-size: t.space(3);
|
|
64
|
+
block-size: t.space(3);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.keyBlock {
|
|
68
|
+
fill: var(--series);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.keyMarker {
|
|
72
|
+
fill: t.role('surface');
|
|
73
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Parts of one whole — traffic by device, runs by outcome — as a ring of segments with the total
|
|
2
|
+
// (or the caller's figure) in its centre, a legend giving each part's value and share, and a hidden
|
|
3
|
+
// data table. Each segment carries its series' marker shape and a gap from its neighbours, so
|
|
4
|
+
// the ring reads in greyscale. SVG geometry from `donut-chart-view.ts`; text is HTML.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { useUi } from '../theme/context';
|
|
8
|
+
import type { ChartHit } from './ChartFrame';
|
|
9
|
+
import { ChartFrame, chartHits } from './ChartFrame';
|
|
10
|
+
import type { MarkerShape } from './chart-frame-view';
|
|
11
|
+
import {
|
|
12
|
+
chartNumberFormat,
|
|
13
|
+
chartPercentFormat,
|
|
14
|
+
chartTable,
|
|
15
|
+
cssPercent,
|
|
16
|
+
hitEdge,
|
|
17
|
+
markerPath,
|
|
18
|
+
pointLabel,
|
|
19
|
+
seriesStyle,
|
|
20
|
+
} from './chart-frame-view';
|
|
21
|
+
import styles from './DonutChart.module.scss';
|
|
22
|
+
import type { DonutArc } from './donut-chart-view';
|
|
23
|
+
import { annularSector, DONUT, donutArcs } from './donut-chart-view';
|
|
24
|
+
|
|
25
|
+
export interface DonutSegment {
|
|
26
|
+
/** The part's name, already translated. */
|
|
27
|
+
readonly label: string;
|
|
28
|
+
/** Its size; zero, negative and non-finite values are listed but not drawn. */
|
|
29
|
+
readonly value: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DonutChartProps {
|
|
33
|
+
/** The chart's name, already translated: the figure's caption and the svg's accessible name. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Clockwise from twelve o'clock, in the order the legend lists them. */
|
|
36
|
+
segments: readonly DonutSegment[];
|
|
37
|
+
/** The figure in the ring's centre, already formatted. Default: the formatted total. */
|
|
38
|
+
centreValue?: string | undefined;
|
|
39
|
+
/** A short line under the centre figure, already translated — "Total", "Sessions". */
|
|
40
|
+
centreLabel?: string | undefined;
|
|
41
|
+
/** The segment column's head in the data table, already translated — "Device". */
|
|
42
|
+
keyLabel?: string | undefined;
|
|
43
|
+
/** The value column's head in the data table, already translated. Default: `label`. */
|
|
44
|
+
valueLabel?: string | undefined;
|
|
45
|
+
/** Formats every value. Default: `Intl.NumberFormat` in the page's locale. */
|
|
46
|
+
format?: ((value: number) => string) | undefined;
|
|
47
|
+
/** Give every drawn segment a tab stop and a hover/focus readout. Default off. */
|
|
48
|
+
focusable?: boolean | undefined;
|
|
49
|
+
/** Show the caption above the chart. Default off — it names the figure either way. */
|
|
50
|
+
showCaption?: boolean | undefined;
|
|
51
|
+
/** Lands on the root `<figure>`. */
|
|
52
|
+
class?: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const finitePositive = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);
|
|
56
|
+
|
|
57
|
+
export function DonutChart(props: DonutChartProps): JSX.Element {
|
|
58
|
+
const ui = useUi();
|
|
59
|
+
const format = (value: number): string => (props.format ?? chartNumberFormat(ui.locale))(value);
|
|
60
|
+
const percent = (share: number): string => chartPercentFormat(ui.locale)(share);
|
|
61
|
+
const arcs = (): readonly DonutArc[] => donutArcs(props.segments.map((s) => s.value));
|
|
62
|
+
const total = (): number => props.segments.reduce((sum, s) => sum + finitePositive(s.value), 0);
|
|
63
|
+
const { size, centre, outer, inner, marker } = DONUT;
|
|
64
|
+
|
|
65
|
+
const hits = (): readonly ChartHit[] =>
|
|
66
|
+
props.segments.flatMap((segment, i) => {
|
|
67
|
+
const anchor = arcs()[i]?.anchor;
|
|
68
|
+
if (anchor === null || anchor === undefined) return [];
|
|
69
|
+
const share = arcs()[i]?.share ?? 0;
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
x: cssPercent(anchor.x, size),
|
|
73
|
+
y: cssPercent(anchor.y, size),
|
|
74
|
+
label: pointLabel(
|
|
75
|
+
segment.label,
|
|
76
|
+
undefined,
|
|
77
|
+
`${format(segment.value)} (${percent(share)})`,
|
|
78
|
+
),
|
|
79
|
+
edge: hitEdge(anchor.x, size),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<ChartFrame
|
|
86
|
+
label={props.label}
|
|
87
|
+
showCaption={props.showCaption}
|
|
88
|
+
class={props.class}
|
|
89
|
+
legend={props.segments.map((segment, i) => ({
|
|
90
|
+
label: segment.label,
|
|
91
|
+
value: format(segment.value),
|
|
92
|
+
detail: percent(arcs()[i]?.share ?? 0),
|
|
93
|
+
swatch: segmentSwatch(seriesStyle(i).slot, seriesStyle(i).marker),
|
|
94
|
+
}))}
|
|
95
|
+
table={chartTable({
|
|
96
|
+
caption: props.label,
|
|
97
|
+
keyLabel: props.keyLabel,
|
|
98
|
+
keys: props.segments.map((segment) => segment.label),
|
|
99
|
+
series: [
|
|
100
|
+
{ label: props.valueLabel ?? props.label, values: props.segments.map((s) => s.value) },
|
|
101
|
+
],
|
|
102
|
+
format,
|
|
103
|
+
})}
|
|
104
|
+
>
|
|
105
|
+
<div class={styles['donut']}>
|
|
106
|
+
<svg
|
|
107
|
+
role="img"
|
|
108
|
+
aria-label={props.label}
|
|
109
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
110
|
+
class={styles['ring']}
|
|
111
|
+
>
|
|
112
|
+
{/* The track: the empty ring a chart with nothing to total still draws. */}
|
|
113
|
+
<path
|
|
114
|
+
class={styles['track']}
|
|
115
|
+
d={annularSector(centre, centre, outer, inner, 0, Math.PI * 2)}
|
|
116
|
+
/>
|
|
117
|
+
{arcs().map((arc, i) => {
|
|
118
|
+
if (arc.path === '') return null;
|
|
119
|
+
const style = seriesStyle(i);
|
|
120
|
+
return (
|
|
121
|
+
<g data-series={style.slot} data-segment={String(i)}>
|
|
122
|
+
<path class={styles['segment']} d={arc.path} />
|
|
123
|
+
{arc.marker === null ? null : (
|
|
124
|
+
<path
|
|
125
|
+
class={styles['marker']}
|
|
126
|
+
d={markerPath(style.marker, arc.marker.x, arc.marker.y, marker)}
|
|
127
|
+
data-marker={style.marker}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
</g>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</svg>
|
|
134
|
+
<p class={styles['centre']}>
|
|
135
|
+
<span class={styles['total']}>{props.centreValue ?? format(total())}</span>
|
|
136
|
+
{props.centreLabel === undefined ? null : (
|
|
137
|
+
<span class={styles['caption']}>{props.centreLabel}</span>
|
|
138
|
+
)}
|
|
139
|
+
</p>
|
|
140
|
+
{props.focusable === true ? chartHits(hits()) : null}
|
|
141
|
+
</div>
|
|
142
|
+
</ChartFrame>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A legend key for a segment: its colour slot with its marker shape cut out of it. */
|
|
147
|
+
function segmentSwatch(slot: number, shape: MarkerShape): JSX.Element {
|
|
148
|
+
return (
|
|
149
|
+
<svg class={styles['key']} viewBox="0 0 12 12" data-series={slot} aria-hidden="true">
|
|
150
|
+
<rect class={styles['keyBlock']} width={12} height={12} rx={2} />
|
|
151
|
+
<path class={styles['keyMarker']} d={markerPath(shape, 6, 6, 3.5)} />
|
|
152
|
+
</svg>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
background: t.role('bg-soft');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Dimmed directly: `@include t.disabled` appends `:disabled`, which a `<label>` carrying a data
|
|
29
|
+
// flag can never be, so the rule it compiled to matched nothing (`disabled-dim.test.ts`).
|
|
28
30
|
&[data-disabled='true'] {
|
|
29
|
-
|
|
31
|
+
opacity: 0.55;
|
|
30
32
|
background: t.role('bg-soft');
|
|
31
33
|
cursor: not-allowed;
|
|
32
34
|
}
|
|
@@ -12,7 +12,7 @@ import { useUi } from '../theme/context';
|
|
|
12
12
|
import { solid } from '../theme/solid-adapter';
|
|
13
13
|
import styles from './Dropzone.module.scss';
|
|
14
14
|
import type { FileSelection } from './file-input-view';
|
|
15
|
-
import {
|
|
15
|
+
import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
16
16
|
|
|
17
17
|
export interface DropzoneProps {
|
|
18
18
|
/** Already-translated instruction. Required — it is the control's accessible name. */
|
|
@@ -23,11 +23,13 @@ export interface DropzoneProps {
|
|
|
23
23
|
name?: string | undefined;
|
|
24
24
|
/** The `accept` attribute, and the same list a dropped file is partitioned against. */
|
|
25
25
|
accept?: string | undefined;
|
|
26
|
+
/** Without it the zone takes ONE file, dropped or picked — the rest are refused by `count`. */
|
|
26
27
|
multiple?: boolean | undefined;
|
|
27
28
|
required?: boolean | undefined;
|
|
28
29
|
disabled?: boolean | undefined;
|
|
29
30
|
/** Client-side ceiling. The server enforces its own; this only spares a doomed transfer. */
|
|
30
31
|
maxBytes?: number | undefined;
|
|
32
|
+
/** Read only with `multiple`: a single-file zone's ceiling is one. */
|
|
31
33
|
maxFiles?: number | undefined;
|
|
32
34
|
/** 0..1 while an upload runs. Absent renders no bar at all — never one stuck at zero. */
|
|
33
35
|
progress?: number | undefined;
|
|
@@ -49,8 +51,18 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
49
51
|
const inputId = (): string => props.id ?? fallbackId;
|
|
50
52
|
const percent = (): number => progressPercent(props.progress ?? 0);
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// The input first, then the callback: a drop that only reaches `onSelect` leaves the real
|
|
55
|
+
// control empty, and a refused file left in it is posted by the form anyway.
|
|
56
|
+
const offer = (files: FileList | null | undefined, origin: 'drop' | 'pick'): void => {
|
|
57
|
+
// `multiple` limits a drop too: the picker enforces the attribute itself, but a drop never
|
|
58
|
+
// goes through the picker, so a single-file zone would otherwise take every file dropped on it.
|
|
59
|
+
const selection = selectFiles([...(files ?? [])], {
|
|
60
|
+
accept: props.accept,
|
|
61
|
+
maxBytes: props.maxBytes,
|
|
62
|
+
maxFiles: props.multiple === true ? props.maxFiles : 1,
|
|
63
|
+
});
|
|
64
|
+
adoptAcceptedFiles(input, files, selection, origin);
|
|
65
|
+
props.onSelect(selection);
|
|
54
66
|
};
|
|
55
67
|
|
|
56
68
|
// `dragover` must be cancelled on every tick or the browser refuses the drop and navigates to
|
|
@@ -74,10 +86,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
74
86
|
event.preventDefault();
|
|
75
87
|
setOver(false);
|
|
76
88
|
if (props.disabled === true) return;
|
|
77
|
-
|
|
78
|
-
// control empty, and the form the label sits in submits nothing.
|
|
79
|
-
adoptDroppedFiles(input, event.dataTransfer?.files);
|
|
80
|
-
offer(event.dataTransfer?.files);
|
|
89
|
+
offer(event.dataTransfer?.files, 'drop');
|
|
81
90
|
}}
|
|
82
91
|
>
|
|
83
92
|
<span class={styles['label']}>{props.label}</span>
|
|
@@ -96,7 +105,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
96
105
|
disabled={props.disabled === true}
|
|
97
106
|
aria-describedby={props['aria-describedby']}
|
|
98
107
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
99
|
-
onChange={(event) => offer(event.currentTarget.files)}
|
|
108
|
+
onChange={(event) => offer(event.currentTarget.files, 'pick')}
|
|
100
109
|
/>
|
|
101
110
|
{props.progress === undefined ? null : (
|
|
102
111
|
<span
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// code, cause, fix. Identical text in the CLI, the overlay, and `--json` is the
|
|
3
3
|
// whole point of the error contract — this component must not paraphrase.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
describeErrorCode,
|
|
7
|
+
isUltimateError,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderFixShellArg,
|
|
10
|
+
stringField,
|
|
11
|
+
} from '@ultimat3/core';
|
|
6
12
|
import type { JSX } from 'solid-js';
|
|
7
13
|
import { cx } from '../cx';
|
|
8
14
|
import { UI_KEYS } from '../i18n-keys';
|
|
@@ -30,20 +36,31 @@ interface ErrorParts {
|
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function errorParts(error: unknown): ErrorParts {
|
|
33
|
-
|
|
39
|
+
// Every field through `stringField`, never `error.code`: a getter or a `Proxy` trap on the value
|
|
40
|
+
// throws during the READ, and this is the component that renders an error — its throw replaced
|
|
41
|
+
// the screen reporting one with a blank tree. A field that will not read falls back to the
|
|
42
|
+
// registry's answer for the code, so the code itself survives whatever else is unreadable.
|
|
43
|
+
const code = isUltimateError(error) ? stringField(error, 'code') : undefined;
|
|
44
|
+
if (code !== undefined) {
|
|
45
|
+
const known = describeErrorCode(code);
|
|
34
46
|
return {
|
|
35
|
-
code
|
|
36
|
-
title:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
code,
|
|
48
|
+
// The error's own `title` first: a remote code this realm never registered carries the
|
|
49
|
+
// server's title there (`remoteTitle`), and the registry would only humanise the code.
|
|
50
|
+
title: stringField(error, 'title') ?? known.title,
|
|
51
|
+
cause: stringField(error, 'cause') ?? renderCauseValue(error),
|
|
52
|
+
// Screened: the code came off a value the app (or a wire) built, and a fix is pasted into a
|
|
53
|
+
// shell. The placeholder is single-quoted so it is inert there, never a `<` redirection.
|
|
54
|
+
fix:
|
|
55
|
+
stringField(error, 'fix') ??
|
|
56
|
+
`x errors explain ${renderFixShellArg(code, "'<the code above>'")}`,
|
|
57
|
+
docs: stringField(error, 'docs') ?? known.docs,
|
|
40
58
|
};
|
|
41
59
|
}
|
|
42
|
-
// `props.error` is any thrown value, so `String()` ran the app's own `toString
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
const message = error instanceof Error ? error.message : renderCauseValue(error);
|
|
60
|
+
// `props.error` is any thrown value, so `String()` ran the app's own `toString`. Laundering it
|
|
61
|
+
// through a local `message` is exactly what `scripts/error-render.ts` says it cannot see, which
|
|
62
|
+
// is why this one shipped.
|
|
63
|
+
const message = thrownMessage(error) ?? renderCauseValue(error);
|
|
47
64
|
// Title and docs come from core's registry, never a hand-copy: this screen must read exactly
|
|
48
65
|
// as `x errors explain X_INTERNAL` does, and there is one docs URL for every code.
|
|
49
66
|
const described = describeErrorCode('X_INTERNAL');
|
|
@@ -59,6 +76,16 @@ export function errorParts(error: unknown): ErrorParts {
|
|
|
59
76
|
};
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
/** An `Error`'s own message, or `undefined` when it is not one or will not be read. */
|
|
80
|
+
function thrownMessage(error: unknown): string | undefined {
|
|
81
|
+
try {
|
|
82
|
+
// `instanceof` runs a `Proxy`'s `getPrototypeOf` trap, so the test is inside the guard too.
|
|
83
|
+
return error instanceof Error ? stringField(error, 'message') : undefined;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
export function ErrorState(props: ErrorStateProps): JSX.Element {
|
|
63
90
|
const ui = useUi();
|
|
64
91
|
const parts = (): ErrorParts => errorParts(props.error);
|