@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.
Files changed (41) hide show
  1. package/README.md +18 -0
  2. package/dist/components/Button.js +8 -7
  3. package/dist/components/Callout.js +1 -1
  4. package/dist/components/Card.d.ts +1 -1
  5. package/dist/components/Card.js +21 -4
  6. package/dist/components/Command.js +2 -2
  7. package/dist/components/Empty.js +22 -1
  8. package/dist/components/Field.d.ts +22 -19
  9. package/dist/components/Field.js +11 -9
  10. package/dist/components/Input.d.ts +10 -0
  11. package/dist/components/Input.js +26 -5
  12. package/dist/components/Kbd.js +11 -1
  13. package/dist/components/Menu.js +3 -3
  14. package/dist/components/Modal.js +2 -2
  15. package/dist/components/Pagination.js +6 -1
  16. package/dist/components/Progress.js +8 -1
  17. package/dist/components/Rows.d.ts +1 -1
  18. package/dist/components/Rows.js +1 -1
  19. package/dist/components/Select.d.ts +3 -1
  20. package/dist/components/Select.js +28 -6
  21. package/dist/components/Shell.d.ts +35 -0
  22. package/dist/components/Shell.js +36 -0
  23. package/dist/components/SizeGrid.js +14 -2
  24. package/dist/components/Stat.js +11 -2
  25. package/dist/components/Table.js +7 -1
  26. package/dist/components/Textarea.js +14 -4
  27. package/dist/components/ThemeSwitch.d.ts +19 -0
  28. package/dist/components/ThemeSwitch.js +65 -0
  29. package/dist/components/Toast.js +1 -1
  30. package/dist/components/Tooltip.js +16 -2
  31. package/dist/components/Tour.js +1 -1
  32. package/dist/components/fieldWiring.d.ts +44 -0
  33. package/dist/components/fieldWiring.js +30 -0
  34. package/dist/index.d.ts +10 -1
  35. package/dist/index.js +8 -0
  36. package/dist/styles/index.css +1110 -355
  37. package/dist/tf.css +1110 -355
  38. package/dist/themes/choice.d.ts +71 -0
  39. package/dist/themes/choice.js +81 -0
  40. package/dist/valet.css +1110 -355
  41. 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
- /* The one class the button form does not carry. Every rule in the
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.
@@ -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 head = (title || description || action) && (_jsxs("div", { className: "card-head", children: [icon && (_jsx("span", { className: "card-icon", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: 20 }) })), _jsxs("div", { className: "card-headings", children: [title && _jsx("strong", { className: "card-title", children: title }), description && _jsx("div", { className: "card-desc", children: description })] }), action && _jsx("div", { className: "card-action", children: action })] }));
31
- const cls = `card${onClick ? ' card-pick' : ''}${selected ? ' card-on' : ''}${tone ? ` card-${tone}` : ''}${className ? ` ${className}` : ''}`;
32
- const body = children && _jsx("div", { className: "card-body", children: children });
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, { className: "cmd-group", 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: () => {
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, { className: "cmd-key", children: command.shortcut })] }, command.id)))] }, group.title))) }))] })) }) }) }));
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
  }
@@ -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: `nothing${illustration ? ' nothing-surface' : ''}${className ? ` ${className}` : ''}`, children: [illustration ? (_jsx(Illustration, { name: illustration, size: 160, className: "nothing-figure" })) : (icon && (_jsx("span", { className: "nothing-mark", "aria-hidden": "true", children: _jsx(Icon, { name: icon, size: 20 }) }))), _jsx("p", { className: "nothing-said", children: children }), action && _jsx("div", { className: "nothing-act", children: action })] }));
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
- export interface FieldWiring {
2
- id: string;
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
- children: (field: FieldWiring) => React.ReactNode;
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;
@@ -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
- 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 })), children({
47
- id,
48
- labelId,
49
- /* Both, in reading order, when both are there. A field that has a rule
50
- and has broken it needs to say the rule too -- "must be a URL" on its
51
- own does not tell you what shape of URL. */
52
- 'aria-describedby': [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined,
53
- 'aria-invalid': error ? true : undefined,
54
- }), error && (_jsx("p", { className: "field-error", id: errorId, role: "alert", children: error }))] }));
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>>;
@@ -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
- const Input = forwardRef(function Input({ size = 'md', mono, block, className, type = 'text', ...rest }, ref) {
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
- return _jsx("input", { ref: ref, type: type, className: classes || undefined, ...rest });
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 ghost 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: { sm: 14, md: 16, lg: 18 }[size] }) })] }));
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;
@@ -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
- return _jsx(Keyboard, { className: ['kbd', className].filter(Boolean).join(' '), children: children });
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
  }
