@wtfalch/design 0.7.0 → 0.9.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/README.md +18 -0
- package/dist/components/Button.js +8 -7
- package/dist/components/Callout.js +1 -1
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +21 -4
- package/dist/components/Command.js +2 -2
- package/dist/components/Empty.js +22 -1
- package/dist/components/Field.d.ts +22 -19
- package/dist/components/Field.js +11 -9
- package/dist/components/Input.d.ts +10 -0
- package/dist/components/Input.js +26 -5
- package/dist/components/Kbd.js +11 -1
- package/dist/components/Menu.js +3 -3
- package/dist/components/Modal.js +2 -2
- package/dist/components/Pagination.js +6 -1
- package/dist/components/Progress.js +8 -1
- package/dist/components/Rows.d.ts +1 -1
- package/dist/components/Rows.js +1 -1
- package/dist/components/Select.d.ts +3 -1
- package/dist/components/Select.js +28 -6
- package/dist/components/Shell.d.ts +35 -0
- package/dist/components/Shell.js +36 -0
- package/dist/components/SizeGrid.js +14 -2
- package/dist/components/Stat.js +11 -2
- package/dist/components/Table.js +7 -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.js +1 -1
- package/dist/components/Tooltip.js +16 -2
- package/dist/components/Tour.js +1 -1
- package/dist/components/fieldWiring.d.ts +44 -0
- package/dist/components/fieldWiring.js +30 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +8 -0
- package/dist/styles/index.css +1110 -355
- package/dist/tf.css +1110 -355
- package/dist/themes/choice.d.ts +71 -0
- package/dist/themes/choice.js +81 -0
- package/dist/valet.css +1110 -355
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -7,6 +7,24 @@ product can look like itself without forking the stylesheet.
|
|
|
7
7
|
pnpm add @wtfalch/design
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
## What is public
|
|
11
|
+
|
|
12
|
+
The components, and the tokens. Not the classes.
|
|
13
|
+
|
|
14
|
+
A class in this package is an implementation detail: `.card`, `.set-row`,
|
|
15
|
+
`.pill` and the rest may be renamed or deleted whenever the component that
|
|
16
|
+
draws them changes, and 0.9.0 did exactly that to several of them. If you
|
|
17
|
+
need a card, render `<Card>`; if you need a row of your own, write it in the
|
|
18
|
+
token vocabulary:
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
.app-row { display: flex; gap: var(--space-3); align-items: center; }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`tokens.css` ships separately for that, and every value a theme can change is
|
|
25
|
+
in it. An app that wants utilities should run Tailwind over its own source --
|
|
26
|
+
this package's stylesheet is compiled and does not need it.
|
|
27
|
+
|
|
10
28
|
```ts
|
|
11
29
|
import '@wtfalch/design/tokens.css' // the vocabulary and its base values
|
|
12
30
|
import '@wtfalch/design/styles.css' // the components
|
|
@@ -69,10 +69,16 @@ const blockActivation = (event) => {
|
|
|
69
69
|
export default function Button(props) {
|
|
70
70
|
const { kind = 'default', disabled, size = 'md', busy, block, iconOnly, className } = props;
|
|
71
71
|
const classes = [
|
|
72
|
+
/* Always, on both elements. Every rule that used to hang off the `button`
|
|
73
|
+
element now hangs off this class, so the styling belongs to this
|
|
74
|
+
component rather than to whatever happens to be a `<button>` -- which
|
|
75
|
+
is what let a tab, a row and a card inherit control styling and then
|
|
76
|
+
spend five declarations undoing it. */
|
|
77
|
+
'btn',
|
|
72
78
|
iconOnly ? 'icon-btn' : '',
|
|
73
79
|
kind === 'default' ? '' : kind,
|
|
74
80
|
size === 'md' ? '' : `size-${size}`,
|
|
75
|
-
block ? 'block' : '',
|
|
81
|
+
block ? 'ctl-block' : '',
|
|
76
82
|
className ?? '',
|
|
77
83
|
]
|
|
78
84
|
.filter(Boolean)
|
|
@@ -108,12 +114,7 @@ export default function Button(props) {
|
|
|
108
114
|
el.removeAttribute('aria-busy');
|
|
109
115
|
}, [busy]);
|
|
110
116
|
if (props.asChild) {
|
|
111
|
-
|
|
112
|
-
stylesheet is written against the `button` element, which an anchor is
|
|
113
|
-
not, so the slotted element needs a hook of its own. Adding it here
|
|
114
|
-
rather than to `classes` above keeps the button's own class list, and
|
|
115
|
-
therefore every committed baseline, exactly as it was. */
|
|
116
|
-
const slotClasses = ['btn', classes].filter(Boolean).join(' ');
|
|
117
|
+
const slotClasses = classes;
|
|
117
118
|
/* Narrowed by `props.asChild`, so what is left after the component's own
|
|
118
119
|
props is the DOM attribute set -- which is why an unknown `data-*`
|
|
119
120
|
reaches the child untouched. */
|
|
@@ -70,7 +70,7 @@ export default function Callout({ tone, children, icon, timed, onDismiss, classN
|
|
|
70
70
|
return (_jsxs("div", { className: `callout${tone ? ` callout-${tone}` : ''}${running ? ' callout-timed' : ''}${className ? ` ${className}` : ''}`,
|
|
71
71
|
/* An alert interrupts whatever is being read, which is right for bad news
|
|
72
72
|
and rude for "saved". */
|
|
73
|
-
role: tone === 'bad' ? 'alert' : 'status', onMouseEnter: () => running && setPaused(true), onMouseLeave: () => running && setPaused(false), onFocusCapture: () => running && setPaused(true), onBlurCapture: () => running && setPaused(false), children: [mark && (_jsx("span", { className: "callout-mark", "aria-hidden": "true", children: _jsx(Icon, { name: mark, size: 16 }) })), _jsx("div", { className: "grow", children: children }), running && (
|
|
73
|
+
role: tone === 'bad' ? 'alert' : 'status', onMouseEnter: () => running && setPaused(true), onMouseLeave: () => running && setPaused(false), onFocusCapture: () => running && setPaused(true), onBlurCapture: () => running && setPaused(false), children: [mark && (_jsx("span", { className: "callout-mark", "aria-hidden": "true", children: _jsx(Icon, { name: mark, size: 16 }) })), _jsx("div", { className: "ctl-grow", children: children }), running && (
|
|
74
74
|
/* Decoration: the time remaining is not information anybody can act on,
|
|
75
75
|
and a screen reader counting down a bar is noise on top of a message
|
|
76
76
|
it has already read out. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A box with a reason to exist.
|
|
3
3
|
*
|
|
4
4
|
* `.card` was CSS and nothing else, so every caller assembled its own header:
|
|
5
|
-
* a `<div className="row">`, a `<div className="grow">`, a title in a
|
|
5
|
+
* a `<div className="row">`, a `<div className="ctl-grow">`, a title in a
|
|
6
6
|
* `truncate` span and a description in a `muted mono` div, with the spacing
|
|
7
7
|
* retyped each time. Four variants of that shape existed and no two agreed on
|
|
8
8
|
* the gap between the title and the line under it.
|
package/dist/components/Card.js
CHANGED
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
* A box with a reason to exist.
|
|
11
11
|
*
|
|
12
12
|
* `.card` was CSS and nothing else, so every caller assembled its own header:
|
|
13
|
-
* a `<div className="row">`, a `<div className="grow">`, a title in a
|
|
13
|
+
* a `<div className="row">`, a `<div className="ctl-grow">`, a title in a
|
|
14
14
|
* `truncate` span and a description in a `muted mono` div, with the spacing
|
|
15
15
|
* retyped each time. Four variants of that shape existed and no two agreed on
|
|
16
16
|
* the gap between the title and the line under it.
|
|
@@ -27,9 +27,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
27
27
|
*/
|
|
28
28
|
import Icon from './Icon.js';
|
|
29
29
|
export default function Card({ icon, title, description, action, children, tone, onClick, selected, className, }) {
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const body = children && _jsx("div", { className: "grid gap-3", children: children });
|
|
31
|
+
/* `mb-3` when something follows, which is what `.card-head:not(:last-child)`
|
|
32
|
+
was saying. The component knows whether there is a body; a structural
|
|
33
|
+
pseudo-class was working that out from the DOM. */
|
|
34
|
+
const head = (title || description || action) && (_jsxs("div", { className: `card-head flex items-start gap-3${body ? ' mb-3' : ''}`, children: [icon && (_jsx("span", { className: "card-icon", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: 20 }) })), _jsxs("div", { className: "min-w-0 flex-1", children: [title && _jsx("strong", { className: "card-title", children: title }), description && _jsx("div", { className: "card-desc", children: description })] }), action && _jsx("div", { className: "flex-none self-center flex items-center", children: action })] }));
|
|
35
|
+
/* `card` stays as the hook: the tones, the pick and selected states, and
|
|
36
|
+
`:has(.card-icon)` indenting an inline row past the icon, are all either
|
|
37
|
+
a `color-mix` or a relationship between elements. What is here is the
|
|
38
|
+
card's own box -- and `display: block` with `width: 100%` matter, because
|
|
39
|
+
a pressable card is a `<button>`, which shrink-wraps: a row of cards
|
|
40
|
+
became a row of labels the moment one gained an `onClick`. */
|
|
41
|
+
const cls = [
|
|
42
|
+
'card',
|
|
43
|
+
onClick ? 'card-pick' : '',
|
|
44
|
+
selected ? 'card-on' : '',
|
|
45
|
+
tone ? `card-${tone}` : '',
|
|
46
|
+
className,
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' ');
|
|
33
50
|
if (onClick) {
|
|
34
51
|
return (_jsxs("button", { type: "button", className: cls, "aria-pressed": selected, onClick: onClick, children: [head, body] }));
|
|
35
52
|
}
|
|
@@ -34,11 +34,11 @@ export default function Command({ open, onOpenChange, groups, placeholder = 'Sea
|
|
|
34
34
|
}, isDismissable: true, children: _jsx(AriaModal, { className: `cmd${className ? ` ${className}` : ''}`, children: _jsx(Dialog, { className: "cmd-body", "aria-label": label, children: ({ close }) => (_jsxs(Autocomplete, { inputValue: query, onInputChange: setQuery, filter: () => true, children: [_jsxs(SearchField, { className: "cmd-field", "aria-label": label, autoFocus: true, children: [_jsx(Icon, { name: "chat", className: "cmd-mark" }), _jsx(Input, { className: "cmd-input", placeholder: placeholder })] }), empty ? (
|
|
35
35
|
/* The query, quoted. A palette that says "No results" to a
|
|
36
36
|
typo looks like a palette that is broken. */
|
|
37
|
-
_jsxs("p", { className: "cmd-empty", children: ["Nothing matches ", _jsx("strong", { children: query }), "."] })) : (_jsx(ListBox, { className: "cmd-list", "aria-label": label, selectionMode: "none", children: shown.map((group) => (_jsxs(ListBoxSection, {
|
|
37
|
+
_jsxs("p", { className: "cmd-empty", children: ["Nothing matches ", _jsx("strong", { children: query }), "."] })) : (_jsx(ListBox, { className: "cmd-list", "aria-label": label, selectionMode: "none", children: shown.map((group) => (_jsxs(ListBoxSection, { children: [_jsx(Header, { className: "cmd-group-title", children: group.title }), group.commands.map((command) => (_jsxs(ListBoxItem, { id: command.id, className: "cmd-item", textValue: command.label, isDisabled: command.disabled, onAction: () => {
|
|
38
38
|
command.onRun?.();
|
|
39
39
|
/* Closing is this component's job, not every
|
|
40
40
|
caller's. Forty `onRun`s that each remember to
|
|
41
41
|
close is thirty-nine chances to forget. */
|
|
42
42
|
close();
|
|
43
|
-
}, children: [command.icon && _jsx(Icon, { name: command.icon, className: "cmd-icon" }), _jsxs("span", { className: "cmd-text", children: [_jsx(Text, { slot: "label", className: "cmd-label", children: command.label }), command.description && (_jsx(Text, { slot: "description", className: "cmd-desc", children: command.description }))] }), command.shortcut && _jsx(Kbd, {
|
|
43
|
+
}, children: [command.icon && _jsx(Icon, { name: command.icon, className: "cmd-icon" }), _jsxs("span", { className: "cmd-text", children: [_jsx(Text, { slot: "label", className: "cmd-label", children: command.label }), command.description && (_jsx(Text, { slot: "description", className: "cmd-desc", children: command.description }))] }), command.shortcut && _jsx(Kbd, { children: command.shortcut })] }, command.id)))] }, group.title))) }))] })) }) }) }));
|
|
44
44
|
}
|
package/dist/components/Empty.js
CHANGED
|
@@ -31,5 +31,26 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
31
31
|
import Icon from './Icon.js';
|
|
32
32
|
import Illustration from './Illustration.js';
|
|
33
33
|
export default function Empty({ icon, illustration, children, action, className, }) {
|
|
34
|
-
return (_jsxs("div", { className:
|
|
34
|
+
return (_jsxs("div", { className: [
|
|
35
|
+
'nothing-surface-or-slot grid justify-items-center text-center rounded-md',
|
|
36
|
+
/* Dashed rather than solid: a solid border reads as a thing, and the
|
|
37
|
+
point of this box is that there is no thing -- it marks out where
|
|
38
|
+
the list will be. */
|
|
39
|
+
illustration
|
|
40
|
+
? /* A whole surface rather than a slot in a card. The outline goes,
|
|
41
|
+
because a window-sized dashed rectangle reads as a layout that
|
|
42
|
+
failed rather than a view with nothing on it, and the box
|
|
43
|
+
centres itself in whatever it was dropped into. */
|
|
44
|
+
'gap-3 py-10 px-4 m-auto'
|
|
45
|
+
: 'gap-2 py-5 px-4 border border-dashed border-border-strong',
|
|
46
|
+
className,
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' '), children: [illustration ? (
|
|
50
|
+
/* `currentColor` reaches the figure through the two rules the
|
|
51
|
+
illustrations ship with, so the colour is set here and the drawing
|
|
52
|
+
follows it -- the same grey as the sentence under it, in every
|
|
53
|
+
theme. `.illo` sets `color: inherit`, so this has to out-rank a
|
|
54
|
+
single class: `text-muted` on the element itself does. */
|
|
55
|
+
_jsx(Illustration, { name: illustration, size: 160, className: "nothing-figure" })) : (icon && (_jsx("span", { className: "text-muted leading-[0]", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: 20 }) }))), _jsx("p", { className: "m-0 text-muted text-sm leading-[1.5] max-w-[46ch]", children: children }), action && _jsx("div", { className: "mt-1", children: action })] }));
|
|
35
56
|
}
|
|
@@ -1,21 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
'aria-describedby': string | undefined;
|
|
4
|
-
'aria-invalid': boolean | undefined;
|
|
5
|
-
/** The label element's own id, for a control a `<label for>` cannot name.
|
|
6
|
-
*
|
|
7
|
-
* `htmlFor` is enough for an `<input>`, which is what almost every caller
|
|
8
|
-
* wraps. It is not enough for `Select`, or for anything else built on a
|
|
9
|
-
* `<button>`: a button takes its accessible name from its *contents*, and
|
|
10
|
-
* a `<label for>` pointing at one is ignored by the name computation. A
|
|
11
|
-
* caller that put a `Select` in a `Field` got a label on screen and a
|
|
12
|
-
* control announcing only its current value — and the workaround was an
|
|
13
|
-
* `aria-label` repeating the label string, which is two literals and two
|
|
14
|
-
* chances to drift apart. That drift is exactly the bug this component
|
|
15
|
-
* exists to prevent, so: pass this as `aria-labelledby` and there is one
|
|
16
|
-
* string in one place. */
|
|
17
|
-
labelId: string;
|
|
18
|
-
}
|
|
1
|
+
import { type FieldWiring } from './fieldWiring.js';
|
|
2
|
+
export type { FieldWiring } from './fieldWiring.js';
|
|
19
3
|
export default function Field({ label, hint, error, required, children, labelHidden, layout, className, }: {
|
|
20
4
|
/** What the field is. Always given -- there is no unlabelled case, only
|
|
21
5
|
* fields whose label is hidden. */
|
|
@@ -35,6 +19,25 @@ export default function Field({ label, hint, error, required, children, labelHid
|
|
|
35
19
|
* form to fill in. Same element, same wiring, same guarantees -- the only
|
|
36
20
|
* thing that changes is where the label sits. */
|
|
37
21
|
layout?: 'stack' | 'row';
|
|
38
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The control.
|
|
24
|
+
*
|
|
25
|
+
* **Elements, or a function.** Elements are the shape to reach for: a
|
|
26
|
+
* function cannot cross the server boundary, so a render prop made every
|
|
27
|
+
* page with a form a client component whether or not it needed to be, and
|
|
28
|
+
* all three apps' `design.ts` say so in the same sentence. Plain children
|
|
29
|
+
* read the wiring from context instead, and the package's own controls
|
|
30
|
+
* apply it when the caller has not named an `id`:
|
|
31
|
+
*
|
|
32
|
+
* <Field label="Region" hint="Cannot be changed later">
|
|
33
|
+
* <Select name="region" defaultValue="sg">…</Select>
|
|
34
|
+
* </Field>
|
|
35
|
+
*
|
|
36
|
+
* The function form stays, unchanged and not deprecated. It is still the
|
|
37
|
+
* answer for a control the package does not own, or for a caller that
|
|
38
|
+
* needs the ids for something else -- a `<datalist>` to point at, a label
|
|
39
|
+
* rendered somewhere the provider does not reach.
|
|
40
|
+
*/
|
|
41
|
+
children: React.ReactNode | ((field: FieldWiring) => React.ReactNode);
|
|
39
42
|
className?: string;
|
|
40
43
|
}): import("react").JSX.Element;
|
package/dist/components/Field.js
CHANGED
|
@@ -38,18 +38,20 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
38
38
|
* are breaking it.
|
|
39
39
|
*/
|
|
40
40
|
import { useId } from 'react';
|
|
41
|
+
import { FieldWiringContext } from './fieldWiring.js';
|
|
41
42
|
export default function Field({ label, hint, error, required, children, labelHidden, layout = 'stack', className, }) {
|
|
42
43
|
const id = useId();
|
|
43
44
|
const hintId = `${id}-hint`;
|
|
44
45
|
const errorId = `${id}-error`;
|
|
45
46
|
const labelId = `${id}-label`;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
const wiring = {
|
|
48
|
+
id,
|
|
49
|
+
labelId,
|
|
50
|
+
/* Both, in reading order, when both are there. A field that has a rule
|
|
51
|
+
and has broken it needs to say the rule too -- "must be a URL" on its
|
|
52
|
+
own does not tell you what shape of URL. */
|
|
53
|
+
'aria-describedby': [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined,
|
|
54
|
+
'aria-invalid': error ? true : undefined,
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs("div", { className: `field field-${layout}-layout${error ? ' field-bad' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { id: labelId, className: labelHidden ? 'sr-only' : 'field-label', htmlFor: id, children: [label, required && (_jsxs("span", { className: "field-required", "aria-label": "required", children: [' ', "*"] }))] }), hint && (_jsx("p", { className: "field-hint", id: hintId, children: hint })), typeof children === 'function' ? (children(wiring)) : (_jsx(FieldWiringContext.Provider, { value: wiring, children: children })), error && (_jsx("p", { className: "field-error", id: errorId, role: "alert", children: error }))] }));
|
|
55
57
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IconName } from './Icon.js';
|
|
1
2
|
export interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'className'> {
|
|
2
3
|
/** The same three every control takes, so a row of mixed controls lines up
|
|
3
4
|
* without anyone measuring. */
|
|
@@ -8,6 +9,15 @@ export interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>,
|
|
|
8
9
|
/** Full width of whatever holds it. Inputs already are, by default; this is
|
|
9
10
|
* for the `size`d ones, which are not. */
|
|
10
11
|
block?: boolean;
|
|
12
|
+
/** A glyph inside the left edge. Decorative: it is `aria-hidden`, because
|
|
13
|
+
* a magnifier beside a field called "Search" is the label said twice. */
|
|
14
|
+
icon?: IconName;
|
|
15
|
+
/** Empty it. Given, a clear button appears inside the right edge whenever
|
|
16
|
+
* the field has a value; the caller owns the value and does the clearing. */
|
|
17
|
+
onClear?: () => void;
|
|
18
|
+
/** Inside the right edge, before the clear button. A `Kbd` saying what
|
|
19
|
+
* opens this, which is what the mail client's `.mail-search-key` was. */
|
|
20
|
+
trailing?: React.ReactNode;
|
|
11
21
|
className?: string;
|
|
12
22
|
}
|
|
13
23
|
declare const Input: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLInputElement>>;
|
package/dist/components/Input.js
CHANGED
|
@@ -48,11 +48,22 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
48
48
|
import { forwardRef, useState } from 'react';
|
|
49
49
|
import { ToggleButton } from 'react-aria-components';
|
|
50
50
|
import Icon from './Icon.js';
|
|
51
|
-
|
|
51
|
+
import { useFieldWiring } from './fieldWiring.js';
|
|
52
|
+
const Input = forwardRef(function Input({ size = 'md', mono, block, className, type = 'text', icon, onClear, trailing, ...rest }, ref) {
|
|
53
|
+
/* What the `Field` above wired, when the caller did not thread it by hand.
|
|
54
|
+
Explicit props win: a caller that named an `id` meant that id, and a
|
|
55
|
+
render-prop `Field` spreading its wiring is passing the same values in
|
|
56
|
+
anyway. This is the fallback, not an override. */
|
|
57
|
+
const field = useFieldWiring();
|
|
58
|
+
const wired = {
|
|
59
|
+
id: rest.id ?? field?.id,
|
|
60
|
+
'aria-describedby': rest['aria-describedby'] ?? field?.['aria-describedby'],
|
|
61
|
+
'aria-invalid': rest['aria-invalid'] ?? field?.['aria-invalid'],
|
|
62
|
+
};
|
|
52
63
|
const classes = [
|
|
53
64
|
size === 'md' ? '' : `size-${size}`,
|
|
54
65
|
mono ? 'mono' : '',
|
|
55
|
-
block ? 'block' : '',
|
|
66
|
+
block ? 'ctl-block' : '',
|
|
56
67
|
className ?? '',
|
|
57
68
|
]
|
|
58
69
|
.filter(Boolean)
|
|
@@ -61,12 +72,22 @@ const Input = forwardRef(function Input({ size = 'md', mono, block, className, t
|
|
|
61
72
|
run time does not remount it -- a hook count that changes with a prop is a
|
|
62
73
|
React error, and a remount would drop the caret. */
|
|
63
74
|
const [shown, setShown] = useState(false);
|
|
75
|
+
const glyph = { sm: 14, md: 16, lg: 18 }[size];
|
|
64
76
|
if (type !== 'password') {
|
|
65
|
-
|
|
77
|
+
const box = (_jsx("input", { ref: ref, type: type, className: classes || undefined, ...rest, ...wired }));
|
|
78
|
+
if (!icon && !onClear && !trailing)
|
|
79
|
+
return box;
|
|
80
|
+
/* Something to clear, rather than something that could be cleared: a
|
|
81
|
+
clear button over an empty field is a control that does nothing. Both
|
|
82
|
+
shapes of value are checked because either may be the caller's. */
|
|
83
|
+
const filled = rest.value !== undefined
|
|
84
|
+
? String(rest.value).length > 0
|
|
85
|
+
: String(rest.defaultValue ?? '').length > 0;
|
|
86
|
+
return (_jsxs("span", { className: `adorned adorned-${size}${block ? ' ctl-block' : ''}${icon ? ' adorned-icon' : ''}`, children: [icon && _jsx(Icon, { name: icon, size: glyph, className: "adorned-mark", "aria-hidden": true }), box, (trailing || (onClear && filled)) && (_jsxs("span", { className: "adorned-end", children: [trailing, onClear && filled && (_jsx("button", { type: "button", className: "icon-btn adorned-clear", "aria-label": "Clear", onClick: onClear, disabled: rest.disabled, children: _jsx(Icon, { name: "close", size: glyph }) }))] }))] }));
|
|
66
87
|
}
|
|
67
|
-
return (_jsxs("span", { className: `secret secret-${size}${block ? ' block' : ''}`, children: [_jsx("input", { ref: ref, type: shown ? 'text' : 'password', className: classes || undefined,
|
|
88
|
+
return (_jsxs("span", { className: `secret secret-${size}${block ? ' ctl-block' : ''}`, children: [_jsx("input", { ref: ref, type: shown ? 'text' : 'password', className: classes || undefined,
|
|
68
89
|
/* Only while revealed: as a password these are moot, and setting them
|
|
69
90
|
on a password field makes some browsers stop offering to fill it. */
|
|
70
|
-
autoCapitalize: shown ? 'off' : undefined, autoCorrect: shown ? 'off' : undefined, spellCheck: shown ? false : undefined, ...rest }), _jsx(ToggleButton, { className: `icon-btn
|
|
91
|
+
autoCapitalize: shown ? 'off' : undefined, autoCorrect: shown ? 'off' : undefined, spellCheck: shown ? false : undefined, ...rest, ...wired }), _jsx(ToggleButton, { className: `icon-btn secret-eye${size === 'md' ? '' : ` size-${size}`}`, "aria-label": "Show password", isSelected: shown, onChange: setShown, isDisabled: rest.disabled, children: _jsx(Icon, { name: shown ? 'eye-off' : 'eye', size: glyph }) })] }));
|
|
71
92
|
});
|
|
72
93
|
export default Input;
|
package/dist/components/Kbd.js
CHANGED
|
@@ -23,6 +23,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
23
23
|
* carries the slot wiring that lets `Menu` and `Command` place it, so this is
|
|
24
24
|
* that component with the package's class on it rather than a new one.
|
|
25
25
|
*
|
|
26
|
+
* **Drawn with utilities.** Its five declarations were a `.kbd` rule that
|
|
27
|
+
* `Menu` and `Command` reached for by class name; they are on the element
|
|
28
|
+
* now, so the component carries its own appearance and nothing else has to
|
|
29
|
+
* know the class exists.
|
|
30
|
+
*
|
|
26
31
|
* **It draws the reminder; it does not bind anything.** Same contract as
|
|
27
32
|
* `Menu`'s `shortcut` and `Command`'s: what actually listens for the chord is
|
|
28
33
|
* the app's business, and a component that both drew a key and bound it would
|
|
@@ -30,5 +35,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
30
35
|
*/
|
|
31
36
|
import { Keyboard } from 'react-aria-components';
|
|
32
37
|
export default function Kbd({ children, className, }) {
|
|
33
|
-
|
|
38
|
+
/* Utilities rather than a `.kbd` rule. Five declarations, each of which
|
|
39
|
+
reads as what it is at the call site: it never shrinks in a flex row, it
|
|
40
|
+
is the theme's mono face at the smallest step, and it is quiet. */
|
|
41
|
+
return (_jsx(Keyboard, { className: ['flex-none font-mono text-2xs text-muted tracking-normal', className]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(' '), children: children }));
|
|
34
44
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -17,12 +17,12 @@ function renderItem(item) {
|
|
|
17
17
|
/* Typeahead needs a string, and `label` may be a node. Without this,
|
|
18
18
|
typing the first letter of an item whose label is markup matches
|
|
19
19
|
nothing and the menu looks broken. */
|
|
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, {
|
|
21
|
-
const body = item.items ? (_jsxs(SubmenuTrigger, { children: [row, _jsx(AriaPopover, { className: "menu-sheet", children: _jsx(AriaMenu, { className: "
|
|
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, { 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));
|
|
21
|
+
const body = item.items ? (_jsxs(SubmenuTrigger, { children: [row, _jsx(AriaPopover, { className: "menu-sheet surface-panel border border-border rounded-md min-w-[12rem] max-w-[min(20rem,calc(100vw-var(--space-6)))] max-h-[24rem] overflow-auto overscroll-contain z-[60]", children: _jsx(AriaMenu, { className: "p-1 outline-none grid gap-[var(--border-width)]", children: item.items.map(renderItem) }) })] }, item.id)) : (row);
|
|
22
22
|
if (!item.separated)
|
|
23
23
|
return body;
|
|
24
24
|
return [_jsx(Separator, { className: "menu-rule" }, `${item.id}-rule`), body];
|
|
25
25
|
}
|
|
26
26
|
export default function Menu({ trigger, items, placement = 'bottom start', label, className, }) {
|
|
27
|
-
return (_jsxs(MenuTrigger, { children: [trigger, _jsx(AriaPopover, { className: `menu-sheet${className ? ` ${className}` : ''}`, placement: placement, offset: 6, children: _jsx(AriaMenu, { className: "
|
|
27
|
+
return (_jsxs(MenuTrigger, { children: [trigger, _jsx(AriaPopover, { className: `menu-sheet${className ? ` ${className}` : ''}`, placement: placement, offset: 6, children: _jsx(AriaMenu, { className: "p-1 outline-none grid gap-[var(--border-width)]", "aria-label": label, children: items.map((entry) => isSection(entry) ? (_jsxs(MenuSection, { children: [_jsx(Header, { className: "menu-section-title", children: entry.title }), entry.items.map(renderItem)] }, entry.title)) : (renderItem(entry))) }) })] }));
|
|
28
28
|
}
|
package/dist/components/Modal.js
CHANGED
|
@@ -75,7 +75,7 @@ export default function Modal({ title, description, subtitle, head, children, fo
|
|
|
75
75
|
return (_jsx(ModalOverlay, { className: `backdrop${edge ? ` sheeted from-${edge}` : ''}`, isOpen: true, isDismissable: canClose && dismissOnScrim, isKeyboardDismissDisabled: !canClose, onOpenChange: (open) => {
|
|
76
76
|
if (!open && canClose)
|
|
77
77
|
onClose?.();
|
|
78
|
-
}, children: _jsx(AriaModal, { className: `modal${edge ? ` sheet from-${edge}` : ''}${className ? ` ${className}` : ''}`, style: width ? { width } : undefined, children: _jsxs(Dialog, { ref: dialogRef, className: "modal-dialog", "aria-labelledby": labelledBy ?? (title ? headingId : undefined), children: [(title || head || (onClose && closeButton)) && (_jsxs("header", { className: "modal-head", children: [title && (
|
|
78
|
+
}, children: _jsx(AriaModal, { className: `modal${edge ? ` sheet from-${edge}` : ''}${className ? ` ${className}` : ''}`, style: width ? { width } : undefined, children: _jsxs(Dialog, { ref: dialogRef, className: "modal-dialog flex flex-col min-h-0 flex-1", "aria-labelledby": labelledBy ?? (title ? headingId : undefined), children: [(title || head || (onClose && closeButton)) && (_jsxs("header", { className: "modal-head", children: [title && (
|
|
79
79
|
/* `Heading` is what React Aria labels the dialog by, and it
|
|
80
80
|
renders an `<h2>` -- which the browser gives its own margins
|
|
81
81
|
and its own size (17px above and below, 21px type). The old
|
|
@@ -83,5 +83,5 @@ export default function Modal({ title, description, subtitle, head, children, fo
|
|
|
83
83
|
takes them back off so the `<strong>` inside draws exactly as
|
|
84
84
|
it did; without it every window opened with its title
|
|
85
85
|
floating in a band twice the height of the head. */
|
|
86
|
-
_jsx(Heading, { slot: "title", id: headingId, level: 2, className: "modal-title", children: _jsx("strong", { children: title }) })), subtitle, head, onClose && closeButton && (_jsx(Button, { iconOnly: true, className: "x", "aria-label": "Close", isDisabled: closeDisabled, onPress: () => onClose(), children: _jsx(Icon, { name: "close", size: 16 }) }))] })), description && _jsx("p", { className: "
|
|
86
|
+
_jsx(Heading, { slot: "title", id: headingId, level: 2, className: "modal-title m-0 [font:inherit]", children: _jsx("strong", { children: title }) })), subtitle, head, onClose && closeButton && (_jsx(Button, { iconOnly: true, className: "x", "aria-label": "Close", isDisabled: closeDisabled, onPress: () => onClose(), children: _jsx(Icon, { name: "close", size: 16 }) }))] })), description && _jsx("p", { className: "m-0 px-4 pb-3 text-muted text-sm", children: description }), _jsx("div", { className: `modal-body${bodyClass ? ` ${bodyClass}` : ''}`, children: children }), footer && (_jsx("footer", { className: `set-actions${footerClass ? ` ${footerClass}` : ''}`, children: footer }))] }) }) }));
|
|
87
87
|
}
|
|
@@ -99,7 +99,12 @@ export default function Pagination({ position, limit, total, count, onChange, la
|
|
|
99
99
|
exactly once, on a list whose length is a multiple of the limit, and costs
|
|
100
100
|
one empty page rather than a hidden remainder. */
|
|
101
101
|
const canGoOn = pages === undefined ? shown >= limit : page < pages;
|
|
102
|
-
return (_jsxs("nav", { className:
|
|
102
|
+
return (_jsxs("nav", { className: [
|
|
103
|
+
'flex items-center justify-between gap-4 flex-wrap py-2 text-xs text-muted',
|
|
104
|
+
className,
|
|
105
|
+
]
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.join(' '), "aria-label": label, children: [_jsx("p", { className: "pager-count m-0", children: shown === 0 ? (`No ${unit}`) : (_jsxs(_Fragment, { children: [_jsxs("strong", { children: [group(first), "\u2013", group(last)] }), total === undefined ? ` ${unit}` : ` of ${group(total)} ${unit}`] })) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", className: "pager-step", onClick: () => onChange(Math.max(0, position - limit)), disabled: !canGoBack, "aria-label": "Previous page", children: _jsx(Chevron, { back: true }) }), pages !== undefined && (_jsx("ol", { className: "pager-pages", children: pageWindow(page, pages).map((slot, index, slots) => slot === 'gap' ? (
|
|
103
108
|
/* Keyed by the page it follows rather than by its index: a
|
|
104
109
|
window holds at most two gaps and they elide different runs,
|
|
105
110
|
and an index key makes React reuse the wrong one -- which
|
|
@@ -23,5 +23,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
23
23
|
export default function Progress({ value, max, label, detail, tone, className, }) {
|
|
24
24
|
const indeterminate = value === undefined || !max;
|
|
25
25
|
const pct = indeterminate ? 0 : Math.max(0, Math.min(100, (value / max) * 100));
|
|
26
|
-
return (
|
|
26
|
+
return (
|
|
27
|
+
/* `progress` stays as a hook: `card.css` spaces a bar that follows a
|
|
28
|
+
card's contents, which is a relationship between two elements. The rest
|
|
29
|
+
is this component's own shape and reads better here. */
|
|
30
|
+
_jsxs("div", { className: ['progress grid gap-1', className].filter(Boolean).join(' '), children: [(label || detail) && (
|
|
31
|
+
/* Baseline, not centre: the label and the detail are one line of text
|
|
32
|
+
with two weights, and centring them puts the smaller one adrift. */
|
|
33
|
+
_jsxs("div", { className: "flex items-baseline gap-2 text-sm", children: [_jsx("span", { children: label }), _jsx("span", { className: "ctl-grow" }), detail && _jsx("span", { className: "text-muted text-xs font-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
34
|
}
|
package/dist/components/Rows.js
CHANGED
|
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
13
13
|
* hand:
|
|
14
14
|
*
|
|
15
15
|
* <div className="set-row">
|
|
16
|
-
* <span className="grow" style={{ minWidth: 0 }}>
|
|
16
|
+
* <span className="ctl-grow" style={{ minWidth: 0 }}>
|
|
17
17
|
* <span className="named">
|
|
18
18
|
* <span className="truncate">{name}</span>
|
|
19
19
|
* …pills…
|
|
@@ -12,6 +12,8 @@ interface Props {
|
|
|
12
12
|
'aria-label'?: string;
|
|
13
13
|
'aria-labelledby'?: string;
|
|
14
14
|
disabled?: boolean;
|
|
15
|
+
'aria-describedby'?: string;
|
|
16
|
+
'aria-invalid'?: boolean;
|
|
15
17
|
/** What the form submits this under. Without it there is nothing to post,
|
|
16
18
|
* which is what sent callers back to a mirrored hidden input. */
|
|
17
19
|
name?: string;
|
|
@@ -29,5 +31,5 @@ interface Props {
|
|
|
29
31
|
/** Something to the right of an option -- a play button beside a voice. */
|
|
30
32
|
aside?: (value: string) => ReactNode;
|
|
31
33
|
}
|
|
32
|
-
export default function Select({ block, size, className, children, value, defaultValue, onChange, disabled, name, form, 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;
|
|
33
35
|
export {};
|
|
@@ -29,13 +29,26 @@ function readOptions(children) {
|
|
|
29
29
|
free when that element was a `<select>` and is wrong now that it is a
|
|
30
30
|
`<button>` -- `onCopy` alone is typed against a different element. Call sites
|
|
31
31
|
pass five things between them, so five is what this takes. */
|
|
32
|
+
import { useFieldWiring } from './fieldWiring.js';
|
|
32
33
|
import { Select as AriaSelect, Button, ListBox, ListBoxItem, Popover, SelectValue, } from 'react-aria-components';
|
|
33
|
-
export default function Select({ block = false, size, className, children, value, defaultValue, onChange, disabled, name, form, 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, }) {
|
|
34
35
|
const options = readOptions(children);
|
|
35
|
-
/* `
|
|
36
|
-
`
|
|
37
|
-
|
|
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. */
|
|
38
50
|
const control = useRef(null);
|
|
51
|
+
const invalid = ariaInvalid ?? field?.['aria-invalid'];
|
|
39
52
|
useEffect(() => {
|
|
40
53
|
const el = control.current;
|
|
41
54
|
if (!el)
|
|
@@ -45,6 +58,15 @@ export default function Select({ block = false, size, className, children, value
|
|
|
45
58
|
else
|
|
46
59
|
el.removeAttribute('title');
|
|
47
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]);
|
|
48
70
|
/* React Aria owns what was 13 KB of hand-rolled behaviour: the popover is
|
|
49
71
|
positioned against the button and flips when the edge is near, which the
|
|
50
72
|
measured-rectangle `top`/`left` could not; typeahead, Home and End, the
|
|
@@ -56,8 +78,8 @@ export default function Select({ block = false, size, className, children, value
|
|
|
56
78
|
`sel-value` and the caret are the same markup as before, so the closed
|
|
57
79
|
control is pixel-identical; the list is the same markup with React Aria's
|
|
58
80
|
state attributes where the classes were. */
|
|
59
|
-
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) => {
|
|
81
|
+
return (_jsxs(AriaSelect, { className: `sel${block ? ' ctl-block' : ''}${className ? ` ${className}` : ''}`, selectedKey: value !== undefined ? String(value) : undefined, defaultSelectedKey: defaultValue !== undefined ? String(defaultValue) : (options[0]?.value ?? undefined), onSelectionChange: (key) => {
|
|
60
82
|
if (key !== null)
|
|
61
83
|
onChange?.({ target: { value: String(key) } });
|
|
62
|
-
}, isDisabled: disabled, name: name, form: form, "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 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap", children: ({ selectedText, defaultChildren }) => selectedText ?? defaultChildren }), _jsx("svg", { className: "sel-caret flex-none opacity-60", 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 z-[60] max-h-[280px] overflow-y-auto m-0 p-1 list-none border border-border-strong rounded-md surface-panel", placement: "bottom start", offset: 4, maxHeight: 280, children: _jsx(ListBox, { children: options.map((o) => (_jsxs(ListBoxItem, { id: o.value, textValue: o.text, isDisabled: o.disabled, className: "sel-item flex items-center gap-2 p-2 rounded-sm cursor-pointer whitespace-nowrap", children: [_jsx("span", { className: "flex-1 min-w-0 overflow-hidden text-ellipsis", children: o.label }), _jsx("svg", { className: "sel-tick flex-none opacity-0", 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))) }) })] }));
|
|
63
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;
|