@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
@@ -0,0 +1,36 @@
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
+ /* Utilities: this component is layout and nothing else, which is where they
26
+ earn their place. The measure is the one thing that is not a utility --
27
+ it is a number two elements have to agree on, so it is a custom property
28
+ on the frame rather than a literal repeated in three class lists, which
29
+ is how manage ended up with `head-inner-wide` and `main-wide` as separate
30
+ classes and had to remember both. */
31
+ const measure = wide ? 'max-w-[var(--shell-measure-wide)]' : 'max-w-[var(--shell-measure)]';
32
+ const band = `w-full mx-auto px-4 ${measure}`;
33
+ return (_jsxs("div", { className: ['shell flex flex-col min-h-screen surface-bg text-text', className]
34
+ .filter(Boolean)
35
+ .join(' '), children: [_jsxs("header", { className: "flex-none border-b border-border surface-panel", children: [_jsxs("div", { className: `${band} flex items-center justify-between gap-4 py-3 min-w-0`, children: [brand, who && _jsx("div", { className: "flex items-center gap-3 min-w-0", children: who })] }), nav && _jsx("div", { className: `${band} pb-2`, children: nav })] }), _jsx("main", { className: `${band} flex-1 py-6 min-w-0`, children: children })] }));
36
+ }
@@ -41,8 +41,20 @@ export default function SizeGrid({ value, max = { cols: 3, rows: 3 }, disabled =
41
41
  for (let col = 1; col <= extent.cols; col++) {
42
42
  const on = col <= shown.cols && row <= shown.rows;
43
43
  const chosen = col <= value.cols && row <= value.rows;
44
- cells.push(_jsx("button", { type: "button", className: `size-cell${on ? ' on' : ''}${chosen && !hover ? ' chosen' : ''}`, disabled: disabled, "aria-label": `${col} by ${row}`, onMouseEnter: () => !disabled && setHover({ cols: col, rows: row }), onFocus: () => !disabled && setHover({ cols: col, rows: row }), onClick: () => onChange({ cols: col, rows: row }) }, `${col}x${row}`));
44
+ cells.push(_jsx("button", { type: "button",
45
+ /* `size-cell` stays as the hook for the two states: `on` is a fill
46
+ from `color-mix`, which is not a utility. The rest -- a painted
47
+ cell with no contents, so it says it is a block rather than
48
+ taking a control's arrangement -- is here. */
49
+ className: [
50
+ 'size-cell block w-[18px] h-[14px] p-0 rounded-sm border border-border surface-panel-2 cursor-pointer',
51
+ 'disabled:cursor-default disabled:opacity-50',
52
+ on ? 'on' : '',
53
+ chosen && !hover ? 'border-accent' : '',
54
+ ]
55
+ .filter(Boolean)
56
+ .join(' '), disabled: disabled, "aria-label": `${col} by ${row}`, onMouseEnter: () => !disabled && setHover({ cols: col, rows: row }), onFocus: () => !disabled && setHover({ cols: col, rows: row }), onClick: () => onChange({ cols: col, rows: row }) }, `${col}x${row}`));
45
57
  }
46
58
  }
47
- return (_jsxs("div", { className: "size-grid-wrap", children: [_jsx("div", { className: "size-grid", style: { gridTemplateColumns: `repeat(${extent.cols}, 1fr)` }, onMouseLeave: () => setHover(null), children: cells }), _jsxs("span", { className: "set-hint mono", children: [shown.cols, " \u00D7 ", shown.rows, (extent.cols === max.cols || extent.rows === max.rows) && (_jsxs("span", { className: "size-ceiling", children: [' ', "\u00B7 max ", max.cols, "\u00D7", max.rows] }))] })] }));
59
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "grid gap-1", style: { gridTemplateColumns: `repeat(${extent.cols}, 1fr)` }, onMouseLeave: () => setHover(null), children: cells }), _jsxs("span", { className: "set-hint mono", children: [shown.cols, " \u00D7 ", shown.rows, (extent.cols === max.cols || extent.rows === max.rows) && (_jsxs("span", { className: "opacity-[0.55]", children: [' ', "\u00B7 max ", max.cols, "\u00D7", max.rows] }))] })] }));
48
60
  }
@@ -26,6 +26,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
26
26
  * A bordered tile inside a bordered row is two boxes saying one thing.
27
27
  */
