@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,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* A number chosen from a range, by dragging.
|
|
5
|
+
*
|
|
6
|
+
* For a setting whose answer is "about this much" rather than one of a list:
|
|
7
|
+
* how much memory tf may take, say. A `Select` with eight sizes on it makes
|
|
8
|
+
* the person pick the nearest wrong one; a box you type a number into asks
|
|
9
|
+
* them to know the ceiling. A slider shows the ceiling, the floor, and where
|
|
10
|
+
* between them they are.
|
|
11
|
+
*
|
|
12
|
+
* **Bounded, always.** `min` and `max` are required rather than defaulted,
|
|
13
|
+
* because a slider with an invented ceiling lies about what the machine can
|
|
14
|
+
* do. **Stepped, optionally.** `step` given, the knob snaps to that interval
|
|
15
|
+
* -- half a gigabyte, five minutes -- and the value is always one of the
|
|
16
|
+
* marks. Without it the range is continuous (`step="any"`) and the value is
|
|
17
|
+
* wherever the knob stopped, which is right for a quantity nobody counts in
|
|
18
|
+
* units.
|
|
19
|
+
*
|
|
20
|
+
* **Applies on release, shows while dragging.** A setting that saved on
|
|
21
|
+
* every pixel would put a hundred writes behind one gesture, so `onChange`
|
|
22
|
+
* fires when the knob is let go and the value beside the knob follows the
|
|
23
|
+
* drag live. That is the *native* `change` event, listened for directly:
|
|
24
|
+
* React's `onChange` on an input is its `input` event under another name
|
|
25
|
+
* and fires per pixel, which is exactly what the first version did -- every
|
|
26
|
+
* pixel saved, every save handed the value back, and the knob snapped to
|
|
27
|
+
* wherever the last reply said while the pointer was still down. Nobody
|
|
28
|
+
* could drag it. The keyboard is the one place even the native event is too
|
|
29
|
+
* eager: an arrow key fires `change` per press, and the same replies landed
|
|
30
|
+
* between presses (measured: twenty-two presses from the floor landed at
|
|
31
|
+
* 2.5 rather than 12). So key presses settle for a third of a second before
|
|
32
|
+
* they are said, and while a drag or a settle is under way the prop is not
|
|
33
|
+
* allowed to overwrite what is on screen.
|
|
34
|
+
*
|
|
35
|
+
* A native `<input type="range">`, for the same reason `Toggle` is a native
|
|
36
|
+
* checkbox: the keyboard, the focus ring and the announcement come free, and
|
|
37
|
+
* `aria-valuetext` says the value in the caller's words ("12.0 GB") rather
|
|
38
|
+
* than as a bare number.
|
|
39
|
+
*/
|
|
40
|
+
export default function Slider({ label, hint, value, min, max, step, onChange, format = (v) => String(v), disabled, labelHidden, size = 'md', danger, className, }) {
|
|
41
|
+
const id = useId();
|
|
42
|
+
// What the knob is at while it is being dragged. The prop is what was
|
|
43
|
+
// last settled; between the two the drag is shown and nothing is saved.
|
|
44
|
+
const [live, setLive] = useState(value);
|
|
45
|
+
const input = useRef(null);
|
|
46
|
+
const keyboard = useRef(false);
|
|
47
|
+
const dragging = useRef(false);
|
|
48
|
+
const settling = useRef(null);
|
|
49
|
+
// Read at commit time rather than closed over, so the native listener
|
|
50
|
+
// below is registered once and still calls the caller's latest handler.
|
|
51
|
+
const commit = useRef(onChange);
|
|
52
|
+
commit.current = onChange;
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!dragging.current && settling.current === null)
|
|
55
|
+
setLive(value);
|
|
56
|
+
}, [value]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const el = input.current;
|
|
59
|
+
if (!el)
|
|
60
|
+
return;
|
|
61
|
+
const settle = () => {
|
|
62
|
+
const v = Number(el.value);
|
|
63
|
+
if (settling.current !== null)
|
|
64
|
+
clearTimeout(settling.current);
|
|
65
|
+
if (!keyboard.current)
|
|
66
|
+
return commit.current(v);
|
|
67
|
+
settling.current = setTimeout(() => {
|
|
68
|
+
settling.current = null;
|
|
69
|
+
commit.current(v);
|
|
70
|
+
}, 350);
|
|
71
|
+
};
|
|
72
|
+
el.addEventListener('change', settle);
|
|
73
|
+
return () => {
|
|
74
|
+
el.removeEventListener('change', settle);
|
|
75
|
+
if (settling.current !== null)
|
|
76
|
+
clearTimeout(settling.current);
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
const fill = max > min ? ((Math.min(max, Math.max(min, live)) - min) / (max - min)) * 100 : 0;
|
|
80
|
+
// How far into the risk the value is, 0..1: none up to `from`, all at `to`.
|
|
81
|
+
const risk = danger
|
|
82
|
+
? Math.max(0, Math.min(1, (live - danger.from) / ((danger.to ?? max) - danger.from || 1)))
|
|
83
|
+
: 0;
|
|
84
|
+
return (_jsxs("div", { className: `slider-row slider-${size}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { htmlFor: id, className: labelHidden ? 'sr-only' : 'slider-body', children: [_jsx("span", { className: "slider-label", children: label }), hint && _jsx("span", { className: "slider-hint", children: hint })] }), _jsxs("div", { className: "slider-control", children: [_jsxs("span", { className: "slider-box", children: [_jsx("span", { className: "slider-track", style: {
|
|
85
|
+
'--slider-f': String(fill / 100),
|
|
86
|
+
'--slider-risk': `${Math.round(risk * 100)}%`,
|
|
87
|
+
}, children: _jsx("input", { id: id, type: "range", className: "slider", min: min, max: max, step: step ?? 'any', value: live, disabled: disabled, "aria-valuetext": format(live), ref: input, onKeyDown: () => {
|
|
88
|
+
keyboard.current = true;
|
|
89
|
+
}, onPointerDown: () => {
|
|
90
|
+
keyboard.current = false;
|
|
91
|
+
dragging.current = true;
|
|
92
|
+
}, onPointerUp: () => {
|
|
93
|
+
dragging.current = false;
|
|
94
|
+
}, onPointerCancel: () => {
|
|
95
|
+
dragging.current = false;
|
|
96
|
+
},
|
|
97
|
+
// React's `onChange` is the per-pixel event; it only draws. The
|
|
98
|
+
// commit is the native `change`, wired above.
|
|
99
|
+
onChange: (e) => setLive(Number(e.target.value)) }) }), _jsx("output", { htmlFor: id, className: "slider-value mono", children: format(live) })] }), _jsx("span", { className: "slider-end mono", "aria-hidden": "true", children: format(min) }), _jsx("span", { className: "slider-end mono", "aria-hidden": "true", children: format(max) })] })] }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data that genuinely has columns.
|
|
3
|
+
*
|
|
4
|
+
* **This is not `Rows`, and the difference is not cosmetic.** A `Row` is one
|
|
5
|
+
* thing with a name and some facts about it; you read down the names and stop
|
|
6
|
+
* at the one you want. A table is a grid you read *across* as well as down --
|
|
7
|
+
* comparing the same field between two things is the only reason to line them
|
|
8
|
+
* up in tracks. If nobody will ever compare column three between row two and
|
|
9
|
+
* row nine, it is a list, and a list is `Rows`.
|
|
10
|
+
*
|
|
11
|
+
* **A real `<table>`, because the semantics are the accessibility.** A grid of
|
|
12
|
+
* divs looks identical and announces as a wall of unrelated text: a screen
|
|
13
|
+
* reader reading a `<td>` says which column it is in, and `<th scope>` is what
|
|
14
|
+
* makes that possible. There is no ARIA that gets you back what a `<table>`
|
|
15
|
+
* gives for free, only ARIA that approximates it badly.
|
|
16
|
+
*
|
|
17
|
+
* **Numbers go right.** Not decoration -- digits are compared by their columns,
|
|
18
|
+
* and left-aligned numbers of different lengths cannot be. `align: 'end'` also
|
|
19
|
+
* switches the cell to tabular figures so the digits keep their tracks.
|
|
20
|
+
*/
|
|
21
|
+
export interface Column<T> {
|
|
22
|
+
/** The heading. Every column has one; an unlabelled column is a column
|
|
23
|
+
* nobody can ask about. */
|
|
24
|
+
header: string;
|
|
25
|
+
/** What to draw in the cell. */
|
|
26
|
+
cell: (item: T) => React.ReactNode;
|
|
27
|
+
/** `end` for numbers and sizes, which compare by column. */
|
|
28
|
+
align?: 'start' | 'end';
|
|
29
|
+
/** Hides the heading visually and leaves it for a screen reader -- for the
|
|
30
|
+
* column of buttons at the end, where a visible "Actions" is noise. */
|
|
31
|
+
quiet?: boolean;
|
|
32
|
+
width?: string;
|
|
33
|
+
}
|
|
34
|
+
export default function Table<T>({ caption, columns, rows, keyOf, empty, className, }: {
|
|
35
|
+
/** What the table is. Rendered as a real `<caption>`, which is the one place
|
|
36
|
+
* a table's name belongs. */
|
|
37
|
+
caption: string;
|
|
38
|
+
columns: Column<T>[];
|
|
39
|
+
rows: T[];
|
|
40
|
+
keyOf: (item: T) => string;
|
|
41
|
+
empty?: React.ReactNode;
|
|
42
|
+
className?: string;
|
|
43
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export default function Table({ caption, columns, rows, keyOf, empty, className, }) {
|
|
3
|
+
if (!rows.length) {
|
|
4
|
+
return empty ? _jsx("div", { className: "set-hint rows-empty", children: empty }) : null;
|
|
5
|
+
}
|
|
6
|
+
return (_jsx("div", { className: `table-scroll${className ? ` ${className}` : ''}`, children: _jsxs("table", { className: "table", children: [_jsx("caption", { className: "sr-only", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { scope: "col", style: { width: c.width, textAlign: c.align === 'end' ? 'right' : undefined }, children: _jsx("span", { className: c.quiet ? 'sr-only' : undefined, children: c.header }) }, c.header))) }) }), _jsx("tbody", { children: rows.map((item) => (_jsx("tr", { children: columns.map((c, i) => {
|
|
7
|
+
/* The first cell is the row's name, so it is a `<th scope="row">`
|
|
8
|
+
-- that is what lets a reader ask "which row am I in" and get
|
|
9
|
+
an answer instead of a cell index. */
|
|
10
|
+
const Cell = i === 0 ? 'th' : 'td';
|
|
11
|
+
return (_jsx(Cell, { scope: i === 0 ? 'row' : undefined, className: c.align === 'end' ? 'num' : undefined, children: c.cell(item) }, c.header));
|
|
12
|
+
}) }, keyOf(item)))) })] }) }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One row of choices where only one is showing.
|
|
3
|
+
*
|
|
4
|
+
* **The arrow keys are the point.** A tab list is a single tab stop: you Tab
|
|
5
|
+
* into it once and then move along it with arrows. Rendering tabs as ordinary
|
|
6
|
+
* buttons gives the opposite -- one stop per tab -- so a keyboard user pages
|
|
7
|
+
* through the whole strip to reach whatever is after it. That is invisible
|
|
8
|
+
* unless you put the mouse down, which is why it is worth having a component
|
|
9
|
+
* that cannot get it wrong.
|
|
10
|
+
*
|
|
11
|
+
* **Settings' rail is one of these, and I said it was not.** The earlier note
|
|
12
|
+
* here argued that a vertical list with an explanatory line under each name is
|
|
13
|
+
* "a different control that happens to select one of several panes". That was
|
|
14
|
+
* about how it looks. It is a tablist by every behavioural test -- one of ten
|
|
15
|
+
* is current, choosing one swaps the pane beside it, `aria-current` was already
|
|
16
|
+
* on it -- and being written as ten plain buttons cost exactly what this
|
|
17
|
+
* component exists to prevent: ten tab stops between the top of the window and
|
|
18
|
+
* the pane. `orientation="vertical"` is the smaller change.
|
|
19
|
+
*
|
|
20
|
+
* `ViewTabs` is still not one: it carries inline rename, drag-to-reorder and a
|
|
21
|
+
* cog per tab, and swapping it for this would delete features to gain a
|
|
22
|
+
* keyboard it can be given directly. The studio's "Source / Preview" is one
|
|
23
|
+
* button that changes its own label, which is a toggle.
|
|
24
|
+
*
|
|
25
|
+
* **Groups are headings, not tabs.** A vertical rail can put its tabs under
|
|
26
|
+
* group labels -- Settings has fifteen leaves and a flat list of fifteen is a
|
|
27
|
+
* list you read twice. The label is a heading with a hint under it and is not
|
|
28
|
+
* interactive: a button inside a tablist that is not a tab is a thing the
|
|
29
|
+
* arrow keys do not know about, and a collapsed group would hide tabs from a
|
|
30
|
+
* keyboard that expects to reach every one. So the rail shows everything and
|
|
31
|
+
* the group says what the tabs under it have in common; a tab inside a group
|
|
32
|
+
* shows no hint of its own, because the group's is the context and fifteen
|
|
33
|
+
* one-liners is the height the grouping was meant to save. Horizontal strips
|
|
34
|
+
* ignore groups -- there is no room above a tab for a heading.
|
|
35
|
+
*/
|
|
36
|
+
import React from 'react';
|
|
37
|
+
export interface Tab {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
/** Shown after the label, for a count or a state. */
|
|
41
|
+
badge?: string;
|
|
42
|
+
/** A line under the label, for a rail with room for one. Ignored in a
|
|
43
|
+
* horizontal strip, where there is none, and under a group, where the
|
|
44
|
+
* group's hint is the context. */
|
|
45
|
+
hint?: string;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
/** The group this tab sits under in a vertical rail -- a `TabGroup.id`.
|
|
48
|
+
* Tabs of one group must be adjacent in `tabs`; the label is drawn above
|
|
49
|
+
* the first of them. */
|
|
50
|
+
group?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface TabGroup {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
/** What the tabs under this label have in common, in one line. */
|
|
56
|
+
hint?: string;
|
|
57
|
+
}
|
|
58
|
+
export default function Tabs({ tabs, value, onChange, label, orientation, groups, className, }: {
|
|
59
|
+
tabs: Tab[];
|
|
60
|
+
value: string;
|
|
61
|
+
onChange: (id: string) => void;
|
|
62
|
+
/** What this set of tabs is choosing between. */
|
|
63
|
+
label: string;
|
|
64
|
+
/** `vertical` for a rail down the side of a pane — Settings' sections,
|
|
65
|
+
* which are tabs in every way that matters and were separate tab stops
|
|
66
|
+
* because they were written as buttons. */
|
|
67
|
+
orientation?: 'horizontal' | 'vertical';
|
|
68
|
+
/** The headings a vertical rail draws above runs of tabs that name them in
|
|
69
|
+
* `Tab.group`. A group no tab names is not drawn. */
|
|
70
|
+
groups?: TabGroup[];
|
|
71
|
+
className?: string;
|
|
72
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* One row of choices where only one is showing.
|
|
4
|
+
*
|
|
5
|
+
* **The arrow keys are the point.** A tab list is a single tab stop: you Tab
|
|
6
|
+
* into it once and then move along it with arrows. Rendering tabs as ordinary
|
|
7
|
+
* buttons gives the opposite -- one stop per tab -- so a keyboard user pages
|
|
8
|
+
* through the whole strip to reach whatever is after it. That is invisible
|
|
9
|
+
* unless you put the mouse down, which is why it is worth having a component
|
|
10
|
+
* that cannot get it wrong.
|
|
11
|
+
*
|
|
12
|
+
* **Settings' rail is one of these, and I said it was not.** The earlier note
|
|
13
|
+
* here argued that a vertical list with an explanatory line under each name is
|
|
14
|
+
* "a different control that happens to select one of several panes". That was
|
|
15
|
+
* about how it looks. It is a tablist by every behavioural test -- one of ten
|
|
16
|
+
* is current, choosing one swaps the pane beside it, `aria-current` was already
|
|
17
|
+
* on it -- and being written as ten plain buttons cost exactly what this
|
|
18
|
+
* component exists to prevent: ten tab stops between the top of the window and
|
|
19
|
+
* the pane. `orientation="vertical"` is the smaller change.
|
|
20
|
+
*
|
|
21
|
+
* `ViewTabs` is still not one: it carries inline rename, drag-to-reorder and a
|
|
22
|
+
* cog per tab, and swapping it for this would delete features to gain a
|
|
23
|
+
* keyboard it can be given directly. The studio's "Source / Preview" is one
|
|
24
|
+
* button that changes its own label, which is a toggle.
|
|
25
|
+
*
|
|
26
|
+
* **Groups are headings, not tabs.** A vertical rail can put its tabs under
|
|
27
|
+
* group labels -- Settings has fifteen leaves and a flat list of fifteen is a
|
|
28
|
+
* list you read twice. The label is a heading with a hint under it and is not
|
|
29
|
+
* interactive: a button inside a tablist that is not a tab is a thing the
|
|
30
|
+
* arrow keys do not know about, and a collapsed group would hide tabs from a
|
|
31
|
+
* keyboard that expects to reach every one. So the rail shows everything and
|
|
32
|
+
* the group says what the tabs under it have in common; a tab inside a group
|
|
33
|
+
* shows no hint of its own, because the group's is the context and fifteen
|
|
34
|
+
* one-liners is the height the grouping was meant to save. Horizontal strips
|
|
35
|
+
* ignore groups -- there is no room above a tab for a heading.
|
|
36
|
+
*/
|
|
37
|
+
import React, { useRef } from 'react';
|
|
38
|
+
export default function Tabs({ tabs, value, onChange, label, orientation = 'horizontal', groups = [], className, }) {
|
|
39
|
+
const strip = useRef(null);
|
|
40
|
+
const grouped = orientation === 'vertical' && groups.length > 0;
|
|
41
|
+
const move = (from, by) => {
|
|
42
|
+
const n = tabs.length;
|
|
43
|
+
for (let i = 1; i <= n; i++) {
|
|
44
|
+
const at = (from + by * i + n * n) % n;
|
|
45
|
+
if (!tabs[at].disabled) {
|
|
46
|
+
onChange(tabs[at].id);
|
|
47
|
+
// Focus follows selection, which is the pattern for tabs whose panels
|
|
48
|
+
// are cheap to show. The alternative -- move focus, select on Enter --
|
|
49
|
+
// is for tab panels that cost something to render, and none here do.
|
|
50
|
+
strip.current?.querySelectorAll('[role="tab"]')[at]?.focus();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return (_jsxs("div", { className: `tabs tabs-${orientation}${className ? ` ${className}` : ''}`, role: "tablist", "aria-label": label, "aria-orientation": orientation, ref: strip, children: [tabs.map((t, i) => (_jsxs(React.Fragment, { children: [grouped &&
|
|
56
|
+
t.group &&
|
|
57
|
+
tabs[i - 1]?.group !== t.group &&
|
|
58
|
+
(() => {
|
|
59
|
+
const g = groups.find((x) => x.id === t.group);
|
|
60
|
+
return g ? (_jsxs("div", { className: "tab-group", role: "presentation", children: [_jsx("span", { className: "tab-group-label", children: g.label }), g.hint && _jsx("span", { className: "tab-hint", children: g.hint })] })) : null;
|
|
61
|
+
})(), _jsxs("button", { role: "tab", type: "button", className: `tab${t.id === value ? ' on' : ''}${grouped && t.group ? ' in-group' : ''}`, "aria-selected": t.id === value, disabled: t.disabled,
|
|
62
|
+
/* One tab stop for the whole strip: everything but the current tab is
|
|
63
|
+
taken out of the tab order, and the arrows move between them. */
|
|
64
|
+
tabIndex: t.id === value ? 0 : -1, onClick: () => onChange(t.id), onKeyDown: (e) => {
|
|
65
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
move(i, 1);
|
|
68
|
+
}
|
|
69
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
move(i, -1);
|
|
72
|
+
}
|
|
73
|
+
if (e.key === 'Home') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
move(-1, 1);
|
|
76
|
+
}
|
|
77
|
+
if (e.key === 'End') {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
move(tabs.length, -1);
|
|
80
|
+
}
|
|
81
|
+
}, children: [_jsx("span", { className: "tab-label", children: t.label }), t.badge && _jsx("span", { className: "tab-badge", children: t.badge }), t.hint && orientation === 'vertical' && !(grouped && t.group) && (_jsx("span", { className: "tab-hint", children: t.hint }))] })] }, t.id))), _jsx("span", { className: "tabs-rule", "aria-hidden": "true" })] }));
|
|
82
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
|
|
2
|
+
size?: 'sm' | 'md' | 'lg';
|
|
3
|
+
/** Monospace — a system prompt, an environment block, anything where the
|
|
4
|
+
* characters are the content. */
|
|
5
|
+
mono?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
declare const Textarea: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLTextAreaElement>>;
|
|
9
|
+
export default Textarea;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The other bare control `Field` wraps.
|
|
4
|
+
*
|
|
5
|
+
* Not resizable by hand. The chat composer grows with what you type, and the
|
|
6
|
+
* browser's drag handle fought it — you would size it, type a line, and it
|
|
7
|
+
* would snap back. `resize: none` lives in the stylesheet on the element, so
|
|
8
|
+
* this component keeps it by rendering the element.
|
|
9
|
+
*
|
|
10
|
+
* Same contract as `Input`: a plain `<textarea>` so every element rule in the
|
|
11
|
+
* stylesheet keeps working, and its `id`, `aria-describedby` and `aria-invalid`
|
|
12
|
+
* come from `Field`'s render prop rather than from a context. On its own it
|
|
13
|
+
* needs an `aria-label`, for the same reason `Input` does.
|
|
14
|
+
*/
|
|
15
|
+
import { forwardRef } from 'react';
|
|
16
|
+
const Textarea = forwardRef(function Textarea({ size = 'md', mono, className, ...rest }, ref) {
|
|
17
|
+
const classes = [size === 'md' ? '' : `size-${size}`, mono ? 'mono' : '', className ?? '']
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.join(' ');
|
|
20
|
+
return _jsx("textarea", { ref: ref, className: classes || undefined, ...rest });
|
|
21
|
+
});
|
|
22
|
+
export default Textarea;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Something happened, and it does not need answering.
|
|
3
|
+
*
|
|
4
|
+
* There is no transient feedback in this app at all. Every outcome is either a
|
|
5
|
+
* `.callout` that stays until the page changes, or nothing -- so "saved",
|
|
6
|
+
* "copied", "model removed" and "conversation deleted" are all silent, and the
|
|
7
|
+
* only way to know a button worked is that something else moved.
|
|
8
|
+
*
|
|
9
|
+
* **A toast is for the outcome you would not have chased.** If the reader has to
|
|
10
|
+
* act on it, it is a callout and belongs on the page. If they have to decide, it
|
|
11
|
+
* is a dialog. This is the third case: it worked, you may carry on, and in four
|
|
12
|
+
* seconds there will be no trace.
|
|
13
|
+
*
|
|
14
|
+
* **`role="status"`, not `alert`.** An alert interrupts whatever a screen reader
|
|
15
|
+
* is saying, which is right for "the download failed" and rude for "copied".
|
|
16
|
+
* The failing case passes `tone="bad"`, which is the one that promotes itself.
|
|
17
|
+
*/
|
|
18
|
+
export interface Toast {
|
|
19
|
+
text: string;
|
|
20
|
+
/** The same four words `Callout` and `Pill` use. `warn` was missing here
|
|
21
|
+
* until 2026-09-05 -- three tones where the rest of the system has four. */
|
|
22
|
+
tone: 'info' | 'good' | 'warn' | 'bad';
|
|
23
|
+
}
|
|
24
|
+
export declare function useToast(): (text: string, tone?: Toast["tone"]) => void;
|
|
25
|
+
/**
|
|
26
|
+
* The queue and the region, once, at the root.
|
|
27
|
+
*
|
|
28
|
+
* React Aria's `ToastQueue` owns what a `useState` list and two `setTimeout`s
|
|
29
|
+
* did by hand, and three things they did not: the timeout pauses while the
|
|
30
|
+
* pointer or focus is on the toast; the region is a landmark, so a screen
|
|
31
|
+
* reader can reach it with F6 rather than only hearing it; and dismissing one
|
|
32
|
+
* puts focus back where it was, instead of dropping it on the body.
|
|
33
|
+
*
|
|
34
|
+
* The role changes, and on purpose. Each toast was `role="status"` -- or
|
|
35
|
+
* `alert` for the failing tone -- which is right for text that only has to be
|
|
36
|
+
* heard. These carry a Dismiss button, and a live region's contents are not
|
|
37
|
+
* reachable; React Aria makes each toast an `alertdialog` inside a live
|
|
38
|
+
* `region` for that reason, so the announcement still happens and the button
|
|
39
|
+
* can still be reached.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ToastHost({ children }: {
|
|
42
|
+
children: React.ReactNode;
|
|
43
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Something happened, and it does not need answering.
|
|
4
|
+
*
|
|
5
|
+
* There is no transient feedback in this app at all. Every outcome is either a
|
|
6
|
+
* `.callout` that stays until the page changes, or nothing -- so "saved",
|
|
7
|
+
* "copied", "model removed" and "conversation deleted" are all silent, and the
|
|
8
|
+
* only way to know a button worked is that something else moved.
|
|
9
|
+
*
|
|
10
|
+
* **A toast is for the outcome you would not have chased.** If the reader has to
|
|
11
|
+
* act on it, it is a callout and belongs on the page. If they have to decide, it
|
|
12
|
+
* is a dialog. This is the third case: it worked, you may carry on, and in four
|
|
13
|
+
* seconds there will be no trace.
|
|
14
|
+
*
|
|
15
|
+
* **`role="status"`, not `alert`.** An alert interrupts whatever a screen reader
|
|
16
|
+
* is saying, which is right for "the download failed" and rude for "copied".
|
|
17
|
+
* The failing case passes `tone="bad"`, which is the one that promotes itself.
|
|
18
|
+
*/
|
|
19
|
+
import { createContext, useContext, useMemo, useState } from 'react';
|
|
20
|
+
import Icon from './Icon';
|
|
21
|
+
import { UNSTABLE_Toast as AriaToast, Button, Text, UNSTABLE_ToastContent as ToastContent, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastRegion as ToastRegion, } from 'react-aria-components';
|
|
22
|
+
const MARK = {
|
|
23
|
+
info: 'info',
|
|
24
|
+
good: 'check',
|
|
25
|
+
warn: 'warning',
|
|
26
|
+
bad: 'error',
|
|
27
|
+
};
|
|
28
|
+
/** How long one stays. Long enough to read twice, short enough that a run of
|
|
29
|
+
* them does not pile up -- and paused while the pointer or focus is on it,
|
|
30
|
+
* because a message that expires while you are reading it was never shown. */
|
|
31
|
+
const LINGER = 4000;
|
|
32
|
+
const Ctx = createContext(() => { });
|
|
33
|
+
export function useToast() {
|
|
34
|
+
return useContext(Ctx);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The queue and the region, once, at the root.
|
|
38
|
+
*
|
|
39
|
+
* React Aria's `ToastQueue` owns what a `useState` list and two `setTimeout`s
|
|
40
|
+
* did by hand, and three things they did not: the timeout pauses while the
|
|
41
|
+
* pointer or focus is on the toast; the region is a landmark, so a screen
|
|
42
|
+
* reader can reach it with F6 rather than only hearing it; and dismissing one
|
|
43
|
+
* puts focus back where it was, instead of dropping it on the body.
|
|
44
|
+
*
|
|
45
|
+
* The role changes, and on purpose. Each toast was `role="status"` -- or
|
|
46
|
+
* `alert` for the failing tone -- which is right for text that only has to be
|
|
47
|
+
* heard. These carry a Dismiss button, and a live region's contents are not
|
|
48
|
+
* reachable; React Aria makes each toast an `alertdialog` inside a live
|
|
49
|
+
* `region` for that reason, so the announcement still happens and the button
|
|
50
|
+
* can still be reached.
|
|
51
|
+
*/
|
|
52
|
+
export function ToastHost({ children }) {
|
|
53
|
+
const queue = useMemo(() => new ToastQueue({ maxVisibleToasts: 4 }), []);
|
|
54
|
+
/* What gets read aloud, and it is not the toast.
|
|
55
|
+
|
|
56
|
+
The old region was `aria-live="polite"` around the toasts themselves, so a
|
|
57
|
+
screen reader heard "Settings saved" the moment it appeared. React Aria's
|
|
58
|
+
region is a landmark instead -- reachable, labelled "1 notification." --
|
|
59
|
+
and after a push there is no live region anywhere in the document: that
|
|
60
|
+
was measured, not assumed, and it would have shipped "saved", "copied" and
|
|
61
|
+
"the download failed" in silence to anyone not looking.
|
|
62
|
+
|
|
63
|
+
So the text is mirrored into two visually-hidden live regions of our own:
|
|
64
|
+
polite for the ordinary case and assertive for a failure, the same
|
|
65
|
+
distinction `role="status"` and `role="alert"` used to draw. An alert
|
|
66
|
+
interrupts whatever is being read, right for bad news and rude for
|
|
67
|
+
"copied". */
|
|
68
|
+
const [polite, setPolite] = useState('');
|
|
69
|
+
const [assertive, setAssertive] = useState('');
|
|
70
|
+
const push = useMemo(() => (text, tone = 'info') => {
|
|
71
|
+
queue.add({ text, tone }, { timeout: LINGER });
|
|
72
|
+
const say = tone === 'bad' ? setAssertive : setPolite;
|
|
73
|
+
// Cleared and re-set, so the same message twice is announced twice.
|
|
74
|
+
say('');
|
|
75
|
+
requestAnimationFrame(() => say(text));
|
|
76
|
+
}, [queue]);
|
|
77
|
+
return (_jsxs(Ctx.Provider, { value: push, children: [children, _jsx("div", { className: "sr-only", "aria-live": "polite", "aria-atomic": "true", children: polite }), _jsx("div", { className: "sr-only", "aria-live": "assertive", "aria-atomic": "true", children: assertive }), _jsx(ToastRegion, { queue: queue, className: "toasts", children: ({ toast }) => (_jsxs(AriaToast, { toast: toast, className: `toast toast-${toast.content.tone}`, children: [_jsx(Icon, { name: MARK[toast.content.tone], size: 16, className: "toast-mark" }), _jsx(ToastContent, { className: "grow", children: _jsx(Text, { slot: "title", children: toast.content.text }) }), _jsx(Button, { slot: "close", className: "ghost size-sm", "aria-label": "Dismiss", children: _jsx(Icon, { name: "close", size: 14 }) })] })) })] }));
|
|
78
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A setting that takes effect as it moves.
|
|
3
|
+
*
|
|
4
|
+
* **A checkbox and a switch are not the same control, and the difference is not
|
|
5
|
+
* visual.** A checkbox collects an answer: you tick it, and it applies when you
|
|
6
|
+
* press Save. A switch *is* the action — flipping it changes the thing, now,
|
|
7
|
+
* and there is nothing to confirm afterwards. That is why a switch reads as on
|
|
8
|
+
* or off rather than ticked or blank: the two states are both settled, and
|
|
9
|
+
* neither is a draft.
|
|
10
|
+
*
|
|
11
|
+
* Six settings in this app saved the moment they changed and were all drawn as
|
|
12
|
+
* tick boxes: whether an applet is enabled, whether it reaches the internet,
|
|
13
|
+
* whether a tool is granted, whether a server is trusted. Every one of them
|
|
14
|
+
* promised a Save button that does not
|
|
15
|
+
* exist. The header's status panel (gone since 2026-09-03) was the only one
|
|
16
|
+
* that had it right, in a comment nobody else read.
|
|
17
|
+
*
|
|
18
|
+
* **`role="switch"`, so it is announced as one.** A screen reader says "on" and
|
|
19
|
+
* "off" instead of "checked" and "not checked" — which is the same distinction
|
|
20
|
+
* in words, and the only signal a non-visual reader gets that pressing it does
|
|
21
|
+
* something immediately.
|
|
22
|
+
*
|
|
23
|
+
* **The label is the hit area.** A 30×18 target fails the minimum on its own,
|
|
24
|
+
* and reaching for the words is what people do anyway.
|
|
25
|
+
*
|
|
26
|
+
* **One switch, or several checkboxes.** A lone binary setting is a switch. A
|
|
27
|
+
* set you pick from is a list of checkboxes — fifteen switches in a column read
|
|
28
|
+
* as fifteen unrelated settings rather than one choice with fifteen parts. See
|
|
29
|
+
* `Checkbox`.
|
|
30
|
+
*/
|
|
31
|
+
export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size, className, }: {
|
|
32
|
+
label: React.ReactNode;
|
|
33
|
+
/** What it does, or what turning it off costs. Under the label, in the same
|
|
34
|
+
* column, so a long explanation does not push the switch off its row. */
|
|
35
|
+
hint?: React.ReactNode;
|
|
36
|
+
checked: boolean;
|
|
37
|
+
/** Returning a promise moves the knob at once and marks the row busy until it
|
|
38
|
+
* settles: a rejection puts the knob back, a resolution holds it until
|
|
39
|
+
* `checked` catches up. A plain return leaves everything to the caller. */
|
|
40
|
+
onChange: (on: boolean) => void | Promise<void>;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
/** The state in words, beside the switch.
|
|
43
|
+
*
|
|
44
|
+
* Only where nothing else already says it. A switch that is a `Card`'s
|
|
45
|
+
* action has a title above it naming what it governs and a description
|
|
46
|
+
* saying what happens either way — a third word beside the switch is the
|
|
47
|
+
* same fact a third time, in the one place it does not fit. The header's
|
|
48
|
+
* status panel kept it because there the row was the whole component; it
|
|
49
|
+
* is gone, and the gallery's specimen is the one caller left. */
|
|
50
|
+
said?: React.ReactNode;
|
|
51
|
+
labelHidden?: boolean;
|
|
52
|
+
/** The same three the rest of the controls take. `sm` for a switch in a
|
|
53
|
+
* toolbar or a dense row; `lg` where it is the only thing on the screen. */
|
|
54
|
+
size?: 'sm' | 'md' | 'lg';
|
|
55
|
+
className?: string;
|
|
56
|
+
}): import("react").JSX.Element;
|