@wtfalch/design 0.6.0 → 0.8.0

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