28
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 })] }));
29
+ /* Utilities, so the tile's shape reads where it is drawn. `bare` states
30
+ the compact shape rather than adding a class that undoes the tile's --
31
+ which is the mistake the old element reset made across this package.
32
+ `stat` and `stat-value` stay as hooks: the tone is an attribute on the
33
+ tile colouring a child, which is a relationship, and a utility is a
34
+ property on one element. */
35
+ const tile = look === 'bare'
36
+ ? 'grid gap-0 leading-[1.25]'
37
+ : 'grid gap-1 p-4 border border-border rounded-md surface-panel';
38
+ const classes = ['stat', tile, '[&>p]:m-0', className].filter(Boolean).join(' ');
39
+ return (_jsxs("div", { className: classes, "data-tone": tone, children: [_jsx("p", { className: "text-xs text-muted", children: label }), _jsx("p", { className: `stat-value font-strong tabular-nums ${look === 'bare' ? 'text-sm' : 'text-lg'}`, children: value }), note && _jsx("p", { className: "text-xs text-muted", children: note })] }));
31
40
  }
@@ -3,7 +3,13 @@ export default function Table({ caption, columns, rows, keyOf, empty, className,
3
3
  if (!rows.length) {
4
4
  return empty ? _jsx("div", { className: "set-hint rows-empty", children: empty }) : null;
5
5
  }
6
- return (_jsx("div", { className: `table-scroll${className ? ` ${className}` : ''}`, children: _jsxs("table", { className: "table", children: [_jsx("caption", { className: "sr-only", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { scope: "col", style: { width: c.width, textAlign: c.align === 'end' ? 'right' : undefined }, children: _jsx("span", { className: c.quiet ? 'sr-only' : undefined, children: c.header }) }, c.header))) }) }), _jsx("tbody", { children: rows.map((item) => (_jsx("tr", { children: columns.map((c, i) => {
6
+ return (
7
+ /* The one thing about a table that is a property of one element. Its
8
+ cells, headings and last-row rule are structural -- `th:first-child`,
9
+ `tbody tr:last-child td` -- and a structural pseudo-class is the right
10
+ tool for them: expressing the same thing with utilities means this
11
+ component doing index arithmetic to work out what CSS already knows. */
12
+ _jsx("div", { className: ['overflow-x-auto', className].filter(Boolean).join(' '), children: _jsxs("table", { className: "data-table", children: [_jsx("caption", { className: "sr-only", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { scope: "col", style: { width: c.width, textAlign: c.align === 'end' ? 'right' : undefined }, children: _jsx("span", { className: c.quiet ? 'sr-only' : undefined, children: c.header }) }, c.header))) }) }), _jsx("tbody", { children: rows.map((item) => (_jsx("tr", { children: columns.map((c, i) => {
7
13
  /* The first cell is the row's name, so it is a `<th scope="row">`
8
14
  -- that is what lets a reader ask "which row am I in" and get
9
15
  an answer instead of a cell index. */
@@ -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
+ }
@@ -115,5 +115,5 @@ export function ToastHost({ children }) {
115
115
  React error. */
116
116
  if (outer)
117
117
  return _jsx(_Fragment, { children: children });
118
- return (_jsxs(Ctx.Provider, { value: push, children: [children, _jsx("div", { className: "sr-only", "aria-live": "polite", "aria-atomic": "true", children: polite }), _jsx("div", { className: "sr-only", "aria-live": "assertive", "aria-atomic": "true", children: assertive }), _jsx(ToastRegion, { queue: queue, className: "toasts", children: ({ toast }) => (_jsxs(AriaToast, { toast: toast, className: `toast toast-${toast.content.tone}`, children: [_jsx(Icon, { name: MARK[toast.content.tone], size: 16, className: "toast-mark" }), _jsx(ToastContent, { className: "grow", children: _jsx(Text, { slot: "title", children: toast.content.text }) }), _jsx(Button, { slot: "close", className: "ghost size-sm", "aria-label": "Dismiss", children: _jsx(Icon, { name: "close", size: 14 }) })] })) })] }));
118
+ return (_jsxs(Ctx.Provider, { value: push, children: [children, _jsx("div", { className: "sr-only", "aria-live": "polite", "aria-atomic": "true", children: polite }), _jsx("div", { className: "sr-only", "aria-live": "assertive", "aria-atomic": "true", children: assertive }), _jsx(ToastRegion, { queue: queue, className: "toasts", children: ({ toast }) => (_jsxs(AriaToast, { toast: toast, className: `toast toast-${toast.content.tone}`, children: [_jsx(Icon, { name: MARK[toast.content.tone], size: 16, className: "toast-mark" }), _jsx(ToastContent, { className: "ctl-grow", children: _jsx(Text, { slot: "title", children: toast.content.text }) }), _jsx(Button, { slot: "close", className: "toast-close", "aria-label": "Dismiss", children: _jsx(Icon, { name: "close", size: 14 }) })] })) })] }));
119
119
  }
@@ -58,12 +58,26 @@ export default function Tooltip({ label, align = 'right', mark, className, child
58
58
  is also why `overflow: hidden` on any ancestor no longer clips it -- the
59
59
  Permissions pane's horizontal scrollbar, found by hiding one class at a
60
60
  time, was this box sitting in the scrollable overflow. */
61
- return (_jsxs(TooltipTrigger, { delay: 0, closeDelay: 150, children: [_jsx(Focusable, { children: _jsx("span", { className: `explain explain-${align}${className ? ` ${className}` : ''}`,
61
+ return (_jsxs(TooltipTrigger, { delay: 0, closeDelay: 150, children: [_jsx(Focusable, { children: _jsx("span", {
62
+ /* `explain` stays as the hook: the mark brightens on hover and on
63
+ focus of this element, which is a parent state reaching a child.
64
+ The rest is this element's own. `text-transform` and
65
+ `letter-spacing` are reset because one of these sits inside a
66
+ heading, and headings here are uppercase and tracked out. */
67
+ className: [
68
+ 'explain',
69
+ `explain-${align}`,
70
+ 'relative flex-none inline-flex items-center ml-2 cursor-help',
71
+ 'normal-case tracking-normal font-normal',
72
+ className,
73
+ ]
74
+ .filter(Boolean)
75
+ .join(' '),
62
76
  /* A role, because a name on a role-less span is prohibited -- axe's
63
77
  `aria-prohibited-attr`, found the first time this was scanned. `img`
64
78
  rather than `button`: the mark is a glyph that reveals help, not a
65
79
  control that does something, and `button` would promise an action
66
80
  and -- inside the `<label>` rows these sit in -- invite a click that
67
81
  the label forwards to the setting behind it. */
68
- role: "img", "aria-label": mark ? label : `About ${label}`, children: mark ?? (_jsx("span", { className: "explain-mark", "aria-hidden": "true", children: "?" })) }) }), _jsx(AriaTooltip, { className: "explain-tip", placement: align === 'left' ? 'bottom end' : 'bottom start', offset: 8, crossOffset: align === 'left' ? 6 : -6, children: children })] }));
82
+ role: "img", "aria-label": mark ? label : `About ${label}`, children: mark ?? (_jsx("span", { className: "explain-mark w-[18px] h-[18px] rounded-full border border-border text-muted text-xs leading-[16px] text-center", "aria-hidden": "true", children: "?" })) }) }), _jsx(AriaTooltip, { className: "explain-tip", placement: align === 'left' ? 'bottom end' : 'bottom start', offset: 8, crossOffset: align === 'left' ? 6 : -6, children: children })] }));
69
83
  }
@@ -111,5 +111,5 @@ export default function Tour({ stops, onDone, storageKey, }) {
111
111
  height: box.height + 12,
112
112
  } }), _jsxs("div", { ref: card, className: "tour-card", style: style,
113
113
  // biome-ignore lint/a11y/useSemanticElements: a <dialog> brings the top layer, ::backdrop and showModal() focus semantics; this is an anchored coach-mark card that positions itself and manages its own focus, so the role is the faithful choice.
114
- role: "dialog", "aria-modal": "true", "aria-labelledby": "tour-title", tabIndex: -1, children: [_jsx("strong", { id: "tour-title", className: "tour-title", children: stop.title }), _jsx("p", { className: "tour-body", children: stop.body }), _jsxs("div", { className: "tour-actions", children: [_jsxs("span", { className: "tour-count mono", children: [at + 1, " of ", live.length] }), _jsx("span", { className: "grow" }), _jsx(Button, { kind: "ghost", size: "sm", onPress: finish, children: "Skip" }), _jsx(Button, { kind: "primary", size: "sm", onPress: next, children: at + 1 < live.length ? 'Next' : 'Done' })] })] })] }), document.body);
114
+ role: "dialog", "aria-modal": "true", "aria-labelledby": "tour-title", tabIndex: -1, children: [_jsx("strong", { id: "tour-title", className: "tour-title", children: stop.title }), _jsx("p", { className: "tour-body", children: stop.body }), _jsxs("div", { className: "tour-actions", children: [_jsxs("span", { className: "tour-count mono", children: [at + 1, " of ", live.length] }), _jsx("span", { className: "ctl-grow" }), _jsx(Button, { kind: "ghost", size: "sm", onPress: finish, children: "Skip" }), _jsx(Button, { kind: "primary", size: "sm", onPress: next, children: at + 1 < live.length ? 'Next' : 'Done' })] })] })] }), document.body);
115
115
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What a `Field` tells the control inside it.
3
+ *
4
+ * Its own module for the reason `iconNames.ts` and `tourMarker.ts` are:
5
+ * `fastRefresh.test.ts` holds every component module to exporting its
6
+ * component and nothing else, because a module exporting a value beside a
7
+ * component loses its Fast Refresh boundary and editing it re-runs every
8
+ * importer instead of swapping the component in place.
9
+ *
10
+ * The context exists so `Field`'s children can be elements rather than a
11
+ * function. A function cannot cross the server boundary, so the render prop
12
+ * made every page with a form a client component whether or not it needed to
13
+ * be -- which is the line all three consuming apps have in their
14
+ * `design.ts`.
15
+ */
16
+ export interface FieldWiring {
17
+ id: string;
18
+ 'aria-describedby': string | undefined;
19
+ 'aria-invalid': boolean | undefined;
20
+ /** The label element's own id, for a control a `<label for>` cannot name.
21
+ *
22
+ * `htmlFor` is enough for an `<input>`, which is what almost every caller
23
+ * wraps. It is not enough for `Select`, or for anything else built on a
24
+ * `<button>`: a button takes its accessible name from its *contents*, and
25
+ * a `<label for>` pointing at one is ignored by the name computation. A
26
+ * caller that put a `Select` in a `Field` got a label on screen and a
27
+ * control announcing only its current value — and the workaround was an
28
+ * `aria-label` repeating the label string, which is two literals and two
29
+ * chances to drift apart. That drift is exactly the bug `Field` exists to
30
+ * prevent, so: pass this as `aria-labelledby` and there is one string. */
31
+ labelId: string;
32
+ }
33
+ /** `null` outside a `Field`, so a control can tell "no field around me" from
34
+ * "a field that wired me with nothing". */
35
+ export declare const FieldWiringContext: import("react").Context<FieldWiring | null>;
36
+ /**
37
+ * What the `Field` above this control wired, if there is one.
38
+ *
39
+ * For the package's own controls, which apply it when the caller has not
40
+ * named an `id` themselves. A consumer building its own control can use it
41
+ * for the same reason: it is how a control gets a label without the caller
42
+ * threading four attributes by hand.
43
+ */
44
+ export declare function useFieldWiring(): FieldWiring | null;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What a `Field` tells the control inside it.
3
+ *
4
+ * Its own module for the reason `iconNames.ts` and `tourMarker.ts` are:
5
+ * `fastRefresh.test.ts` holds every component module to exporting its
6
+ * component and nothing else, because a module exporting a value beside a
7
+ * component loses its Fast Refresh boundary and editing it re-runs every
8
+ * importer instead of swapping the component in place.
9
+ *
10
+ * The context exists so `Field`'s children can be elements rather than a
11
+ * function. A function cannot cross the server boundary, so the render prop
12
+ * made every page with a form a client component whether or not it needed to
13
+ * be -- which is the line all three consuming apps have in their
14
+ * `design.ts`.
15
+ */
16
+ import { createContext, useContext } from 'react';
17
+ /** `null` outside a `Field`, so a control can tell "no field around me" from
18
+ * "a field that wired me with nothing". */
19
+ export const FieldWiringContext = createContext(null);
20
+ /**
21
+ * What the `Field` above this control wired, if there is one.
22
+ *
23
+ * For the package's own controls, which apply it when the caller has not
24
+ * named an `id` themselves. A consumer building its own control can use it
25
+ * for the same reason: it is how a control gets a label without the caller
26
+ * threading four attributes by hand.
27
+ */
28
+ export function useFieldWiring() {
29
+ return useContext(FieldWiringContext);
30
+ }
package/dist/index.d.ts CHANGED
@@ -36,7 +36,10 @@ export { DangerAction, default as DangerZone } from './components/DangerZone.js'
36
36
  export { default as Dialog } from './components/Dialog.js';
37
37
  export { default as Empty } from './components/Empty.js';
38
38
  export { default as Field } from './components/Field.js';
39
- export type { FieldWiring } from './components/Field.js';
39
+ /** The wiring a `Field` hands its control, and the hook that reads it —
40
+ * its own module, so `Field.tsx` keeps its Fast Refresh boundary. */
41
+ export { useFieldWiring } from './components/fieldWiring.js';
42
+ export type { FieldWiring } from './components/fieldWiring.js';
40
43
  export { default as Icon } from './components/Icon.js';
41
44
  /** A person in one line -- initials, name, address -- and the two pure
42
45
  * functions behind the disc, exported so a caller colouring something else
@@ -72,6 +75,8 @@ export { default as ScrollArea } from './components/ScrollArea.js';
72
75
  export type { Props as ScrollAreaProps } from './components/ScrollArea.js';
73
76
  export { default as Select } from './components/Select.js';
74
77
  export { default as SizeGrid } from './components/SizeGrid.js';
78
+ /** The frame a signed-in surface stands in. Gates nothing, on purpose. */
79
+ export { default as Shell } from './components/Shell.js';
75
80
  export { default as Skeleton } from './components/Skeleton.js';
76
81
  /** One figure, with what it counts over it. Three apps drew this before it
77
82
  * was here. */
@@ -86,6 +91,10 @@ export type { Column } from './components/Table.js';
86
91
  export { default as Tabs } from './components/Tabs.js';
87
92
  export type { Tab, TabGroup } from './components/Tabs.js';
88
93
  export { default as Textarea } from './components/Textarea.js';
94
+ /** The theme control, and the blocking script that beats it to the paint. */
95
+ export { default as ThemeSwitch } from './components/ThemeSwitch.js';
96
+ export { THEME_STORAGE_KEY, themeChoiceScript, themeChoices, storedTheme } from './themes/choice.js';
97
+ export type { ThemeChoice } from './themes/choice.js';
89
98
  export type { Props as TextareaProps } from './components/Textarea.js';
90
99
  /** `ToastHost` nests: one inside another renders through, so a package can
91
100
  * wrap itself and still leave an application with a single region. */
package/dist/index.js CHANGED
@@ -33,6 +33,9 @@ export { DangerAction, default as DangerZone } from './components/DangerZone.js'
33
33
  export { default as Dialog } from './components/Dialog.js';
34
34
  export { default as Empty } from './components/Empty.js';
35
35
  export { default as Field } from './components/Field.js';
36
+ /** The wiring a `Field` hands its control, and the hook that reads it —
37
+ * its own module, so `Field.tsx` keeps its Fast Refresh boundary. */
38
+ export { useFieldWiring } from './components/fieldWiring.js';
36
39
  export { default as Icon } from './components/Icon.js';
37
40
  /** A person in one line -- initials, name, address -- and the two pure
38
41
  * functions behind the disc, exported so a caller colouring something else
@@ -64,6 +67,8 @@ export { Row, Rows } from './components/Rows.js';
64
67
  export { default as ScrollArea } from './components/ScrollArea.js';
65
68
  export { default as Select } from './components/Select.js';
66
69
  export { default as SizeGrid } from './components/SizeGrid.js';
70
+ /** The frame a signed-in surface stands in. Gates nothing, on purpose. */
71
+ export { default as Shell } from './components/Shell.js';
67
72
  export { default as Skeleton } from './components/Skeleton.js';
68
73
  /** One figure, with what it counts over it. Three apps drew this before it
69
74
  * was here. */
@@ -75,6 +80,9 @@ export { default as SplitPane } from './components/SplitPane.js';
75
80
  export { default as Table } from './components/Table.js';
76
81
  export { default as Tabs } from './components/Tabs.js';
77
82
  export { default as Textarea } from './components/Textarea.js';
83
+ /** The theme control, and the blocking script that beats it to the paint. */
84
+ export { default as ThemeSwitch } from './components/ThemeSwitch.js';
85
+ export { THEME_STORAGE_KEY, themeChoiceScript, themeChoices, storedTheme } from './themes/choice.js';
78
86
  /** `ToastHost` nests: one inside another renders through, so a package can
79
87
  * wrap itself and still leave an application with a single region. */
80
88
  export { ToastHost, useHasToastHost, useToast } from './components/Toast.js';