@@ -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, { 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));
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);
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: "menu-list", "aria-label": label, children: items.map((entry) => isSection(entry) ? (_jsxs(MenuSection, { className: "menu-section", children: [_jsx(Header, { className: "menu-section-title", children: entry.title }), entry.items.map(renderItem)] }, entry.title)) : (renderItem(entry))) }) })] }));
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
  }
@@ -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: "modal-desc", children: description }), _jsx("div", { className: `modal-body${bodyClass ? ` ${bodyClass}` : ''}`, children: children }), footer && (_jsx("footer", { className: `set-actions${footerClass ? ` ${footerClass}` : ''}`, children: footer }))] }) }) }));
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: `pager${className ? ` ${className}` : ''}`, "aria-label": label, children: [_jsx("p", { className: "pager-count", 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: "pager-controls", 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' ? (
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 (_jsxs("div", { className: `progress${className ? ` ${className}` : ''}`, children: [(label || detail) && (_jsxs("div", { className: "progress-head", children: [_jsx("span", { className: "progress-label", children: label }), _jsx("span", { className: "grow" }), detail && _jsx("span", { className: "progress-detail mono", children: detail })] })), _jsx("div", { className: `bar${tone ? ` bar-${tone}` : ''}${indeterminate ? ' bar-indeterminate' : ''}`, role: "progressbar", "aria-label": label, "aria-valuenow": indeterminate ? undefined : Math.round(pct), "aria-valuemin": indeterminate ? undefined : 0, "aria-valuemax": indeterminate ? undefined : 100, "aria-valuetext": detail, children: _jsx("i", { style: indeterminate ? undefined : { width: `${pct}%` } }) })] }));
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
  }
@@ -5,7 +5,7 @@
5
5
  * hand:
6
6
  *
7
7
  * <div className="set-row">
8
- * <span className="grow" style={{ minWidth: 0 }}>
8
+ * <span className="ctl-grow" style={{ minWidth: 0 }}>
9
9
  * <span className="named">
10
10
  * <span className="truncate">{name}</span>
11
11
  * …pills…
@@ -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
- /* `title`, set on the element: React Aria's `Button` takes `id` and the
36
- `aria-*` labelling props and filters the rest, the same `filterDOMProps`
37
- that dropped `aria-busy` on `Button` and `aria-modal` on `Modal`. */
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": ariaLabelledBy, children: [_jsxs(Button, { ref: control, id: id, className: `sel-control${size ? ` size-${size}` : ''}`, children: [_jsx(SelectValue, { className: "sel-value", children: ({ selectedText, defaultChildren }) => selectedText ?? defaultChildren }), _jsx("svg", { className: "sel-caret", viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m6 9 6 6 6-6" }) })] }), _jsx(Popover, { className: "sel-list", placement: "bottom start", offset: 4, maxHeight: 280, children: _jsx(ListBox, { className: "sel-listbox", children: options.map((o) => (_jsxs(ListBoxItem, { id: o.value, textValue: o.text, isDisabled: o.disabled, className: "sel-item", children: [_jsx("span", { className: "sel-item-label", children: o.label }), _jsx("svg", { className: "sel-tick", viewBox: "0 0 24 24", width: "13", height: "13", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", d: "m5 13 4 4 10-10" }) }), aside?.(o.value)] }, o.value))) }) })] }));
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;