@ultimat3/ui 22.15.0 → 24.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 +45 -14
- package/CLAUDE.md +16 -52
- package/README.md +69 -13
- package/package.json +6 -5
- package/src/a11y.ts +24 -10
- package/src/catalog/parse-component.ts +85 -12
- package/src/catalog/render-catalog.ts +21 -1
- package/src/components/Avatar.module.scss +1 -1
- package/src/components/BarChart.module.scss +12 -1
- package/src/components/BarChart.tsx +41 -15
- package/src/components/Button.tsx +3 -8
- package/src/components/Card.module.scss +1 -1
- package/src/components/Checkbox.module.scss +4 -4
- package/src/components/Checkbox.tsx +6 -0
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +135 -71
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +6 -4
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +4 -2
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Form.module.scss +2 -2
- package/src/components/Input.module.scss +1 -1
- package/src/components/Link.tsx +64 -12
- package/src/components/Meter.tsx +3 -3
- package/src/components/Pagination.tsx +89 -37
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Radio.module.scss +2 -2
- package/src/components/Select.module.scss +3 -3
- package/src/components/Skeleton.module.scss +1 -1
- package/src/components/Spinner.module.scss +3 -3
- package/src/components/Switch.module.scss +2 -2
- package/src/components/Table.module.scss +2 -2
- package/src/components/Tabs.module.scss +11 -5
- package/src/components/Textarea.tsx +7 -4
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +35 -5
- package/src/components/button-classes.ts +25 -0
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +34 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +15 -9
- package/src/sass-probe.ts +55 -0
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/tokens/_index.scss +3 -1
- package/src/tokens/_mixins.scss +56 -8
- package/src/tokens/_motion.scss +4 -0
- package/src/tokens/_stroke.scss +20 -0
- package/src/tokens/_units.scss +47 -0
- package/src/tokens/reset.scss +4 -4
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +6 -0
- package/src/theme/inline-script.ts +0 -49
|
@@ -46,12 +46,64 @@ export function headerComment(source: string): string {
|
|
|
46
46
|
return lines.join(' ').trim();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Every prop a caller may write for `<name>`: the members of `interface <name>`, its bases' first,
|
|
51
|
+
* or — where `<name>` is `type <name> = A | B`, one interface per mode — the modes merged.
|
|
52
|
+
*/
|
|
53
|
+
export function parseProps(source: string, typeName: string): PropDoc[] {
|
|
54
|
+
const modes = unionMembers(source, typeName);
|
|
55
|
+
if (modes === undefined) return interfaceProps(source, typeName);
|
|
56
|
+
return mergeModes(modes.map((mode) => interfaceProps(source, mode)));
|
|
57
|
+
}
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
/** `GridLinkProps<Row>` names the interface `GridLinkProps`. */
|
|
60
|
+
const withoutTypeArguments = (name: string): string => name.replace(/<[^>]*>/, '').trim();
|
|
61
|
+
|
|
62
|
+
/** `A`, `B` out of `export type <name> = A | B;`. `undefined` when `<name>` is not such an alias. */
|
|
63
|
+
function unionMembers(source: string, typeName: string): string[] | undefined {
|
|
64
|
+
const alias = new RegExp(`export type ${typeName}(?:<[^>]*>)?\\s*=\\s*([\\w\\s|<>]+);`).exec(
|
|
65
|
+
source,
|
|
66
|
+
);
|
|
67
|
+
return alias?.[1]
|
|
68
|
+
?.split('|')
|
|
69
|
+
.map((member) => withoutTypeArguments(member))
|
|
70
|
+
.filter((member) => member !== '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One row per prop across modes. Required only when EVERY mode requires it — a prop one mode
|
|
75
|
+
* forbids is optional to a reader choosing between them — and typed as each mode types it.
|
|
76
|
+
*/
|
|
77
|
+
function mergeModes(modes: readonly (readonly PropDoc[])[]): PropDoc[] {
|
|
78
|
+
const merged = new Map<string, PropDoc>();
|
|
79
|
+
for (const props of modes) {
|
|
80
|
+
for (const prop of props) {
|
|
81
|
+
const seen = merged.get(prop.name);
|
|
82
|
+
if (seen === undefined) {
|
|
83
|
+
merged.set(prop.name, prop);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
merged.set(prop.name, {
|
|
87
|
+
name: prop.name,
|
|
88
|
+
type: seen.type === prop.type ? seen.type : `${seen.type} | ${prop.type}`,
|
|
89
|
+
required: seen.required && prop.required,
|
|
90
|
+
doc: seen.doc === '' ? prop.doc : seen.doc,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...merged.values()].map((prop) => ({
|
|
95
|
+
...prop,
|
|
96
|
+
required: prop.required && modes.every((props) => props.some((p) => p.name === prop.name)),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The members of `interface <name> { … }`, generics and all, after those of every base. */
|
|
101
|
+
function interfaceProps(source: string, interfaceName: string): PropDoc[] {
|
|
102
|
+
const declared = interfaceDeclaration(source, interfaceName);
|
|
103
|
+
if (declared === undefined) return [];
|
|
104
|
+
const { body, bases } = declared;
|
|
105
|
+
|
|
106
|
+
const own: PropDoc[] = [];
|
|
55
107
|
let doc: string[] = [];
|
|
56
108
|
let buffer = '';
|
|
57
109
|
let depth = 0;
|
|
@@ -68,11 +120,16 @@ export function parseProps(source: string, interfaceName: string): PropDoc[] {
|
|
|
68
120
|
// A member ends at a `;` that is not inside a function type or an inline object type.
|
|
69
121
|
if (depth > 0 || !buffer.endsWith(';')) continue;
|
|
70
122
|
const prop = toProp(buffer, doc.join(' ').trim());
|
|
71
|
-
if (prop !== undefined)
|
|
123
|
+
if (prop !== undefined) own.push(prop);
|
|
72
124
|
buffer = '';
|
|
73
125
|
doc = [];
|
|
74
126
|
}
|
|
75
|
-
|
|
127
|
+
|
|
128
|
+
const names = new Set(own.map((prop) => prop.name));
|
|
129
|
+
const inherited = bases
|
|
130
|
+
.flatMap((base) => interfaceProps(source, base))
|
|
131
|
+
.filter((prop) => !names.has(prop.name));
|
|
132
|
+
return [...inherited, ...own];
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
function isComment(line: string): boolean {
|
|
@@ -105,9 +162,13 @@ function toProp(declaration: string, doc: string): PropDoc | undefined {
|
|
|
105
162
|
const match = /^(?:readonly\s+)?('[^']+'|"[^"]+"|\w+)(\?)?:\s*([\s\S]+);$/.exec(declaration);
|
|
106
163
|
if (match === null) return undefined;
|
|
107
164
|
const [, rawName = '', optional, rawType = ''] = match;
|
|
165
|
+
const type = normaliseType(rawType);
|
|
166
|
+
// `hrefFor?: undefined` is how one mode of a union forbids another mode's prop: a member that
|
|
167
|
+
// exists so the prop CANNOT be written is not a prop.
|
|
168
|
+
if (type === '' || type === 'never') return undefined;
|
|
108
169
|
return {
|
|
109
170
|
name: rawName.replace(/^['"]|['"]$/g, ''),
|
|
110
|
-
type
|
|
171
|
+
type,
|
|
111
172
|
required: optional === undefined,
|
|
112
173
|
doc,
|
|
113
174
|
};
|
|
@@ -126,17 +187,29 @@ function normaliseType(type: string): string {
|
|
|
126
187
|
);
|
|
127
188
|
}
|
|
128
189
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Brace-matched so a nested object type does not end the interface early. `export` is optional:
|
|
192
|
+
* a base shared by two modes is a detail of the file, and its members are still the component's.
|
|
193
|
+
*/
|
|
194
|
+
function interfaceDeclaration(
|
|
195
|
+
source: string,
|
|
196
|
+
interfaceName: string,
|
|
197
|
+
): { readonly body: string; readonly bases: readonly string[] } | undefined {
|
|
198
|
+
const header = new RegExp(
|
|
199
|
+
`(?:export )?interface ${interfaceName}(?:<[^>]*>)?(?:\\s+extends\\s+([\\w\\s,<>]+?))?\\s*\\{`,
|
|
200
|
+
).exec(source);
|
|
132
201
|
if (header === null) return undefined;
|
|
202
|
+
const bases = (header[1] ?? '')
|
|
203
|
+
.split(',')
|
|
204
|
+
.map((base) => withoutTypeArguments(base))
|
|
205
|
+
.filter((base) => base !== '');
|
|
133
206
|
const start = header.index + header[0].length;
|
|
134
207
|
let depth = 1;
|
|
135
208
|
for (let i = start; i < source.length; i += 1) {
|
|
136
209
|
const char = source[i];
|
|
137
210
|
if (char === '{') depth += 1;
|
|
138
211
|
if (char === '}') depth -= 1;
|
|
139
|
-
if (depth === 0) return source.slice(start, i);
|
|
212
|
+
if (depth === 0) return { body: source.slice(start, i), bases };
|
|
140
213
|
}
|
|
141
214
|
return undefined;
|
|
142
215
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
lineHeightTokens,
|
|
14
14
|
radiusTokens,
|
|
15
15
|
spaceTokens,
|
|
16
|
+
strokeTokens,
|
|
16
17
|
zTokens,
|
|
17
18
|
} from '../tokens/tokens';
|
|
18
19
|
import type { ComponentDoc } from './parse-component';
|
|
@@ -99,6 +100,8 @@ ${scaleTable('Space — `--space-*`', spaceTokens)}
|
|
|
99
100
|
|
|
100
101
|
${scaleTable('Radius — `--radius-*`', radiusTokens)}
|
|
101
102
|
|
|
103
|
+
${scaleTable('Stroke — `--stroke-*`', strokeTokens)}
|
|
104
|
+
|
|
102
105
|
${scaleTable('Font size — `--text-*`', fontSizeTokens)}
|
|
103
106
|
|
|
104
107
|
${scaleTable('Font weight — `--weight-*`', fontWeightTokens)}
|
|
@@ -111,6 +114,23 @@ ${scaleTable('Easing — `--easing-*`', easingTokens)}
|
|
|
111
114
|
|
|
112
115
|
${scaleTable('Z-index — `--z-*`', zTokens)}
|
|
113
116
|
|
|
114
|
-
${scaleTable('Breakpoints —
|
|
117
|
+
${scaleTable('Breakpoints — `respond-to` · `respond-down` · `respond-between`', breakpointTokens)}
|
|
118
|
+
|
|
119
|
+
\`@include t.respond-to(md)\` is \`md\` and wider, \`t.respond-down(md)\` is narrower than \`md\`, and
|
|
120
|
+
\`t.respond-between(md, lg)\` is \`md\` up to \`lg\`. A max-width arm stops 0.02px under its rung, so
|
|
121
|
+
no width matches two of them.
|
|
122
|
+
|
|
123
|
+
### Computed lengths
|
|
124
|
+
|
|
125
|
+
${table(
|
|
126
|
+
['Function', 'Answers'],
|
|
127
|
+
[
|
|
128
|
+
['`t.rem(24px)`', '`1.5rem` — px, rem or a unitless px count in, rem out'],
|
|
129
|
+
[
|
|
130
|
+
'`t.fluid(1rem, 2rem, 20rem, 80rem)`',
|
|
131
|
+
'one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between',
|
|
132
|
+
],
|
|
133
|
+
],
|
|
134
|
+
)}
|
|
115
135
|
`;
|
|
116
136
|
}
|
|
@@ -26,6 +26,17 @@
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The second series — what went wrong, stacked on what went right.
|
|
30
|
+
.secondary {
|
|
31
|
+
fill: t.role('danger');
|
|
32
|
+
opacity: 0.6;
|
|
33
|
+
transition: opacity t.duration(fast) t.easing(out);
|
|
34
|
+
|
|
35
|
+
&:hover {
|
|
36
|
+
opacity: 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
.last {
|
|
30
41
|
opacity: 1;
|
|
31
42
|
}
|
|
@@ -36,7 +47,7 @@
|
|
|
36
47
|
// SVG text scales with the chart: below `sm` these shrink to unreadable specks, and an
|
|
37
48
|
// unreadable label is noise. Every bar still carries its `<title>`.
|
|
38
49
|
display: none;
|
|
39
|
-
font-size: 8px;
|
|
50
|
+
font-size: t.rem(8px);
|
|
40
51
|
fill: t.role('fg-muted');
|
|
41
52
|
|
|
42
53
|
@include t.respond-to(sm) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// A bar chart of one series — clicks per day, signups per week —
|
|
1
|
+
// A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed
|
|
2
|
+
// on top), as static SVG. No charting
|
|
2
3
|
// library: the framework's own docs use a sparkline pulling one in as the cautionary example, and
|
|
3
4
|
// a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the
|
|
4
5
|
// server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
|
|
@@ -7,7 +8,7 @@ import type { JSX } from 'solid-js';
|
|
|
7
8
|
import { cx } from '../cx';
|
|
8
9
|
import styles from './BarChart.module.scss';
|
|
9
10
|
import type { ChartPoint } from './bar-chart-view';
|
|
10
|
-
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './bar-chart-view';
|
|
11
|
+
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf, secondaryRects } from './bar-chart-view';
|
|
11
12
|
|
|
12
13
|
export interface BarChartProps {
|
|
13
14
|
/** The accessible name for the whole chart, already translated. */
|
|
@@ -16,11 +17,19 @@ export interface BarChartProps {
|
|
|
16
17
|
points: readonly ChartPoint[];
|
|
17
18
|
/** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
|
|
18
19
|
highlightLast?: boolean | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Names of the two series, already translated, read by each rect's `<title>`. Give them when a
|
|
22
|
+
* point carries `secondary`: a stacked bar with no name for its top is two numbers and no key.
|
|
23
|
+
*/
|
|
24
|
+
seriesLabels?: { readonly primary: string; readonly secondary: string } | undefined;
|
|
19
25
|
class?: string | undefined;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export function BarChart(props: BarChartProps): JSX.Element {
|
|
23
29
|
const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
|
|
30
|
+
const tops = (): ReturnType<typeof secondaryRects> => secondaryRects(props.points);
|
|
31
|
+
const named = (label: string | undefined, value: number): string =>
|
|
32
|
+
label === undefined ? String(value) : `${label} ${value}`;
|
|
24
33
|
const lastIndex = (): number => props.points.length - 1;
|
|
25
34
|
const first = (): ChartPoint | undefined => props.points[0];
|
|
26
35
|
const last = (): ChartPoint | undefined => props.points.at(-1);
|
|
@@ -44,20 +53,37 @@ export function BarChart(props: BarChartProps): JSX.Element {
|
|
|
44
53
|
{props.points.map((point, index) => {
|
|
45
54
|
const rect = rects()[index];
|
|
46
55
|
if (rect === undefined) return null;
|
|
56
|
+
const top = tops()[index] ?? null;
|
|
47
57
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{point.key}: {point.value}
|
|
59
|
-
</
|
|
60
|
-
|
|
58
|
+
<g>
|
|
59
|
+
<rect
|
|
60
|
+
class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
|
|
61
|
+
x={rect.x}
|
|
62
|
+
y={rect.y}
|
|
63
|
+
width={rect.width}
|
|
64
|
+
height={rect.height}
|
|
65
|
+
rx={2}
|
|
66
|
+
data-bar="true"
|
|
67
|
+
>
|
|
68
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.primary, point.value)}`}</title>
|
|
69
|
+
</rect>
|
|
70
|
+
{top === null ? null : (
|
|
71
|
+
<rect
|
|
72
|
+
class={cx(
|
|
73
|
+
styles['secondary'],
|
|
74
|
+
highlight() && index === lastIndex() && styles['last'],
|
|
75
|
+
)}
|
|
76
|
+
x={top.x}
|
|
77
|
+
y={top.y}
|
|
78
|
+
width={top.width}
|
|
79
|
+
height={top.height}
|
|
80
|
+
rx={2}
|
|
81
|
+
data-series="secondary"
|
|
82
|
+
>
|
|
83
|
+
<title>{`${point.key}: ${named(props.seriesLabels?.secondary, point.secondary ?? 0)}`}</title>
|
|
84
|
+
</rect>
|
|
85
|
+
)}
|
|
86
|
+
</g>
|
|
61
87
|
);
|
|
62
88
|
})}
|
|
63
89
|
<text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.top - 3} text-anchor="end">
|
|
@@ -14,6 +14,7 @@ import type { JSX } from 'solid-js';
|
|
|
14
14
|
import { ariaBool } from '../a11y';
|
|
15
15
|
import { cx } from '../cx';
|
|
16
16
|
import styles from './Button.module.scss';
|
|
17
|
+
import { buttonClassKeys } from './button-classes';
|
|
17
18
|
import { Spinner } from './Spinner';
|
|
18
19
|
import type { ButtonVariant, Size, Tone } from './variants';
|
|
19
20
|
|
|
@@ -66,14 +67,8 @@ export function Button(props: ButtonProps): JSX.Element {
|
|
|
66
67
|
<button
|
|
67
68
|
id={props.id}
|
|
68
69
|
type={props.type ?? 'button'}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
styles[`variant-${props.variant ?? 'primary'}`],
|
|
72
|
-
styles[`tone-${props.tone ?? 'accent'}`],
|
|
73
|
-
styles[`size-${props.size ?? 'md'}`],
|
|
74
|
-
props.fullWidth === true && styles['full'],
|
|
75
|
-
props.class,
|
|
76
|
-
)}
|
|
70
|
+
// The keys are `button-classes.ts`'s: `<Link appearance="button">` asks the same function.
|
|
71
|
+
class={cx(...buttonClassKeys(props).map((key) => styles[key]), props.class)}
|
|
77
72
|
// Only the caller's explicit `disabled` reaches the attribute. `loading` is a state the user
|
|
78
73
|
// is meant to read and wait out, not a control taken away from under them.
|
|
79
74
|
disabled={props.disabled === true ? true : undefined}
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
inset-block-start: 0.1rem;
|
|
35
35
|
inline-size: 0.3rem;
|
|
36
36
|
block-size: 0.55rem;
|
|
37
|
-
border-inline-end:
|
|
38
|
-
border-block-end:
|
|
37
|
+
border-inline-end: t.stroke(thick) solid t.role('accent-fg');
|
|
38
|
+
border-block-end: t.stroke(thick) solid t.role('accent-fg');
|
|
39
39
|
transform: rotate(40deg) scale(0);
|
|
40
40
|
transform-origin: center;
|
|
41
41
|
content: '';
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
.input:focus-visible + .box {
|
|
69
|
-
outline:
|
|
70
|
-
outline-offset:
|
|
69
|
+
outline: t.stroke(thick) solid t.role('accent');
|
|
70
|
+
outline-offset: t.stroke(thick);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
.input[aria-invalid='true'] + .box {
|
|
@@ -12,6 +12,11 @@ export interface CheckboxProps {
|
|
|
12
12
|
id?: string | undefined;
|
|
13
13
|
name?: string | undefined;
|
|
14
14
|
value?: string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in
|
|
17
|
+
* a table whose page already holds another form. The platform's own association, no script.
|
|
18
|
+
*/
|
|
19
|
+
form?: string | undefined;
|
|
15
20
|
checked?: boolean | undefined;
|
|
16
21
|
/** Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. */
|
|
17
22
|
indeterminate?: boolean | undefined;
|
|
@@ -33,6 +38,7 @@ export function Checkbox(props: CheckboxProps): JSX.Element {
|
|
|
33
38
|
id={props.id}
|
|
34
39
|
name={props.name}
|
|
35
40
|
value={props.value}
|
|
41
|
+
form={props.form}
|
|
36
42
|
checked={props.checked === true}
|
|
37
43
|
disabled={props.disabled === true}
|
|
38
44
|
required={props.required === true}
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
.palette {
|
|
4
4
|
position: fixed;
|
|
5
5
|
inset-block-start: 12vh;
|
|
6
|
-
inset-
|
|
6
|
+
// Centred by auto margins between two zero insets, never by a 50% inset and a -50% translate:
|
|
7
|
+
// the inset is logical and the translate physical, so under `dir="rtl"` that pair puts the
|
|
8
|
+
// palette a whole width off-centre (`rtl-sheets.test.ts`).
|
|
9
|
+
inset-inline: 0;
|
|
7
10
|
inline-size: min(32rem, calc(100vw - #{t.space(8)}));
|
|
8
11
|
max-block-size: 60vh;
|
|
9
|
-
margin: 0;
|
|
12
|
+
margin-block: 0;
|
|
13
|
+
margin-inline: auto;
|
|
10
14
|
padding: 0;
|
|
11
15
|
border: 1px solid t.role('line');
|
|
12
16
|
border-radius: t.radius(lg);
|
|
@@ -20,8 +24,7 @@
|
|
|
20
24
|
overflow: hidden;
|
|
21
25
|
z-index: t.z(dialog);
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
transform: translateX(-50%) translateY(-4px) scale(0.98);
|
|
27
|
+
transform: translateY(calc(-1 * #{t.space(1)})) scale(0.98);
|
|
25
28
|
opacity: 0;
|
|
26
29
|
visibility: hidden;
|
|
27
30
|
transition:
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
visibility t.duration(fast) t.easing(out);
|
|
31
34
|
|
|
32
35
|
&[open] {
|
|
33
|
-
transform:
|
|
36
|
+
transform: translateY(0) scale(1);
|
|
34
37
|
opacity: 1;
|
|
35
38
|
visibility: visible;
|
|
36
39
|
transition-duration: t.duration(base);
|
|
@@ -33,13 +33,12 @@ export interface Column<Row> {
|
|
|
33
33
|
width?: string | undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
interface DataTableBaseProps<Row> {
|
|
37
37
|
caption: string;
|
|
38
38
|
columns: readonly Column<Row>[];
|
|
39
39
|
rows: readonly Row[];
|
|
40
40
|
rowKey: (row: Row) => string;
|
|
41
41
|
sort?: SortState | undefined;
|
|
42
|
-
onSortChange?: (sort: SortState | undefined) => void;
|
|
43
42
|
loading?: boolean | undefined;
|
|
44
43
|
/** An UltimateError (or anything shaped like one) from the query. */
|
|
45
44
|
error?: unknown | undefined;
|
|
@@ -49,7 +48,6 @@ export interface DataTableProps<Row> {
|
|
|
49
48
|
/** Opaque cursors from the query result; absent means no further page. */
|
|
50
49
|
nextCursor?: string | undefined;
|
|
51
50
|
prevCursor?: string | undefined;
|
|
52
|
-
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
53
51
|
stickyHeader?: boolean | undefined;
|
|
54
52
|
density?: 'comfortable' | 'compact' | undefined;
|
|
55
53
|
/** Placeholder row count while loading. Match the usual page size. */
|
|
@@ -57,6 +55,31 @@ export interface DataTableProps<Row> {
|
|
|
57
55
|
class?: string | undefined;
|
|
58
56
|
}
|
|
59
57
|
|
|
58
|
+
/** The pager and the sort headers call back: the table sits in an island. */
|
|
59
|
+
export interface DataTableCallbackProps<Row> extends DataTableBaseProps<Row> {
|
|
60
|
+
/** Callback paging. Never beside `hrefFor` — the type refuses the pair. */
|
|
61
|
+
onCursor?: ((cursor: string, direction: 'next' | 'prev') => void) | undefined;
|
|
62
|
+
/** Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. */
|
|
63
|
+
onSortChange?: ((sort: SortState | undefined) => void) | undefined;
|
|
64
|
+
hrefFor?: undefined;
|
|
65
|
+
sortHrefFor?: undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The pager and the sort headers are anchors: a server-rendered table needs no script. */
|
|
69
|
+
export interface DataTableLinkProps<Row> extends DataTableBaseProps<Row> {
|
|
70
|
+
/** Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. */
|
|
71
|
+
hrefFor: (cursor: string, direction: 'next' | 'prev') => string;
|
|
72
|
+
/**
|
|
73
|
+
* Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined`
|
|
74
|
+
* is "unsorted"). Absent, a sortable header is plain text — never a button with no handler.
|
|
75
|
+
*/
|
|
76
|
+
sortHrefFor?: ((sort: SortState | undefined) => string) | undefined;
|
|
77
|
+
onCursor?: undefined;
|
|
78
|
+
onSortChange?: undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type DataTableProps<Row> = DataTableCallbackProps<Row> | DataTableLinkProps<Row>;
|
|
82
|
+
|
|
60
83
|
export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
61
84
|
const ui = useUi();
|
|
62
85
|
|
|
@@ -114,76 +137,117 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
114
137
|
));
|
|
115
138
|
};
|
|
116
139
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
/** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
|
|
141
|
+
const pager = (): JSX.Element =>
|
|
142
|
+
props.hrefFor === undefined ? (
|
|
143
|
+
<Pagination
|
|
144
|
+
nextCursor={props.nextCursor}
|
|
145
|
+
prevCursor={props.prevCursor}
|
|
146
|
+
onCursor={props.onCursor}
|
|
147
|
+
/>
|
|
148
|
+
) : (
|
|
149
|
+
<Pagination
|
|
150
|
+
nextCursor={props.nextCursor}
|
|
151
|
+
prevCursor={props.prevCursor}
|
|
152
|
+
hrefFor={props.hrefFor}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const indicator = (key: string): JSX.Element => (
|
|
157
|
+
<span aria-hidden="true" class={styles['indicator']}>
|
|
158
|
+
{ariaSortFor(props.sort, key) === 'ascending'
|
|
159
|
+
? '▲'
|
|
160
|
+
: ariaSortFor(props.sort, key) === 'descending'
|
|
161
|
+
? '▼'
|
|
162
|
+
: '↕'}
|
|
163
|
+
</span>
|
|
164
|
+
);
|
|
121
165
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
166
|
+
/**
|
|
167
|
+
* One mode per table, as the pager has: an anchor at the next state's URL when the table is
|
|
168
|
+
* linked, a button that calls back when it is not. A linked table with no `sortHrefFor` gets the
|
|
169
|
+
* header text alone — a `<button>` on a page that never hydrates is a control that does nothing.
|
|
170
|
+
*/
|
|
171
|
+
const sortControl = (column: Column<Row>): JSX.Element => {
|
|
172
|
+
if (column.sortable !== true) return column.header;
|
|
173
|
+
const label = `${column.header}: ${sortLabel(column.key)}`;
|
|
174
|
+
if (props.hrefFor !== undefined) {
|
|
175
|
+
const sortHrefFor = props.sortHrefFor;
|
|
176
|
+
if (sortHrefFor === undefined) return column.header;
|
|
177
|
+
return (
|
|
178
|
+
<a
|
|
179
|
+
class={styles['sortButton']}
|
|
180
|
+
aria-label={label}
|
|
181
|
+
href={sortHrefFor(nextSortState(props.sort, column.key))}
|
|
182
|
+
>
|
|
183
|
+
{column.header}
|
|
184
|
+
{indicator(column.key)}
|
|
185
|
+
</a>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const onSortChange = props.onSortChange;
|
|
125
189
|
return (
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
class={styles['sortButton']}
|
|
193
|
+
aria-label={label}
|
|
194
|
+
onClick={() => onSortChange?.(nextSortState(props.sort, column.key))}
|
|
195
|
+
>
|
|
196
|
+
{column.header}
|
|
197
|
+
{indicator(column.key)}
|
|
198
|
+
</button>
|
|
131
199
|
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// An array, not a fragment: this package's `.tsx` compiles to two factories (Solid's and render's
|
|
203
|
+
// `h`), and only an array means the same sibling list to both.
|
|
204
|
+
const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
|
|
205
|
+
<Table
|
|
206
|
+
caption={props.caption}
|
|
207
|
+
stickyHeader={props.stickyHeader !== false}
|
|
208
|
+
density={props.density ?? 'comfortable'}
|
|
209
|
+
>
|
|
210
|
+
<thead>
|
|
211
|
+
<tr>
|
|
212
|
+
{props.columns.map((column) => (
|
|
213
|
+
<th
|
|
214
|
+
scope="col"
|
|
215
|
+
style={column.width === undefined ? undefined : { 'inline-size': column.width }}
|
|
216
|
+
aria-sort={ariaSortFor(props.sort, column.key)}
|
|
217
|
+
class={column.numeric === true ? styles['numeric'] : undefined}
|
|
218
|
+
>
|
|
219
|
+
{sortControl(column)}
|
|
220
|
+
</th>
|
|
221
|
+
))}
|
|
222
|
+
</tr>
|
|
223
|
+
</thead>
|
|
224
|
+
<tbody
|
|
225
|
+
class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
|
|
226
|
+
aria-busy={ariaBool(isBusyBranch(decided))}
|
|
140
227
|
>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
) : (
|
|
167
|
-
column.header
|
|
168
|
-
)}
|
|
169
|
-
</th>
|
|
170
|
-
))}
|
|
171
|
-
</tr>
|
|
172
|
-
</thead>
|
|
173
|
-
<tbody
|
|
174
|
-
class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
|
|
175
|
-
aria-busy={ariaBool(isBusyBranch(decided))}
|
|
176
|
-
>
|
|
177
|
-
{body()}
|
|
178
|
-
</tbody>
|
|
179
|
-
</Table>
|
|
180
|
-
{props.nextCursor === undefined && props.prevCursor === undefined ? null : (
|
|
181
|
-
<Pagination
|
|
182
|
-
nextCursor={props.nextCursor}
|
|
183
|
-
prevCursor={props.prevCursor}
|
|
184
|
-
onCursor={props.onCursor}
|
|
185
|
-
/>
|
|
186
|
-
)}
|
|
187
|
-
</div>
|
|
188
|
-
);
|
|
228
|
+
{body()}
|
|
229
|
+
</tbody>
|
|
230
|
+
</Table>,
|
|
231
|
+
props.nextCursor === undefined && props.prevCursor === undefined ? null : pager(),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The branch is decided INSIDE the returned JSX, as `AsyncRegion` does: a component body runs
|
|
236
|
+
* once, so a decision taken there is the decision for the island's whole life — a table that
|
|
237
|
+
* first rendered its error state kept it after the query recovered.
|
|
238
|
+
*/
|
|
239
|
+
const content = (): JSX.Element => {
|
|
240
|
+
const decided = branch();
|
|
241
|
+
if (decided.kind === 'failed') {
|
|
242
|
+
return <ErrorState error={decided.error} onRetry={props.onRetry} />;
|
|
243
|
+
}
|
|
244
|
+
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two
|
|
245
|
+
// ifs: "No results" under a first page in flight is the bug that ordering used to stop.
|
|
246
|
+
if (decided.kind === 'empty') {
|
|
247
|
+
return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
|
|
248
|
+
}
|
|
249
|
+
return table(decided);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return <div class={cx(styles['wrap'], props.class)}>{content()}</div>;
|
|
189
253
|
}
|