@wtfalch/design 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/components/Brand.d.ts +10 -0
- package/dist/components/Brand.js +212 -0
- package/dist/components/Button.d.ts +63 -0
- package/dist/components/Button.js +74 -0
- package/dist/components/Callout.d.ts +37 -0
- package/dist/components/Callout.js +71 -0
- package/dist/components/Card.d.ts +42 -0
- package/dist/components/Card.js +30 -0
- package/dist/components/Checkbox.d.ts +32 -0
- package/dist/components/Checkbox.js +31 -0
- package/dist/components/DangerZone.d.ts +59 -0
- package/dist/components/DangerZone.js +50 -0
- package/dist/components/Dialog.d.ts +28 -0
- package/dist/components/Dialog.js +29 -0
- package/dist/components/Empty.d.ts +45 -0
- package/dist/components/Empty.js +35 -0
- package/dist/components/Field.d.ts +58 -0
- package/dist/components/Field.js +46 -0
- package/dist/components/Icon.d.ts +64 -0
- package/dist/components/Icon.js +235 -0
- package/dist/components/Illustration.d.ts +36 -0
- package/dist/components/Illustration.js +48 -0
- package/dist/components/Input.d.ts +14 -0
- package/dist/components/Input.js +65 -0
- package/dist/components/Markdown.d.ts +21 -0
- package/dist/components/Markdown.js +29 -0
- package/dist/components/Modal.d.ts +59 -0
- package/dist/components/Modal.js +72 -0
- package/dist/components/Pill.d.ts +40 -0
- package/dist/components/Pill.js +41 -0
- package/dist/components/Progress.d.ts +35 -0
- package/dist/components/Progress.js +27 -0
- package/dist/components/Rows.d.ts +101 -0
- package/dist/components/Rows.js +55 -0
- package/dist/components/Select.d.ts +28 -0
- package/dist/components/Select.js +56 -0
- package/dist/components/SizeGrid.d.ts +34 -0
- package/dist/components/SizeGrid.js +41 -0
- package/dist/components/Skeleton.d.ts +45 -0
- package/dist/components/Skeleton.js +47 -0
- package/dist/components/Slider.d.ts +70 -0
- package/dist/components/Slider.js +100 -0
- package/dist/components/Table.d.ts +43 -0
- package/dist/components/Table.js +13 -0
- package/dist/components/Tabs.d.ts +72 -0
- package/dist/components/Tabs.js +82 -0
- package/dist/components/Textarea.d.ts +9 -0
- package/dist/components/Textarea.js +22 -0
- package/dist/components/Toast.d.ts +43 -0
- package/dist/components/Toast.js +78 -0
- package/dist/components/Toggle.d.ts +56 -0
- package/dist/components/Toggle.js +189 -0
- package/dist/components/Tooltip.d.ts +22 -0
- package/dist/components/Tooltip.js +62 -0
- package/dist/components/Tour.d.ts +33 -0
- package/dist/components/Tour.js +108 -0
- package/dist/components/iconNames.d.ts +18 -0
- package/dist/components/iconNames.js +60 -0
- package/dist/components/tourMarker.d.ts +29 -0
- package/dist/components/tourMarker.js +58 -0
- package/dist/contrast.d.ts +18 -0
- package/dist/contrast.js +27 -0
- package/dist/hooks/useTrapFocus.d.ts +24 -0
- package/dist/hooks/useTrapFocus.js +67 -0
- package/dist/illustrations.d.ts +11 -0
- package/dist/illustrations.js +55 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +65 -0
- package/dist/styles/index.css +3124 -0
- package/dist/themes.d.ts +210 -0
- package/dist/themes.js +300 -0
- package/dist/tokens.css +251 -0
- package/package.json +74 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A word attached to a thing.
|
|
4
|
+
*
|
|
5
|
+
* Three jobs, three weights, and the weight is the whole design: a
|
|
6
|
+
* *measurement* is quiet, because there is nothing to act on and it should
|
|
7
|
+
* read as text that happens to sit in a row; a *capability* is a plain chip,
|
|
8
|
+
* because it is one of a set scanned across and none may be louder than the
|
|
9
|
+
* others; a *state* is the only one that takes colour, because it is the only
|
|
10
|
+
* one that can change.
|
|
11
|
+
*
|
|
12
|
+
* The four state tones are the ones `Callout` and `Toast` use, so one
|
|
13
|
+
* vocabulary covers the app: `warn` is a caution — the thing works, mind how —
|
|
14
|
+
* and `bad` is something that is actually not working.
|
|
15
|
+
*
|
|
16
|
+
* **Colour is never the only signal.** A state pill has a tint, a border and a
|
|
17
|
+
* word. The tint alone was 1.67:1 on Paper for weeks and looked fine on every
|
|
18
|
+
* dark theme anybody tested, which is why `pillTones.test.ts` measures every
|
|
19
|
+
* tone against every theme rather than trusting anyone's eye.
|
|
20
|
+
*
|
|
21
|
+
* **Tint into `--panel`, not `--panel-2`.** Mixing a mid tone into the darker
|
|
22
|
+
* surface moves the background towards the text; on a light theme that drops a
|
|
23
|
+
* label from 4.7:1 to 3.9:1. The tones mix at 10% into the panel for that
|
|
24
|
+
* reason and not for taste.
|
|
25
|
+
*
|
|
26
|
+
* This was the second-most-copied markup in the app — `<span className="pill
|
|
27
|
+
* pill-info">` at every call site, and a gallery page teaching it. A page that
|
|
28
|
+
* documents hand-written markup is a page that will be copied.
|
|
29
|
+
*/
|
|
30
|
+
export default function Pill({ tone, quiet, inRow, className, children, ...rest }) {
|
|
31
|
+
const classes = [
|
|
32
|
+
'pill',
|
|
33
|
+
quiet ? 'pill-quiet' : '',
|
|
34
|
+
tone ? `pill-${tone}` : '',
|
|
35
|
+
inRow ? 'row-pill' : '',
|
|
36
|
+
className ?? '',
|
|
37
|
+
]
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(' ');
|
|
40
|
+
return (_jsx("span", { className: classes, ...rest, children: children }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far along something is.
|
|
3
|
+
*
|
|
4
|
+
* There was a `.bar` -- six pixels of `--panel-2` with an accent `<i>` inside --
|
|
5
|
+
* and every caller did the arithmetic, the formatting and the conditional
|
|
6
|
+
* itself. That was survivable while one screen had a download in it. It is not
|
|
7
|
+
* now: the engine install, the model pull, the image runtime and the weights
|
|
8
|
+
* fetch all report progress, and three of them have a phase where the total is
|
|
9
|
+
* not known yet.
|
|
10
|
+
*
|
|
11
|
+
* **The indeterminate case is the reason this exists.** `.bar` could only draw a
|
|
12
|
+
* fraction, so a fetch that had not yet read `content-length` drew nothing --
|
|
13
|
+
* an empty track, indistinguishable from stalled, for however long the server
|
|
14
|
+
* took to answer. A thing that is working and a thing that is stuck must not
|
|
15
|
+
* look alike.
|
|
16
|
+
*
|
|
17
|
+
* **It announces itself.** A bar is a picture of a number, and a picture of a
|
|
18
|
+
* number is nothing at all to somebody who cannot see it. `role="progressbar"`
|
|
19
|
+
* with a `valuetext`, because "62%" is what the bar shows and "412 MB of 660 MB"
|
|
20
|
+
* is what it means.
|
|
21
|
+
*/
|
|
22
|
+
export default function Progress({ value, max, label, detail, tone, className, }: {
|
|
23
|
+
/** How far. Omit for indeterminate — the work has started, the size has not
|
|
24
|
+
* arrived. */
|
|
25
|
+
value?: number;
|
|
26
|
+
max?: number;
|
|
27
|
+
/** What is progressing. Required: an unlabelled bar is a rectangle. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** The human sentence, shown beside the bar and announced instead of the
|
|
30
|
+
* percentage. */
|
|
31
|
+
detail?: string;
|
|
32
|
+
/** A subset of the tone words; the default fill is the accent and is not a tone. */
|
|
33
|
+
tone?: 'good' | 'bad';
|
|
34
|
+
className?: string;
|
|
35
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* How far along something is.
|
|
4
|
+
*
|
|
5
|
+
* There was a `.bar` -- six pixels of `--panel-2` with an accent `<i>` inside --
|
|
6
|
+
* and every caller did the arithmetic, the formatting and the conditional
|
|
7
|
+
* itself. That was survivable while one screen had a download in it. It is not
|
|
8
|
+
* now: the engine install, the model pull, the image runtime and the weights
|
|
9
|
+
* fetch all report progress, and three of them have a phase where the total is
|
|
10
|
+
* not known yet.
|
|
11
|
+
*
|
|
12
|
+
* **The indeterminate case is the reason this exists.** `.bar` could only draw a
|
|
13
|
+
* fraction, so a fetch that had not yet read `content-length` drew nothing --
|
|
14
|
+
* an empty track, indistinguishable from stalled, for however long the server
|
|
15
|
+
* took to answer. A thing that is working and a thing that is stuck must not
|
|
16
|
+
* look alike.
|
|
17
|
+
*
|
|
18
|
+
* **It announces itself.** A bar is a picture of a number, and a picture of a
|
|
19
|
+
* number is nothing at all to somebody who cannot see it. `role="progressbar"`
|
|
20
|
+
* with a `valuetext`, because "62%" is what the bar shows and "412 MB of 660 MB"
|
|
21
|
+
* is what it means.
|
|
22
|
+
*/
|
|
23
|
+
export default function Progress({ value, max, label, detail, tone, className, }) {
|
|
24
|
+
const indeterminate = value === undefined || !max;
|
|
25
|
+
const pct = indeterminate ? 0 : Math.max(0, Math.min(100, (value / max) * 100));
|
|
26
|
+
return (_jsxs("div", { className: `progress${className ? ` ${className}` : ''}`, children: [(label || detail) && (_jsxs("div", { className: "progress-head", children: [_jsx("span", { className: "progress-label", children: label }), _jsx("span", { className: "grow" }), detail && _jsx("span", { className: "progress-detail mono", children: detail })] })), _jsx("div", { className: `bar${tone ? ` bar-${tone}` : ''}${indeterminate ? ' bar-indeterminate' : ''}`, role: "progressbar", "aria-label": label, "aria-valuenow": indeterminate ? undefined : Math.round(pct), "aria-valuemin": indeterminate ? undefined : 0, "aria-valuemax": indeterminate ? undefined : 100, "aria-valuetext": detail, children: _jsx("i", { style: indeterminate ? undefined : { width: `${pct}%` } }) })] }));
|
|
27
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list of things, each with a name, some qualifiers and something to do.
|
|
3
|
+
*
|
|
4
|
+
* There are thirty-eight of these in the app and every one is assembled by
|
|
5
|
+
* hand:
|
|
6
|
+
*
|
|
7
|
+
* <div className="set-row">
|
|
8
|
+
* <span className="grow" style={{ minWidth: 0 }}>
|
|
9
|
+
* <span className="named">
|
|
10
|
+
* <span className="truncate">{name}</span>
|
|
11
|
+
* …pills…
|
|
12
|
+
* </span>
|
|
13
|
+
* <div className="set-hint">{meta}</div>
|
|
14
|
+
* </span>
|
|
15
|
+
* …buttons…
|
|
16
|
+
* </div>
|
|
17
|
+
*
|
|
18
|
+
* Six nested elements, two of which exist only to stop the name eating the
|
|
19
|
+
* pills, and `minWidth: 0` inline on one of them because a flex item will not
|
|
20
|
+
* shrink below its content without it. Miss that and the row looks fine until
|
|
21
|
+
* somebody installs a model with a sixty-character name, at which point the
|
|
22
|
+
* tags saying what it can do get ellipsised away and the name -- the one part
|
|
23
|
+
* you could have guessed -- takes the whole row. That has been fixed twice.
|
|
24
|
+
*
|
|
25
|
+
* **The parts are named, so the layout is not the caller's problem.** `name`
|
|
26
|
+
* truncates, `pills` never do, `hint` is the line underneath, `trail` is the
|
|
27
|
+
* right-aligned column that lines up down the list, `actions` are the buttons.
|
|
28
|
+
* Every row in the app is some subset of those five.
|
|
29
|
+
*
|
|
30
|
+
* **`Rows` owns the dividers.** A rule between rows and none after the last one
|
|
31
|
+
* -- which sounds trivial and was not: `:last-of-type` matches by element type,
|
|
32
|
+
* so an actions bar rendered after the list meant the final row kept its border
|
|
33
|
+
* and every settings pane ended in two parallel lines. The container knows what
|
|
34
|
+
* a row is and the CSS asks it, rather than guessing from the DOM.
|
|
35
|
+
*/
|
|
36
|
+
export declare function Rows({ children, empty, label, look, className, }: {
|
|
37
|
+
/** Absent is the empty case too: a caller mapping over nothing renders
|
|
38
|
+
* nothing, and that has to reach `empty` rather than an empty bordered box. */
|
|
39
|
+
children?: React.ReactNode;
|
|
40
|
+
/** Shown instead of the list when there is nothing. Every caller writes one
|
|
41
|
+
* of these by hand today and half of them forget. */
|
|
42
|
+
empty?: React.ReactNode;
|
|
43
|
+
/** What the list is, for a screen reader. A list of rows with no name is a
|
|
44
|
+
* stack of unrelated sentences. */
|
|
45
|
+
label?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How the list is drawn.
|
|
48
|
+
*
|
|
49
|
+
* - `list` — rows separated by a hairline. Right for things you are reading:
|
|
50
|
+
* downloads, activity, applets.
|
|
51
|
+
* - `pick` — each row its own bordered box, spaced. Right for things you are
|
|
52
|
+
* *choosing between*: the rule-of-thumb elsewhere in this app is that
|
|
53
|
+
* several options are choice rows, and a ruled list reads as a table of
|
|
54
|
+
* records rather than as a set of alternatives one of which is yours.
|
|
55
|
+
*
|
|
56
|
+
* A variant rather than a second component: the slots, the truncation rule
|
|
57
|
+
* and the `min-width: 0` that makes the pills survive are the same either
|
|
58
|
+
* way, and only the surface differs.
|
|
59
|
+
*/
|
|
60
|
+
look?: 'list' | 'pick';
|
|
61
|
+
className?: string;
|
|
62
|
+
}): import("react").JSX.Element | null;
|
|
63
|
+
export declare function Row({ name, pills, hint, trail, actions, below, onClick, tone, picked, loading, waiting, align, className, }: {
|
|
64
|
+
/** The subject. Truncates -- it is the only part that may. */
|
|
65
|
+
name: React.ReactNode;
|
|
66
|
+
/** Qualifiers, on the name's line. Never truncated: a badge that is
|
|
67
|
+
* ellipsised is a badge that has stopped saying anything. */
|
|
68
|
+
pills?: React.ReactNode;
|
|
69
|
+
/** The line underneath: sizes, counts, whatever is true but secondary. */
|
|
70
|
+
hint?: React.ReactNode;
|
|
71
|
+
/** A right-aligned column before the actions -- a timestamp, a size. Lines up
|
|
72
|
+
* down the list, which is the whole reason it is a slot and not another
|
|
73
|
+
* pill. */
|
|
74
|
+
trail?: React.ReactNode;
|
|
75
|
+
actions?: React.ReactNode;
|
|
76
|
+
/** Something that opens underneath this row -- a confirmation, a report, the
|
|
77
|
+
* detail of the thing named above. A sibling rather than a child, because it
|
|
78
|
+
* is the full width of the list and not part of the row's own layout.
|
|
79
|
+
*
|
|
80
|
+
* This exists so callers do not wrap the row in a div of their own: doing
|
|
81
|
+
* that puts a plain element between `role="list"` and `role="listitem"`,
|
|
82
|
+
* which breaks the relationship, and it hides the row from the container's
|
|
83
|
+
* "no rule after the last one" rule. */
|
|
84
|
+
below?: React.ReactNode;
|
|
85
|
+
/** Makes the whole row activate. Rendered as a button, so it has a keyboard
|
|
86
|
+
* and a focus ring, which a `<div onClick>` has neither of. */
|
|
87
|
+
onClick?: () => void;
|
|
88
|
+
tone?: 'bad';
|
|
89
|
+
/** The one that is chosen, in a `pick` list. A second signal beside whatever
|
|
90
|
+
* the row already says in words -- colour is never the only one. */
|
|
91
|
+
picked?: boolean;
|
|
92
|
+
/** This row is the thing currently working. */
|
|
93
|
+
loading?: boolean;
|
|
94
|
+
/** Something else is working, so this one cannot be chosen yet. */
|
|
95
|
+
waiting?: boolean;
|
|
96
|
+
/** `start` when the row has enough text that vertically centred buttons drift
|
|
97
|
+
* away from the name they belong to. */
|
|
98
|
+
align?: 'center' | 'start';
|
|
99
|
+
className?: string;
|
|
100
|
+
}): import("react").JSX.Element;
|
|
101
|
+
export default Rows;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A list of things, each with a name, some qualifiers and something to do.
|
|
4
|
+
*
|
|
5
|
+
* There are thirty-eight of these in the app and every one is assembled by
|
|
6
|
+
* hand:
|
|
7
|
+
*
|
|
8
|
+
* <div className="set-row">
|
|
9
|
+
* <span className="grow" style={{ minWidth: 0 }}>
|
|
10
|
+
* <span className="named">
|
|
11
|
+
* <span className="truncate">{name}</span>
|
|
12
|
+
* …pills…
|
|
13
|
+
* </span>
|
|
14
|
+
* <div className="set-hint">{meta}</div>
|
|
15
|
+
* </span>
|
|
16
|
+
* …buttons…
|
|
17
|
+
* </div>
|
|
18
|
+
*
|
|
19
|
+
* Six nested elements, two of which exist only to stop the name eating the
|
|
20
|
+
* pills, and `minWidth: 0` inline on one of them because a flex item will not
|
|
21
|
+
* shrink below its content without it. Miss that and the row looks fine until
|
|
22
|
+
* somebody installs a model with a sixty-character name, at which point the
|
|
23
|
+
* tags saying what it can do get ellipsised away and the name -- the one part
|
|
24
|
+
* you could have guessed -- takes the whole row. That has been fixed twice.
|
|
25
|
+
*
|
|
26
|
+
* **The parts are named, so the layout is not the caller's problem.** `name`
|
|
27
|
+
* truncates, `pills` never do, `hint` is the line underneath, `trail` is the
|
|
28
|
+
* right-aligned column that lines up down the list, `actions` are the buttons.
|
|
29
|
+
* Every row in the app is some subset of those five.
|
|
30
|
+
*
|
|
31
|
+
* **`Rows` owns the dividers.** A rule between rows and none after the last one
|
|
32
|
+
* -- which sounds trivial and was not: `:last-of-type` matches by element type,
|
|
33
|
+
* so an actions bar rendered after the list meant the final row kept its border
|
|
34
|
+
* and every settings pane ended in two parallel lines. The container knows what
|
|
35
|
+
* a row is and the CSS asks it, rather than guessing from the DOM.
|
|
36
|
+
*/
|
|
37
|
+
export function Rows({ children, empty, label, look = 'list', className, }) {
|
|
38
|
+
const items = Array.isArray(children) ? children.filter(Boolean) : children;
|
|
39
|
+
const none = Array.isArray(items) ? items.length === 0 : !items;
|
|
40
|
+
if (none) {
|
|
41
|
+
return empty ? _jsx("div", { className: "set-hint rows-empty", children: empty }) : null;
|
|
42
|
+
}
|
|
43
|
+
return (_jsx("div", { className: `rows${look === 'pick' ? ' rows-pick' : ''}${className ? ` ${className}` : ''}`,
|
|
44
|
+
// biome-ignore lint/a11y/useSemanticElements: valid ARIA on a div; a <ul> brings the browser's list reset and the stylesheet and 204 baselines key on `div.rows`.
|
|
45
|
+
role: "list", "aria-label": label, children: items }));
|
|
46
|
+
}
|
|
47
|
+
export function Row({ name, pills, hint, trail, actions, below, onClick, tone, picked, loading, waiting, align = 'center', className, }) {
|
|
48
|
+
const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "row-subject", children: [_jsxs("span", { className: "named", children: [_jsx("span", { className: "truncate", children: name }), pills] }), hint && _jsx("div", { className: "set-hint", children: hint })] }), trail && _jsx("span", { className: "row-trail set-hint mono", children: trail })] }));
|
|
49
|
+
const rowClass = `set-row rows-row${tone ? ` rows-${tone}` : ''}` +
|
|
50
|
+
`${align === 'start' ? ' rows-top' : ''}${picked ? ' is-picked' : ''}` +
|
|
51
|
+
`${loading ? ' is-loading' : ''}${waiting ? ' is-waiting' : ''}` +
|
|
52
|
+
`${className ? ` ${className}` : ''}`;
|
|
53
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: rowClass, role: "listitem", children: [onClick ? (_jsx("button", { type: "button", className: "row-hit", disabled: waiting, onClick: onClick, children: body })) : (body), actions] }), below] }));
|
|
54
|
+
}
|
|
55
|
+
export default Rows;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
interface Props {
|
|
3
|
+
block?: boolean;
|
|
4
|
+
size?: 'sm' | 'md' | 'lg';
|
|
5
|
+
className?: string;
|
|
6
|
+
/** `<option>`s, exactly as a native select takes them. The children are the
|
|
7
|
+
* API and React Aria's items are the implementation; `readOptions` is the
|
|
8
|
+
* seam between them. */
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
id?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
'aria-label'?: string;
|
|
13
|
+
'aria-labelledby'?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
value?: string | number;
|
|
16
|
+
defaultValue?: string | number;
|
|
17
|
+
/** Native-select-shaped on purpose -- `e.target.value` -- because that is
|
|
18
|
+
* what every call site was written against when this *was* a `<select>`. */
|
|
19
|
+
onChange?: (event: {
|
|
20
|
+
target: {
|
|
21
|
+
value: string;
|
|
22
|
+
};
|
|
23
|
+
}) => void;
|
|
24
|
+
/** Something to the right of an option -- a play button beside a voice. */
|
|
25
|
+
aside?: (value: string) => ReactNode;
|
|
26
|
+
}
|
|
27
|
+
export default function Select({ block, size, className, children, value, defaultValue, onChange, disabled, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, }: Props): import("react").JSX.Element;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Children, isValidElement, useEffect, useRef } from 'react';
|
|
3
|
+
/** Read `<option>` children into a list this can render itself. */
|
|
4
|
+
function readOptions(children) {
|
|
5
|
+
const out = [];
|
|
6
|
+
Children.forEach(children, (child) => {
|
|
7
|
+
if (!isValidElement(child))
|
|
8
|
+
return;
|
|
9
|
+
const props = child.props;
|
|
10
|
+
const label = props.children;
|
|
11
|
+
out.push({
|
|
12
|
+
value: String(props.value ?? ''),
|
|
13
|
+
label,
|
|
14
|
+
text: typeof label === 'string' ? label : String(props.value ?? ''),
|
|
15
|
+
disabled: props.disabled,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/* Named rather than inherited from `SelectHTMLAttributes`.
|
|
21
|
+
The old signature spread every select attribute onto the element, which was
|
|
22
|
+
free when that element was a `<select>` and is wrong now that it is a
|
|
23
|
+
`<button>` -- `onCopy` alone is typed against a different element. Call sites
|
|
24
|
+
pass five things between them, so five is what this takes. */
|
|
25
|
+
import { Select as AriaSelect, Button, ListBox, ListBoxItem, Popover, SelectValue, } from 'react-aria-components';
|
|
26
|
+
export default function Select({ block = false, size, className, children, value, defaultValue, onChange, disabled, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, }) {
|
|
27
|
+
const options = readOptions(children);
|
|
28
|
+
/* `title`, set on the element: React Aria's `Button` takes `id` and the
|
|
29
|
+
`aria-*` labelling props and filters the rest, the same `filterDOMProps`
|
|
30
|
+
that dropped `aria-busy` on `Button` and `aria-modal` on `Modal`. */
|
|
31
|
+
const control = useRef(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const el = control.current;
|
|
34
|
+
if (!el)
|
|
35
|
+
return;
|
|
36
|
+
if (title)
|
|
37
|
+
el.title = title;
|
|
38
|
+
else
|
|
39
|
+
el.removeAttribute('title');
|
|
40
|
+
}, [title]);
|
|
41
|
+
/* React Aria owns what was 13 KB of hand-rolled behaviour: the popover is
|
|
42
|
+
positioned against the button and flips when the edge is near, which the
|
|
43
|
+
measured-rectangle `top`/`left` could not; typeahead, Home and End, the
|
|
44
|
+
arrows, Escape, outside-press and focus restore are all its. What stays
|
|
45
|
+
is the seam -- `<option>` children in, `{target: {value}}` out -- because
|
|
46
|
+
the call sites were written against a native select and there is no
|
|
47
|
+
reason to make them care that it is not one any more.
|
|
48
|
+
|
|
49
|
+
`sel-value` and the caret are the same markup as before, so the closed
|
|
50
|
+
control is pixel-identical; the list is the same markup with React Aria's
|
|
51
|
+
state attributes where the classes were. */
|
|
52
|
+
return (_jsxs(AriaSelect, { className: `sel${block ? ' block' : ''}${className ? ` ${className}` : ''}`, selectedKey: value !== undefined ? String(value) : undefined, defaultSelectedKey: defaultValue !== undefined ? String(defaultValue) : (options[0]?.value ?? undefined), onSelectionChange: (key) => {
|
|
53
|
+
if (key !== null)
|
|
54
|
+
onChange?.({ target: { value: String(key) } });
|
|
55
|
+
}, isDisabled: disabled, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children: [_jsxs(Button, { ref: control, id: id, className: `sel-control${size ? ` size-${size}` : ''}`, children: [_jsx(SelectValue, { className: "sel-value", children: ({ selectedText, defaultChildren }) => selectedText ?? defaultChildren }), _jsx("svg", { className: "sel-caret", viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m6 9 6 6 6-6" }) })] }), _jsx(Popover, { className: "sel-list", placement: "bottom start", offset: 4, maxHeight: 280, children: _jsx(ListBox, { className: "sel-listbox", children: options.map((o) => (_jsxs(ListBoxItem, { id: o.value, textValue: o.text, isDisabled: o.disabled, className: "sel-item", children: [_jsx("span", { className: "sel-item-label", children: o.label }), _jsx("svg", { className: "sel-tick", viewBox: "0 0 24 24", width: "13", height: "13", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", d: "m5 13 4 4 10-10" }) }), aside?.(o.value)] }, o.value))) }) })] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick a tile size by sweeping a grid, the way a spreadsheet asks for table
|
|
3
|
+
* dimensions.
|
|
4
|
+
*
|
|
5
|
+
* Six preset buttons and a `2×1` dropdown both said the same thing in a
|
|
6
|
+
* notation you have to decode. A tile is a rectangle on a grid, so the control
|
|
7
|
+
* is that rectangle: hover to see the shape you would get, click to take it.
|
|
8
|
+
* The label underneath keeps the numbers for anyone who wants them.
|
|
9
|
+
*
|
|
10
|
+
* It grows as you reach its edge, the way a spreadsheet's does: showing every
|
|
11
|
+
* cell up to the ceiling would be a wall of squares you have to aim in, and
|
|
12
|
+
* showing a fixed few would put the larger sizes behind a number box. Reaching
|
|
13
|
+
* the last column offers one more, and only then.
|
|
14
|
+
*
|
|
15
|
+
* `max` is the ceiling, and it means something: for an applet it is the
|
|
16
|
+
* dashboard grid you configured, so the control cannot offer a tile the layout
|
|
17
|
+
* has no room for.
|
|
18
|
+
*/
|
|
19
|
+
export default function SizeGrid({ value, max, disabled, onChange, }: {
|
|
20
|
+
value: {
|
|
21
|
+
cols: number;
|
|
22
|
+
rows: number;
|
|
23
|
+
};
|
|
24
|
+
/** The furthest this can go. The grid never offers past it. */
|
|
25
|
+
max?: {
|
|
26
|
+
cols: number;
|
|
27
|
+
rows: number;
|
|
28
|
+
};
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
onChange: (size: {
|
|
31
|
+
cols: number;
|
|
32
|
+
rows: number;
|
|
33
|
+
}) => void;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Pick a tile size by sweeping a grid, the way a spreadsheet asks for table
|
|
5
|
+
* dimensions.
|
|
6
|
+
*
|
|
7
|
+
* Six preset buttons and a `2×1` dropdown both said the same thing in a
|
|
8
|
+
* notation you have to decode. A tile is a rectangle on a grid, so the control
|
|
9
|
+
* is that rectangle: hover to see the shape you would get, click to take it.
|
|
10
|
+
* The label underneath keeps the numbers for anyone who wants them.
|
|
11
|
+
*
|
|
12
|
+
* It grows as you reach its edge, the way a spreadsheet's does: showing every
|
|
13
|
+
* cell up to the ceiling would be a wall of squares you have to aim in, and
|
|
14
|
+
* showing a fixed few would put the larger sizes behind a number box. Reaching
|
|
15
|
+
* the last column offers one more, and only then.
|
|
16
|
+
*
|
|
17
|
+
* `max` is the ceiling, and it means something: for an applet it is the
|
|
18
|
+
* dashboard grid you configured, so the control cannot offer a tile the layout
|
|
19
|
+
* has no room for.
|
|
20
|
+
*/
|
|
21
|
+
export default function SizeGrid({ value, max = { cols: 3, rows: 3 }, disabled = false, onChange, }) {
|
|
22
|
+
// What the pointer is over, which is what gets shown. Falls back to the real
|
|
23
|
+
// value on leave, so the control never lies about what is selected.
|
|
24
|
+
const [hover, setHover] = useState(null);
|
|
25
|
+
const shown = hover ?? value;
|
|
26
|
+
// One spare row and column beyond whatever is reached, so there is always
|
|
27
|
+
// somewhere further to go until the ceiling says otherwise.
|
|
28
|
+
const extent = {
|
|
29
|
+
cols: Math.min(max.cols, Math.max(value.cols, shown.cols) + 1),
|
|
30
|
+
rows: Math.min(max.rows, Math.max(value.rows, shown.rows) + 1),
|
|
31
|
+
};
|
|
32
|
+
const cells = [];
|
|
33
|
+
for (let row = 1; row <= extent.rows; row++) {
|
|
34
|
+
for (let col = 1; col <= extent.cols; col++) {
|
|
35
|
+
const on = col <= shown.cols && row <= shown.rows;
|
|
36
|
+
const chosen = col <= value.cols && row <= value.rows;
|
|
37
|
+
cells.push(_jsx("button", { type: "button", className: `size-cell${on ? ' on' : ''}${chosen && !hover ? ' chosen' : ''}`, disabled: disabled, "aria-label": `${col} by ${row}`, onMouseEnter: () => !disabled && setHover({ cols: col, rows: row }), onFocus: () => !disabled && setHover({ cols: col, rows: row }), onClick: () => onChange({ cols: col, rows: row }) }, `${col}x${row}`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return (_jsxs("div", { className: "size-grid-wrap", children: [_jsx("div", { className: "size-grid", style: { gridTemplateColumns: `repeat(${extent.cols}, 1fr)` }, onMouseLeave: () => setHover(null), children: cells }), _jsxs("span", { className: "set-hint mono", children: [shown.cols, " \u00D7 ", shown.rows, (extent.cols === max.cols || extent.rows === max.rows) && (_jsxs("span", { className: "size-ceiling", children: [' ', "\u00B7 max ", max.cols, "\u00D7", max.rows] }))] })] }));
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of what has not arrived yet.
|
|
3
|
+
*
|
|
4
|
+
* Ported from chef-monorepo's `Skeleton`, keeping its API — `variant`,
|
|
5
|
+
* `surface`, `animation`, and the convention that a bare number is a spacing
|
|
6
|
+
* step, so `height={5}` is 20px. That convention lands exactly on this app's
|
|
7
|
+
* four-pixel grid, which is why it survived the port unchanged. What did not
|
|
8
|
+
* survive is `cva` and `clsx`: this project keeps four runtime dependencies on
|
|
9
|
+
* purpose, and two of them would have been for string concatenation.
|
|
10
|
+
*
|
|
11
|
+
* **The colour is `currentColor`, not a named surface.** chef's version picks
|
|
12
|
+
* `bg-surface-muted` or `bg-surface-elevated`, and the first place this landed
|
|
13
|
+
* was a card inside a modal — where the card is already that exact token, so
|
|
14
|
+
* six skeletons rendered invisible on an identical background. Any scheme that
|
|
15
|
+
* names the colour has that in it: the caller has to know what it is being
|
|
16
|
+
* drawn on, and is wrong the first time a surface moves. Ink at low alpha is
|
|
17
|
+
* right on every surface in every theme without anybody deciding.
|
|
18
|
+
*
|
|
19
|
+
* **A skeleton is a promise about layout, not a loading noise.** Its whole job
|
|
20
|
+
* is to occupy the space the real thing will occupy, so nothing jumps when the
|
|
21
|
+
* content lands. A grey box of the wrong size is worse than no box at all: it
|
|
22
|
+
* says "something this shape is coming", and then something a different shape
|
|
23
|
+
* arrives and pushes the page around. If you cannot say what shape is coming,
|
|
24
|
+
* the honest control is `Progress`, which claims nothing about layout.
|
|
25
|
+
*
|
|
26
|
+
* **Invisible to a screen reader.** These are pictures of absent text; read
|
|
27
|
+
* aloud they are nothing at all. Mark the region `aria-busy` and let the
|
|
28
|
+
* announcement come from the content when it arrives — a reader should be told
|
|
29
|
+
* "loading" once, not handed six shapes.
|
|
30
|
+
*/
|
|
31
|
+
export default function Skeleton({ variant, surface, animation, width, height, lines, className, }: {
|
|
32
|
+
variant?: 'rectangular' | 'rounded' | 'circular';
|
|
33
|
+
/** How strongly it reads. Named for the surface in the original because it
|
|
34
|
+
* chose a colour there; here the colour comes from `currentColor`, so this
|
|
35
|
+
* is only a strength — `elevated` for the flatter surfaces where a faint
|
|
36
|
+
* bar disappears. */
|
|
37
|
+
surface?: 'muted' | 'elevated';
|
|
38
|
+
animation?: 'pulse' | 'none';
|
|
39
|
+
width?: number | string;
|
|
40
|
+
height?: number | string;
|
|
41
|
+
/** A paragraph rather than a bar. The last line is short, because real
|
|
42
|
+
* paragraphs end mid-line and a block of equal bars reads as a table. */
|
|
43
|
+
lines?: number;
|
|
44
|
+
className?: string;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The shape of what has not arrived yet.
|
|
4
|
+
*
|
|
5
|
+
* Ported from chef-monorepo's `Skeleton`, keeping its API — `variant`,
|
|
6
|
+
* `surface`, `animation`, and the convention that a bare number is a spacing
|
|
7
|
+
* step, so `height={5}` is 20px. That convention lands exactly on this app's
|
|
8
|
+
* four-pixel grid, which is why it survived the port unchanged. What did not
|
|
9
|
+
* survive is `cva` and `clsx`: this project keeps four runtime dependencies on
|
|
10
|
+
* purpose, and two of them would have been for string concatenation.
|
|
11
|
+
*
|
|
12
|
+
* **The colour is `currentColor`, not a named surface.** chef's version picks
|
|
13
|
+
* `bg-surface-muted` or `bg-surface-elevated`, and the first place this landed
|
|
14
|
+
* was a card inside a modal — where the card is already that exact token, so
|
|
15
|
+
* six skeletons rendered invisible on an identical background. Any scheme that
|
|
16
|
+
* names the colour has that in it: the caller has to know what it is being
|
|
17
|
+
* drawn on, and is wrong the first time a surface moves. Ink at low alpha is
|
|
18
|
+
* right on every surface in every theme without anybody deciding.
|
|
19
|
+
*
|
|
20
|
+
* **A skeleton is a promise about layout, not a loading noise.** Its whole job
|
|
21
|
+
* is to occupy the space the real thing will occupy, so nothing jumps when the
|
|
22
|
+
* content lands. A grey box of the wrong size is worse than no box at all: it
|
|
23
|
+
* says "something this shape is coming", and then something a different shape
|
|
24
|
+
* arrives and pushes the page around. If you cannot say what shape is coming,
|
|
25
|
+
* the honest control is `Progress`, which claims nothing about layout.
|
|
26
|
+
*
|
|
27
|
+
* **Invisible to a screen reader.** These are pictures of absent text; read
|
|
28
|
+
* aloud they are nothing at all. Mark the region `aria-busy` and let the
|
|
29
|
+
* announcement come from the content when it arrives — a reader should be told
|
|
30
|
+
* "loading" once, not handed six shapes.
|
|
31
|
+
*/
|
|
32
|
+
const STEP = 4;
|
|
33
|
+
/** A bare number is a spacing step; a string is a length, used as given. */
|
|
34
|
+
const length = (value, fallback) => {
|
|
35
|
+
const v = value ?? fallback;
|
|
36
|
+
return typeof v === 'number' ? `${v * STEP}px` : v;
|
|
37
|
+
};
|
|
38
|
+
export default function Skeleton({ variant = 'rectangular', surface = 'muted', animation = 'pulse', width = '100%', height = 2, lines, className, }) {
|
|
39
|
+
const cls = `skel skel-${variant} skel-on-${surface}${animation === 'none' ? '' : ' skel-pulse'}${className ? ` ${className}` : ''}`;
|
|
40
|
+
if (lines && lines > 1) {
|
|
41
|
+
return (_jsx("span", { className: "skel-lines", "aria-hidden": "true", children: Array.from({ length: lines }, (_, i) => (_jsx("span", { className: cls, style: {
|
|
42
|
+
height: length(height, 2),
|
|
43
|
+
width: i === lines - 1 ? '62%' : length(width, '100%'),
|
|
44
|
+
} }, i))) }));
|
|
45
|
+
}
|
|
46
|
+
return (_jsx("span", { className: cls, "aria-hidden": "true", style: { height: length(height, 2), width: length(width, '100%') } }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A number chosen from a range, by dragging.
|
|
3
|
+
*
|
|
4
|
+
* For a setting whose answer is "about this much" rather than one of a list:
|
|
5
|
+
* how much memory tf may take, say. A `Select` with eight sizes on it makes
|
|
6
|
+
* the person pick the nearest wrong one; a box you type a number into asks
|
|
7
|
+
* them to know the ceiling. A slider shows the ceiling, the floor, and where
|
|
8
|
+
* between them they are.
|
|
9
|
+
*
|
|
10
|
+
* **Bounded, always.** `min` and `max` are required rather than defaulted,
|
|
11
|
+
* because a slider with an invented ceiling lies about what the machine can
|
|
12
|
+
* do. **Stepped, optionally.** `step` given, the knob snaps to that interval
|
|
13
|
+
* -- half a gigabyte, five minutes -- and the value is always one of the
|
|
14
|
+
* marks. Without it the range is continuous (`step="any"`) and the value is
|
|
15
|
+
* wherever the knob stopped, which is right for a quantity nobody counts in
|
|
16
|
+
* units.
|
|
17
|
+
*
|
|
18
|
+
* **Applies on release, shows while dragging.** A setting that saved on
|
|
19
|
+
* every pixel would put a hundred writes behind one gesture, so `onChange`
|
|
20
|
+
* fires when the knob is let go and the value beside the knob follows the
|
|
21
|
+
* drag live. That is the *native* `change` event, listened for directly:
|
|
22
|
+
* React's `onChange` on an input is its `input` event under another name
|
|
23
|
+
* and fires per pixel, which is exactly what the first version did -- every
|
|
24
|
+
* pixel saved, every save handed the value back, and the knob snapped to
|
|
25
|
+
* wherever the last reply said while the pointer was still down. Nobody
|
|
26
|
+
* could drag it. The keyboard is the one place even the native event is too
|
|
27
|
+
* eager: an arrow key fires `change` per press, and the same replies landed
|
|
28
|
+
* between presses (measured: twenty-two presses from the floor landed at
|
|
29
|
+
* 2.5 rather than 12). So key presses settle for a third of a second before
|
|
30
|
+
* they are said, and while a drag or a settle is under way the prop is not
|
|
31
|
+
* allowed to overwrite what is on screen.
|
|
32
|
+
*
|
|
33
|
+
* A native `<input type="range">`, for the same reason `Toggle` is a native
|
|
34
|
+
* checkbox: the keyboard, the focus ring and the announcement come free, and
|
|
35
|
+
* `aria-valuetext` says the value in the caller's words ("12.0 GB") rather
|
|
36
|
+
* than as a bare number.
|
|
37
|
+
*/
|
|
38
|
+
export default function Slider({ label, hint, value, min, max, step, onChange, format, disabled, labelHidden, size, danger, className, }: {
|
|
39
|
+
label: React.ReactNode;
|
|
40
|
+
/** What it decides, or what the ends of it mean. */
|
|
41
|
+
hint?: React.ReactNode;
|
|
42
|
+
value: number;
|
|
43
|
+
min: number;
|
|
44
|
+
max: number;
|
|
45
|
+
/** The interval the knob snaps to. Absent, the range is continuous. */
|
|
46
|
+
step?: number;
|
|
47
|
+
/** The value settled on: knob released, or a key pressed. */
|
|
48
|
+
onChange: (value: number) => void;
|
|
49
|
+
/** The value in words, beside the knob and to a screen reader. */
|
|
50
|
+
format?: (value: number) => string;
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
labelHidden?: boolean;
|
|
53
|
+
/** The same three names every other control takes, and the same three
|
|
54
|
+
* boxes: `sm`, `md` and `lg` are `.size-sm`, `.size-md` and `.size-lg`'s
|
|
55
|
+
* padding and type, so a slider beside a button or a select of the same
|
|
56
|
+
* size is the same height. Widths 120, 150 and 180. */
|
|
57
|
+
size?: 'sm' | 'md' | 'lg';
|
|
58
|
+
/**
|
|
59
|
+
* Where the range starts to be a risk. The fill is the accent up to
|
|
60
|
+
* `from` and drifts, step by step, to the danger colour at `to` (the
|
|
61
|
+
* maximum when omitted) -- so a value past the safe mark reads as such
|
|
62
|
+
* without a word beside it, and the further past, the more so. For a
|
|
63
|
+
* ceiling somebody may raise past what was measured.
|
|
64
|
+
*/
|
|
65
|
+
danger?: {
|
|
66
|
+
from: number;
|
|
67
|
+
to?: number;
|
|
68
|
+
};
|
|
69
|
+
className?: string;
|
|
70
|
+
}): import("react").JSX.Element;
|