@wtfalch/design 0.6.0 → 0.8.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/dist/components/Button.d.ts +19 -27
- package/dist/components/Button.js +61 -1
- package/dist/components/Callout.d.ts +0 -21
- package/dist/components/Callout.js +7 -0
- package/dist/components/Card.js +7 -0
- package/dist/components/Checkbox.d.ts +0 -31
- package/dist/components/Checkbox.js +7 -0
- package/dist/components/Command.js +10 -2
- package/dist/components/DangerZone.js +7 -0
- package/dist/components/Dialog.d.ts +0 -21
- package/dist/components/Dialog.js +7 -0
- package/dist/components/Field.d.ts +22 -50
- package/dist/components/Field.js +18 -9
- package/dist/components/Identity.d.ts +0 -25
- package/dist/components/Identity.js +7 -0
- package/dist/components/Input.d.ts +10 -0
- package/dist/components/Input.js +31 -3
- package/dist/components/Kbd.d.ts +7 -0
- package/dist/components/Kbd.js +34 -0
- package/dist/components/Markdown.js +7 -0
- package/dist/components/Menu.js +10 -2
- package/dist/components/Modal.d.ts +0 -33
- package/dist/components/Modal.js +7 -0
- package/dist/components/Pagination.d.ts +0 -33
- package/dist/components/Pagination.js +7 -0
- package/dist/components/Popover.js +7 -0
- package/dist/components/Rows.js +7 -0
- package/dist/components/ScrollArea.js +7 -0
- package/dist/components/Select.d.ts +8 -1
- package/dist/components/Select.js +34 -5
- package/dist/components/Shell.d.ts +35 -0
- package/dist/components/Shell.js +26 -0
- package/dist/components/SizeGrid.js +7 -0
- package/dist/components/Slider.js +7 -0
- package/dist/components/SplitPane.js +7 -0
- package/dist/components/Stat.d.ts +45 -0
- package/dist/components/Stat.js +31 -0
- package/dist/components/Tabs.d.ts +21 -0
- package/dist/components/Tabs.js +8 -1
- package/dist/components/Textarea.js +14 -4
- package/dist/components/ThemeSwitch.d.ts +19 -0
- package/dist/components/ThemeSwitch.js +65 -0
- package/dist/components/Toast.d.ts +24 -18
- package/dist/components/Toast.js +44 -3
- package/dist/components/Toggle.d.ts +0 -30
- package/dist/components/Toggle.js +7 -0
- package/dist/components/Tooltip.js +7 -0
- package/dist/components/Tour.d.ts +0 -23
- package/dist/components/Tour.js +7 -0
- package/dist/components/fieldWiring.d.ts +44 -0
- package/dist/components/fieldWiring.js +30 -0
- package/dist/index.d.ts +23 -3
- package/dist/index.js +17 -1
- package/dist/styles/index.css +259 -13
- package/dist/tf.css +259 -13
- package/dist/themes/choice.d.ts +71 -0
- package/dist/themes/choice.js +81 -0
- package/dist/valet.css +259 -13
- package/package.json +2 -1
package/dist/components/Menu.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
9
|
+
import { Menu as AriaMenu, Popover as AriaPopover, Header, MenuItem, MenuSection, MenuTrigger, Separator, SubmenuTrigger, Text, } from 'react-aria-components';
|
|
10
|
+
import Kbd from './Kbd.js';
|
|
3
11
|
import Icon from './Icon.js';
|
|
4
12
|
function isSection(entry) {
|
|
5
13
|
return 'title' in entry && Array.isArray(entry.items);
|
|
@@ -9,7 +17,7 @@ function renderItem(item) {
|
|
|
9
17
|
/* Typeahead needs a string, and `label` may be a node. Without this,
|
|
10
18
|
typing the first letter of an item whose label is markup matches
|
|
11
19
|
nothing and the menu looks broken. */
|
|
12
|
-
textValue: typeof item.label === 'string' ? item.label : item.id, children: [item.icon && _jsx(Icon, { name: item.icon, className: "menu-icon" }), _jsxs("span", { className: "menu-text", children: [_jsx(Text, { slot: "label", className: "menu-label", children: item.label }), item.description && (_jsx(Text, { slot: "description", className: "menu-desc", children: item.description }))] }), item.shortcut && _jsx(
|
|
20
|
+
textValue: typeof item.label === 'string' ? item.label : item.id, children: [item.icon && _jsx(Icon, { name: item.icon, className: "menu-icon" }), _jsxs("span", { className: "menu-text", children: [_jsx(Text, { slot: "label", className: "menu-label", children: item.label }), item.description && (_jsx(Text, { slot: "description", className: "menu-desc", children: item.description }))] }), item.shortcut && _jsx(Kbd, { className: "menu-key", children: item.shortcut }), item.items && (_jsx("svg", { className: "menu-more", viewBox: "0 0 24 24", width: "12", height: "12", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m9 6 6 6-6 6" }) }))] }, item.id));
|
|
13
21
|
const body = item.items ? (_jsxs(SubmenuTrigger, { children: [row, _jsx(AriaPopover, { className: "menu-sheet", children: _jsx(AriaMenu, { className: "menu-list", children: item.items.map(renderItem) }) })] }, item.id)) : (row);
|
|
14
22
|
if (!item.separated)
|
|
15
23
|
return body;
|
|
@@ -1,36 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A window over the app, and the behaviour every one of them was missing.
|
|
3
|
-
*
|
|
4
|
-
* There are eight of these -- Settings, FirstRun, ViewSettings, DownloadModal,
|
|
5
|
-
* ModelSettings, AppletSettings, AppletReview, the studio -- and all eight were
|
|
6
|
-
* built by hand from `.backdrop` and `.modal`. Not one of them trapped focus.
|
|
7
|
-
*
|
|
8
|
-
* **That is not a detail.** Everything behind a modal is still in the tab
|
|
9
|
-
* order: still focusable, still clickable by a keyboard, and completely
|
|
10
|
-
* invisible under the scrim. Tab past the last button in Settings and you are
|
|
11
|
-
* somewhere on the dashboard you cannot see, operating controls you cannot
|
|
12
|
-
* read. Shift-Tab from the first does the same going the other way. The mouse
|
|
13
|
-
* never finds this, which is why it survived eight implementations.
|
|
14
|
-
*
|
|
15
|
-
* So: focus moves in, is kept in, and goes back where it came from when the
|
|
16
|
-
* window closes -- to the button that opened it, not to the top of the page.
|
|
17
|
-
* `role="dialog"` and `aria-modal` say the same thing to a screen reader, which
|
|
18
|
-
* otherwise reads the page underneath as though it were still there.
|
|
19
|
-
*
|
|
20
|
-
* **This is the workspace shape.** A title, a body, optionally a footer, sized
|
|
21
|
-
* to its content. `Dialog` is the narrow two-answer version and is built on
|
|
22
|
-
* this -- same trap, same restore, stricter about the scrim, because for "may
|
|
23
|
-
* this applet write to your files" a stray click on the background is a way of
|
|
24
|
-
* answering by accident.
|
|
25
|
-
*
|
|
26
|
-
* **A sheet is this with `edge` set, not a second component.** A drawer from
|
|
27
|
-
* the side of the window differs from a window in the middle of it by where it
|
|
28
|
-
* is anchored and which way it slides -- and in nothing else. Same focus trap,
|
|
29
|
-
* same restore, same scrim, same header, body and footer. Writing a `Sheet`
|
|
30
|
-
* that duplicates all of that to change two CSS properties is how a design
|
|
31
|
-
* system ends up with two windows that drift: one of them gets the fix and
|
|
32
|
-
* nobody notices which.
|
|
33
|
-
*/
|
|
34
1
|
export default function Modal({ title, description, subtitle, head, children, footer, footerClass, onClose, closeDisabled, width, edge, bodyClass, className, dismissOnScrim, closeButton, labelledBy, }: {
|
|
35
2
|
/** The window's name. Rendered as the heading and announced on open. */
|
|
36
3
|
title?: React.ReactNode;
|
package/dist/components/Modal.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
/**
|
|
3
10
|
* A window over the app, and the behaviour every one of them was missing.
|
|
4
11
|
*
|
|
@@ -1,36 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Moving through a list that does not fit.
|
|
3
|
-
*
|
|
4
|
-
* **It counts in items, not in pages**, because that is what the server
|
|
5
|
-
* answers and what the reader asks. A JMAP query returns a position, a limit
|
|
6
|
-
* and a total; a mailbox is "51–100 of 1,284", not "page 2 of 26". Page
|
|
7
|
-
* numbers are this component's arithmetic, done once here rather than at every
|
|
8
|
-
* call site -- which is where the off-by-one lives, and where it becomes an
|
|
9
|
-
* empty last page.
|
|
10
|
-
*
|
|
11
|
-
* **The count is the point, and it is said out loud.** "51–100 of 1,284" tells
|
|
12
|
-
* you how far in you are and how much is left; two arrows tell you neither. A
|
|
13
|
-
* pager with no count is a pager you navigate by feel.
|
|
14
|
-
*
|
|
15
|
-
* **A total is optional, because a server may refuse to count.** JMAP's
|
|
16
|
-
* `calculateTotal` is a request, not a promise, and a large mailbox is exactly
|
|
17
|
-
* where it gets declined. Without one this shows the range and keeps Next
|
|
18
|
-
* enabled while a full page came back, which is the only honest thing it can
|
|
19
|
-
* do: a page shorter than the limit is the end.
|
|
20
|
-
*
|
|
21
|
-
* **The ellipsis is a field, not punctuation.** It stands for the pages you
|
|
22
|
-
* cannot see, so it is where you say which one you want: type a number, press
|
|
23
|
-
* Enter, and you are there. Drawn as a ghost -- no border, no background, the
|
|
24
|
-
* `…` as its placeholder -- so at rest the row looks exactly like a row of
|
|
25
|
-
* page buttons with an elision in it, and it becomes a control when you touch
|
|
26
|
-
* it. Without this, reaching page 17 of 26 is eleven presses of Next, and the
|
|
27
|
-
* middle of a row of live buttons is a dead spot.
|
|
28
|
-
*
|
|
29
|
-
* **`nav` with a name**, so a screen reader can jump to it and so two pagers
|
|
30
|
-
* on a page are distinguishable. The current page's button is
|
|
31
|
-
* `aria-current="page"`, which is what tells a reader where they are without
|
|
32
|
-
* relying on the colour that says it visually.
|
|
33
|
-
*/
|
|
34
1
|
export interface Props {
|
|
35
2
|
/** Index of the first item shown, counting from zero -- the same number the
|
|
36
3
|
* query was given, so caller and component never disagree about the origin. */
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
/**
|
|
3
10
|
* Moving through a list that does not fit.
|
|
4
11
|
*
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { Popover as AriaPopover, Dialog, DialogTrigger, } from 'react-aria-components';
|
|
3
10
|
export default function Popover({ trigger, children, placement = 'bottom start', label, offset = 6, open, onOpenChange, className, }) {
|
|
4
11
|
return (_jsxs(DialogTrigger, { isOpen: open, onOpenChange: onOpenChange, children: [trigger, _jsx(AriaPopover, { className: `pop${className ? ` ${className}` : ''}`, placement: placement, offset: offset, children: _jsx(Dialog, { className: "pop-body", "aria-label": label, children: ({ close }) => (typeof children === 'function' ? children(close) : children) }) })] }));
|
package/dist/components/Rows.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
/**
|
|
3
10
|
* A list of things, each with a name, some qualifiers and something to do.
|
|
4
11
|
*
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { useCallback, useEffect, useRef } from 'react';
|
|
3
10
|
export default function ScrollArea({ axis = 'y', fade = true, hideBar = false, className, children, ref, label, }) {
|
|
4
11
|
const own = useRef(null);
|
|
@@ -12,6 +12,13 @@ interface Props {
|
|
|
12
12
|
'aria-label'?: string;
|
|
13
13
|
'aria-labelledby'?: string;
|
|
14
14
|
disabled?: boolean;
|
|
15
|
+
'aria-describedby'?: string;
|
|
16
|
+
'aria-invalid'?: boolean;
|
|
17
|
+
/** What the form submits this under. Without it there is nothing to post,
|
|
18
|
+
* which is what sent callers back to a mirrored hidden input. */
|
|
19
|
+
name?: string;
|
|
20
|
+
/** The form to submit with, when the control sits outside it. */
|
|
21
|
+
form?: string;
|
|
15
22
|
value?: string | number;
|
|
16
23
|
defaultValue?: string | number;
|
|
17
24
|
/** Native-select-shaped on purpose -- `e.target.value` -- because that is
|
|
@@ -24,5 +31,5 @@ interface Props {
|
|
|
24
31
|
/** Something to the right of an option -- a play button beside a voice. */
|
|
25
32
|
aside?: (value: string) => ReactNode;
|
|
26
33
|
}
|
|
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;
|
|
34
|
+
export default function Select({ block, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: Props): import("react").JSX.Element;
|
|
28
35
|
export {};
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { Children, isValidElement, useEffect, useRef } from 'react';
|
|
3
10
|
/** Read `<option>` children into a list this can render itself. */
|
|
4
11
|
function readOptions(children) {
|
|
@@ -22,13 +29,26 @@ function readOptions(children) {
|
|
|
22
29
|
free when that element was a `<select>` and is wrong now that it is a
|
|
23
30
|
`<button>` -- `onCopy` alone is typed against a different element. Call sites
|
|
24
31
|
pass five things between them, so five is what this takes. */
|
|
32
|
+
import { useFieldWiring } from './fieldWiring.js';
|
|
25
33
|
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, }) {
|
|
34
|
+
export default function Select({ block = false, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
27
35
|
const options = readOptions(children);
|
|
28
|
-
/* `
|
|
29
|
-
`
|
|
30
|
-
|
|
36
|
+
/* A `Select` is a `<button>`, and a button takes its accessible name from
|
|
37
|
+
its contents -- so `Field`'s `htmlFor` does not name it and the caller
|
|
38
|
+
used to repeat the label in an `aria-label`. Reading the wiring here is
|
|
39
|
+
what removes the second literal. Explicit props win throughout. */
|
|
40
|
+
const field = useFieldWiring();
|
|
41
|
+
const wiredId = id ?? field?.id;
|
|
42
|
+
const wiredLabelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId);
|
|
43
|
+
/* `title` and `aria-invalid`, set on the element: React Aria's `Button`
|
|
44
|
+
takes `id` and the *labelling* aria props -- label, labelledby,
|
|
45
|
+
describedby, details -- and filters the rest, the same `filterDOMProps`
|
|
46
|
+
that dropped `aria-busy` on `Button` and `aria-modal` on `Modal`.
|
|
47
|
+
`aria-invalid` is not on that list, so passing it as a prop typechecks,
|
|
48
|
+
reads correctly and does nothing at all. That is the third time this
|
|
49
|
+
has caught someone, which is why it is written down here too. */
|
|
31
50
|
const control = useRef(null);
|
|
51
|
+
const invalid = ariaInvalid ?? field?.['aria-invalid'];
|
|
32
52
|
useEffect(() => {
|
|
33
53
|
const el = control.current;
|
|
34
54
|
if (!el)
|
|
@@ -38,6 +58,15 @@ export default function Select({ block = false, size, className, children, value
|
|
|
38
58
|
else
|
|
39
59
|
el.removeAttribute('title');
|
|
40
60
|
}, [title]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const el = control.current;
|
|
63
|
+
if (!el)
|
|
64
|
+
return;
|
|
65
|
+
if (invalid)
|
|
66
|
+
el.setAttribute('aria-invalid', 'true');
|
|
67
|
+
else
|
|
68
|
+
el.removeAttribute('aria-invalid');
|
|
69
|
+
}, [invalid]);
|
|
41
70
|
/* React Aria owns what was 13 KB of hand-rolled behaviour: the popover is
|
|
42
71
|
positioned against the button and flips when the edge is near, which the
|
|
43
72
|
measured-rectangle `top`/`left` could not; typeahead, Home and End, the
|
|
@@ -52,5 +81,5 @@ export default function Select({ block = false, size, className, children, value
|
|
|
52
81
|
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
82
|
if (key !== null)
|
|
54
83
|
onChange?.({ target: { value: String(key) } });
|
|
55
|
-
}, isDisabled: disabled, "aria-label": ariaLabel, "aria-labelledby":
|
|
84
|
+
}, isDisabled: disabled, name: name, form: form, "aria-label": ariaLabel, "aria-labelledby": wiredLabelledBy, children: [_jsxs(Button, { ref: control, id: wiredId, "aria-describedby": ariaDescribedBy ?? field?.['aria-describedby'], 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
85
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame a signed-in surface stands in: a header, and the page under it.
|
|
3
|
+
*
|
|
4
|
+
* Three apps have one, all called `shell.tsx`, all the same six elements. The
|
|
5
|
+
* differences were a wider variant in manage and where each put the theme
|
|
6
|
+
* control, which is a prop and a slot, not three components.
|
|
7
|
+
*
|
|
8
|
+
* **It gates nothing, and that is worth stating because a frame looks like
|
|
9
|
+
* the place to.** A layout cannot reliably stop the page beneath it
|
|
10
|
+
* rendering, so every page decides for itself who may see it. Putting a check
|
|
11
|
+
* here would read as protection and provide none. All three app docblocks say
|
|
12
|
+
* this; it moves with the component.
|
|
13
|
+
*
|
|
14
|
+
* **A `<header>` and a `<main>`, so the landmarks exist.** Hand-built
|
|
15
|
+
* versions reached for `<div>` often enough that this is the second reason to
|
|
16
|
+
* have one: "skip to content" and a screen reader's landmark list both come
|
|
17
|
+
* from the elements, and neither is visible to the person who wrote the div.
|
|
18
|
+
*
|
|
19
|
+
* Server-renderable: no hooks, no handlers. `who` and `brand` are slots, so
|
|
20
|
+
* the client parts an app needs -- a user menu, the theme switch -- are the
|
|
21
|
+
* app's to pass and stay its own client boundaries.
|
|
22
|
+
*/
|
|
23
|
+
export default function Shell({ brand, who, nav, wide, children, className, }: {
|
|
24
|
+
/** Top left: the product's name or mark, usually a link home. */
|
|
25
|
+
brand?: React.ReactNode;
|
|
26
|
+
/** Top right: who is signed in, the theme, whatever else is chrome. */
|
|
27
|
+
who?: React.ReactNode;
|
|
28
|
+
/** A row under the header, for an app whose pages hang off a context --
|
|
29
|
+
* manage's organisation nav is this. */
|
|
30
|
+
nav?: React.ReactNode;
|
|
31
|
+
/** A wider measure, for a page that is a table rather than a form. */
|
|
32
|
+
wide?: boolean;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
className?: string;
|
|
35
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The frame a signed-in surface stands in: a header, and the page under it.
|
|
4
|
+
*
|
|
5
|
+
* Three apps have one, all called `shell.tsx`, all the same six elements. The
|
|
6
|
+
* differences were a wider variant in manage and where each put the theme
|
|
7
|
+
* control, which is a prop and a slot, not three components.
|
|
8
|
+
*
|
|
9
|
+
* **It gates nothing, and that is worth stating because a frame looks like
|
|
10
|
+
* the place to.** A layout cannot reliably stop the page beneath it
|
|
11
|
+
* rendering, so every page decides for itself who may see it. Putting a check
|
|
12
|
+
* here would read as protection and provide none. All three app docblocks say
|
|
13
|
+
* this; it moves with the component.
|
|
14
|
+
*
|
|
15
|
+
* **A `<header>` and a `<main>`, so the landmarks exist.** Hand-built
|
|
16
|
+
* versions reached for `<div>` often enough that this is the second reason to
|
|
17
|
+
* have one: "skip to content" and a screen reader's landmark list both come
|
|
18
|
+
* from the elements, and neither is visible to the person who wrote the div.
|
|
19
|
+
*
|
|
20
|
+
* Server-renderable: no hooks, no handlers. `who` and `brand` are slots, so
|
|
21
|
+
* the client parts an app needs -- a user menu, the theme switch -- are the
|
|
22
|
+
* app's to pass and stay its own client boundaries.
|
|
23
|
+
*/
|
|
24
|
+
export default function Shell({ brand, who, nav, wide = false, children, className, }) {
|
|
25
|
+
return (_jsxs("div", { className: ['shell', className].filter(Boolean).join(' '), children: [_jsxs("header", { className: "shell-head", children: [_jsxs("div", { className: `shell-head-inner${wide ? ' shell-wide' : ''}`, children: [brand, who && _jsx("div", { className: "shell-who", children: who })] }), nav && _jsx("div", { className: `shell-nav${wide ? ' shell-wide' : ''}`, children: nav })] }), _jsx("main", { className: `shell-main${wide ? ' shell-wide' : ''}`, children: children })] }));
|
|
26
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { useState } from 'react';
|
|
3
10
|
/**
|
|
4
11
|
* Pick a tile size by sweeping a grid, the way a spreadsheet asks for table
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
3
10
|
/**
|
|
4
11
|
* A number chosen from a range, by dragging.
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
3
10
|
function clamp(value, min, max) {
|
|
4
11
|
return Math.min(max, Math.max(min, value));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One figure, with what it counts over it and what it means under it.
|
|
3
|
+
*
|
|
4
|
+
* **Drawn three times before it was a component.** valet's portal overview
|
|
5
|
+
* and its organisation page under `/admin` each had their own, and the same
|
|
6
|
+
* account's numbers rendered in two shapes on the two surfaces read as two
|
|
7
|
+
* products rather than as two views of one thing. Folding them into one local
|
|
8
|
+
* `Stat` fixed that inside valet and left the next app to rediscover it;
|
|
9
|
+
* valet's own docblock says so ("a candidate for the package, once a second
|
|
10
|
+
* product wants one"). The mail client's counts are the second product.
|
|
11
|
+
*
|
|
12
|
+
* **`tabular-nums`, and that is the reason this is not a `Card`.** Figures
|
|
13
|
+
* are read down a row, and proportional digits put the same magnitude in
|
|
14
|
+
* different places on every tile, so a column of numbers stops being
|
|
15
|
+
* comparable at a glance. A tile is not a card with a big number in it: it is
|
|
16
|
+
* a number with furniture, and the furniture exists to make the number
|
|
17
|
+
* legible next to the one beside it.
|
|
18
|
+
*
|
|
19
|
+
* **The label goes over the value.** A figure read before you know what it
|
|
20
|
+
* counts is a figure you read twice. This is the one place in the package
|
|
21
|
+
* where the caption leads.
|
|
22
|
+
*
|
|
23
|
+
* **`look="bare"` is the compact form**, for a figure inside a row that
|
|
24
|
+
* already has a border around it -- valet's `.v-figure`, in the admin table.
|
|
25
|
+
* A bordered tile inside a bordered row is two boxes saying one thing.
|
|
26
|
+
*/
|
|
27
|
+
export default function Stat({ label, value, note, tone, look, className, }: {
|
|
28
|
+
/** What is being counted. Over the value, not under it. */
|
|
29
|
+
label: React.ReactNode;
|
|
30
|
+
/** The figure. A string, because the formatting -- locale, unit, precision
|
|
31
|
+
* -- is the caller's: this component cannot know whether 1024 should read
|
|
32
|
+
* as `1,024`, `1.0k` or `1 KiB`. */
|
|
33
|
+
value: React.ReactNode;
|
|
34
|
+
/** The sentence under the figure. What it excludes, when it was measured,
|
|
35
|
+
* what it is a fraction of. */
|
|
36
|
+
note?: React.ReactNode;
|
|
37
|
+
/** Semantic colour on the value, the same four words as `Callout` and
|
|
38
|
+
* `Pill`. Left off, a figure is just a figure -- which is right for most
|
|
39
|
+
* of them, and a row where every tile is coloured says nothing. */
|
|
40
|
+
tone?: 'info' | 'good' | 'warn' | 'bad';
|
|
41
|
+
/** `tile` has a border and a panel behind it. `bare` is the figure alone,
|
|
42
|
+
* for a cell in something already bordered. */
|
|
43
|
+
look?: 'tile' | 'bare';
|
|
44
|
+
className?: string;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* One figure, with what it counts over it and what it means under it.
|
|
4
|
+
*
|
|
5
|
+
* **Drawn three times before it was a component.** valet's portal overview
|
|
6
|
+
* and its organisation page under `/admin` each had their own, and the same
|
|
7
|
+
* account's numbers rendered in two shapes on the two surfaces read as two
|
|
8
|
+
* products rather than as two views of one thing. Folding them into one local
|
|
9
|
+
* `Stat` fixed that inside valet and left the next app to rediscover it;
|
|
10
|
+
* valet's own docblock says so ("a candidate for the package, once a second
|
|
11
|
+
* product wants one"). The mail client's counts are the second product.
|
|
12
|
+
*
|
|
13
|
+
* **`tabular-nums`, and that is the reason this is not a `Card`.** Figures
|
|
14
|
+
* are read down a row, and proportional digits put the same magnitude in
|
|
15
|
+
* different places on every tile, so a column of numbers stops being
|
|
16
|
+
* comparable at a glance. A tile is not a card with a big number in it: it is
|
|
17
|
+
* a number with furniture, and the furniture exists to make the number
|
|
18
|
+
* legible next to the one beside it.
|
|
19
|
+
*
|
|
20
|
+
* **The label goes over the value.** A figure read before you know what it
|
|
21
|
+
* counts is a figure you read twice. This is the one place in the package
|
|
22
|
+
* where the caption leads.
|
|
23
|
+
*
|
|
24
|
+
* **`look="bare"` is the compact form**, for a figure inside a row that
|
|
25
|
+
* already has a border around it -- valet's `.v-figure`, in the admin table.
|
|
26
|
+
* A bordered tile inside a bordered row is two boxes saying one thing.
|
|
27
|
+
*/
|
|
28
|
+
export default function Stat({ label, value, note, tone, look = 'tile', className, }) {
|
|
29
|
+
const classes = ['stat', look === 'bare' ? 'stat-bare' : '', className].filter(Boolean).join(' ');
|
|
30
|
+
return (_jsxs("div", { className: classes, "data-tone": tone, children: [_jsx("p", { className: "stat-label", children: label }), _jsx("p", { className: "stat-value", children: value }), note && _jsx("p", { className: "stat-note", children: note })] }));
|
|
31
|
+
}
|
|
@@ -39,6 +39,27 @@ export interface Tab {
|
|
|
39
39
|
label: string;
|
|
40
40
|
/** Shown after the label, for a count or a state. */
|
|
41
41
|
badge?: string;
|
|
42
|
+
/**
|
|
43
|
+
* What the badge means, on hover and to a screen reader.
|
|
44
|
+
*
|
|
45
|
+
* A badge is one or two characters by design, and a mark that terse either
|
|
46
|
+
* explains itself or does not. valet had two on its admin strip that did
|
|
47
|
+
* not, and moved them out of the badge into a visible line rather than ship
|
|
48
|
+
* a glyph nobody could resolve -- which is a page working around a
|
|
49
|
+
* component, not a page making a choice.
|
|
50
|
+
*
|
|
51
|
+
* On `title` plus `aria-label`, not `title` alone: `title` never appears on
|
|
52
|
+
* a touch screen and is inconsistently announced, so the accessible name is
|
|
53
|
+
* set explicitly. The badge becomes a labelled `<abbr>`-shaped thing rather
|
|
54
|
+
* than decoration, which is what it always was.
|
|
55
|
+
*
|
|
56
|
+
* Additive, deliberately. Without this the badge's own text stays part of
|
|
57
|
+
* the tab's accessible name, which is right for the count case the prop
|
|
58
|
+
* above was written for -- "Keys 3" is a useful thing to hear. Hiding an
|
|
59
|
+
* untitled badge would have been the tidier rule and would have taken that
|
|
60
|
+
* count away from everyone already relying on it.
|
|
61
|
+
*/
|
|
62
|
+
badgeTitle?: string;
|
|
42
63
|
/** A line under the label, for a rail with room for one. Ignored in a
|
|
43
64
|
* horizontal strip, where there is none, and under a group, where the
|
|
44
65
|
* group's hint is the context. */
|
package/dist/components/Tabs.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. A
|
|
4
|
+
server component may still import it -- that is the point -- it simply
|
|
5
|
+
renders on the client. The ones without this line (Brand, Empty, Icon,
|
|
6
|
+
Illustration, Pill, Progress, Skeleton, Stat, Textarea, Table) render on
|
|
7
|
+
the server, which is why the directive is per component rather than one
|
|
8
|
+
line at the package's front door. */
|
|
2
9
|
/**
|
|
3
10
|
* One row of choices where only one is showing.
|
|
4
11
|
*
|
|
@@ -78,5 +85,5 @@ export default function Tabs({ tabs, value, onChange, label, orientation = 'hori
|
|
|
78
85
|
e.preventDefault();
|
|
79
86
|
move(tabs.length, -1);
|
|
80
87
|
}
|
|
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" })] }));
|
|
88
|
+
}, children: [_jsx("span", { className: "tab-label", children: t.label }), t.badge && (_jsx("span", { className: "tab-badge", title: t.badgeTitle, "aria-label": t.badgeTitle, 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
89
|
}
|
|
@@ -8,15 +8,25 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
8
8
|
* this component keeps it by rendering the element.
|
|
9
9
|
*
|
|
10
10
|
* Same contract as `Input`: a plain `<textarea>` so every element rule in the
|
|
11
|
-
* stylesheet keeps working, and its `id`, `aria-describedby` and
|
|
12
|
-
* come from `Field`
|
|
13
|
-
*
|
|
11
|
+
* stylesheet keeps working, and its `id`, `aria-describedby` and
|
|
12
|
+
* `aria-invalid` come from the `Field` above it -- handed in by a render
|
|
13
|
+
* prop, or read from context when the caller passed plain children. On its
|
|
14
|
+
* own it needs an `aria-label`, for the same reason `Input` does.
|
|
14
15
|
*/
|
|
15
16
|
import { forwardRef } from 'react';
|
|
17
|
+
import { useFieldWiring } from './fieldWiring.js';
|
|
16
18
|
const Textarea = forwardRef(function Textarea({ size = 'md', mono, className, ...rest }, ref) {
|
|
17
19
|
const classes = [size === 'md' ? '' : `size-${size}`, mono ? 'mono' : '', className ?? '']
|
|
18
20
|
.filter(Boolean)
|
|
19
21
|
.join(' ');
|
|
20
|
-
|
|
22
|
+
/* Explicit props win; this is the fallback. After `rest` in the spread,
|
|
23
|
+
because a key present with an undefined value still overwrites. */
|
|
24
|
+
const field = useFieldWiring();
|
|
25
|
+
const wired = {
|
|
26
|
+
id: rest.id ?? field?.id,
|
|
27
|
+
'aria-describedby': rest['aria-describedby'] ?? field?.['aria-describedby'],
|
|
28
|
+
'aria-invalid': rest['aria-invalid'] ?? field?.['aria-invalid'],
|
|
29
|
+
};
|
|
30
|
+
return _jsx("textarea", { ref: ref, className: classes || undefined, ...rest, ...wired });
|
|
21
31
|
});
|
|
22
32
|
export default Textarea;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Product } from '../products/index.js';
|
|
2
|
+
import type { BrandName } from './brandMarks.js';
|
|
3
|
+
export default function ThemeSwitch({ product, only, label, labelHidden, size, storageKey, onChange, className, }: {
|
|
4
|
+
/** Whose themes to offer. A registered name, or a `Product` from
|
|
5
|
+
* `defineProduct` for an app with its own palette. */
|
|
6
|
+
product: BrandName | Product;
|
|
7
|
+
/** A subset of the product's themes, in the order to show them. */
|
|
8
|
+
only?: readonly string[];
|
|
9
|
+
label?: string;
|
|
10
|
+
/** The label becomes the accessible name and is not drawn, for a header
|
|
11
|
+
* where the control's meaning is obvious from its contents. */
|
|
12
|
+
labelHidden?: boolean;
|
|
13
|
+
size?: 'sm' | 'md' | 'lg';
|
|
14
|
+
storageKey?: string;
|
|
15
|
+
/** For an app that has something else to update. The theme is already
|
|
16
|
+
* applied by the time this runs. */
|
|
17
|
+
onChange?: (theme: string) => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/* Client, because this module's own JSX attaches handlers or calls hooks. */
|
|
4
|
+
/**
|
|
5
|
+
* The control that changes the theme.
|
|
6
|
+
*
|
|
7
|
+
* All three apps have one and the first forty lines are the same in each: the
|
|
8
|
+
* guarded `localStorage` read, the guarded write, the assignment to
|
|
9
|
+
* `document.documentElement.dataset.theme`. What differed was the wrapper and
|
|
10
|
+
* the label -- one says "Appearance", one says "Theme" -- which is drift, not
|
|
11
|
+
* design.
|
|
12
|
+
*
|
|
13
|
+
* **The choice lives in the browser, not the account.** It is a preference
|
|
14
|
+
* about this screen rather than a fact about the person: the same account on
|
|
15
|
+
* a laptop in a bright room and a phone at night wants two answers. That is
|
|
16
|
+
* also why it needs no server round trip and no session.
|
|
17
|
+
*
|
|
18
|
+
* **The default is not stored.** A browser that never chose follows whatever
|
|
19
|
+
* the default becomes, so changing a product's default reaches everyone who
|
|
20
|
+
* never expressed an opinion, rather than only new visitors.
|
|
21
|
+
*
|
|
22
|
+
* **It writes `data-theme` and does not call `applyTheme`.** The palettes are
|
|
23
|
+
* already on the page as CSS from `productCss`; the attribute is the switch.
|
|
24
|
+
* `applyTheme` writes custom properties one by one and is for the case with
|
|
25
|
+
* no stylesheet to lean on.
|
|
26
|
+
*
|
|
27
|
+
* **`aria-labelledby`, or an `aria-label`, but never both and never neither.**
|
|
28
|
+
* The apps passed `aria-label="Appearance"` beside a visible "Appearance"
|
|
29
|
+
* span, which is the same string written twice -- exactly the drift `Field`
|
|
30
|
+
* exists to stop. Given `label`, this renders it and points the control at
|
|
31
|
+
* it; given `labelHidden`, the string becomes the accessible name and is not
|
|
32
|
+
* drawn.
|
|
33
|
+
*/
|
|
34
|
+
import { useEffect, useId, useState } from 'react';
|
|
35
|
+
import { THEME_STORAGE_KEY, storedTheme, themeChoices } from '../themes/choice.js';
|
|
36
|
+
import Select from './Select.js';
|
|
37
|
+
export default function ThemeSwitch({ product, only, label = 'Appearance', labelHidden = false, size = 'sm', storageKey = THEME_STORAGE_KEY, onChange, className, }) {
|
|
38
|
+
const choices = themeChoices(product, only);
|
|
39
|
+
const labelId = useId();
|
|
40
|
+
/* The server cannot know what this browser stored, so the first render has
|
|
41
|
+
to match what the server sent -- the attribute the blocking script wrote
|
|
42
|
+
is on `<html>` already, and reading `localStorage` during render would
|
|
43
|
+
be a hydration mismatch. The effect catches the control up. */
|
|
44
|
+
const [choice, setChoice] = useState(() => choices[0]?.id ?? '');
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setChoice(storedTheme(product, { only, storageKey }));
|
|
47
|
+
// The product object is rebuilt per render when passed inline; its
|
|
48
|
+
// identity is not the dependency, what it resolves to is.
|
|
49
|
+
}, [product, only, storageKey]);
|
|
50
|
+
const choose = (next) => {
|
|
51
|
+
if (!choices.some((c) => c.id === next))
|
|
52
|
+
return;
|
|
53
|
+
setChoice(next);
|
|
54
|
+
try {
|
|
55
|
+
localStorage.setItem(storageKey, next);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* A browser blocking site data still gets the theme, just not the
|
|
59
|
+
memory of it. Failing the change over the storage would be worse. */
|
|
60
|
+
}
|
|
61
|
+
document.documentElement.dataset.theme = next;
|
|
62
|
+
onChange?.(next);
|
|
63
|
+
};
|
|
64
|
+
return (_jsxs("div", { className: ['theme-switch', className].filter(Boolean).join(' '), children: [_jsx("span", { className: "theme-switch-label", id: labelId, hidden: labelHidden, children: label }), _jsx(Select, { "aria-labelledby": labelHidden ? undefined : labelId, "aria-label": labelHidden ? label : undefined, size: size, value: choice, onChange: (e) => choose(e.target.value), children: choices.map((c) => (_jsx("option", { value: c.id, children: c.label }, c.id))) })] }));
|
|
65
|
+
}
|