@wtfalch/design 0.10.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,12 @@ draws them changes. 0.9.0 did exactly that to several of them, and 0.10.0
17
17
  deleted 41 more: the components that drew them draw themselves now, so the
18
18
  rules had no reader left.
19
19
 
20
+ 0.11.0 went the other way and removed the rules that reached *through* a
21
+ class, which is the same mistake seen from the other side. `.row > .switch-row`
22
+ styled a Toggle by the container a caller had written round it, so the caller
23
+ had to know to write `.row` -- a class no component here renders. Those are
24
+ props now: `inRow` on `Toggle` and on `Field`, `tile` on `Checkbox`.
25
+
20
26
  If you need a card, render `<Card>`; if you need a row of your own, write it
21
27
  in the token vocabulary:
22
28
 
@@ -188,6 +194,36 @@ Icons and illustrations are the system's, shared by every product the way
188
194
  `Button` is. A product wanting its own inside the package's components is a
189
195
  case nobody has had; when it comes, the product entry is where to bind it.
190
196
 
197
+ ## Rich text
198
+
199
+ Prose, written and drawn, added in `0.11.0` for the CMS and the forum.
200
+
201
+ ```tsx
202
+ import { RichText, isEmptyRichText } from '@wtfalch/design' // drawing it
203
+ import { RichTextEditor } from '@wtfalch/design/editor' // writing it
204
+ import { richTextSchema } from '@wtfalch/design/rich-text' // storing it
205
+ ```
206
+
207
+ Three entries and not one, because they cost different things. Drawing prose
208
+ is a server component with no dependencies. Writing it loads TipTap, which is
209
+ ProseMirror, and a site that only reads should not download an editor.
210
+ Validating it needs zod. TipTap and zod are **optional peer dependencies**:
211
+ install them if you import those entries, and the front door works without
212
+ either.
213
+
214
+ **The restriction is the component.** The toolbar offers two heading levels,
215
+ bold, italic, a link and two kinds of list. `richTextSchema` admits exactly
216
+ those and `RichText` draws exactly those, so a document cannot contain
217
+ something a page cannot render — and a consumer that validates on the way
218
+ into its database gets that guarantee against a crafted request too, not just
219
+ against the toolbar. Tables, colours, fonts, code blocks and quotes are off,
220
+ each one a line in the component with the reason beside it. A seventh thing
221
+ is added in all three places, on purpose.
222
+
223
+ Nothing here produces an HTML string: `RichText` walks the value into React
224
+ elements, so there is no sanitiser to configure and none to get wrong. That
225
+ is the difference from `Markdown`, which parses and must sanitise.
226
+
191
227
  ## Status
192
228
 
193
229
  `0.3.1`. Twenty-eight components, every one of the 70 gallery specimens
@@ -53,5 +53,5 @@ match, label, busyLabel, busy = false, disabled = false, onConfirm,
53
53
  unavailable, }) {
54
54
  const [asking, setAsking] = useState(false);
55
55
  const [typed, setTyped] = useState('');
56
- return (_jsxs("div", { className: `danger-act${kind === 'plain' ? ' plain' : ''}`, children: [_jsx("h4", { children: title }), _jsx("div", { className: "set-hint", children: description }), unavailable ? (_jsx("div", { className: "set-hint mt-2", children: unavailable })) : confirm === 'type' ? (_jsx(_Fragment, { children: _jsxs("div", { className: "row danger-row field-row", children: [_jsx(Field, { label: "Confirm", hint: _jsxs(_Fragment, { children: ["Type ", _jsx("code", { className: "mono danger-name", children: match }), " to confirm."] }), children: (f) => (_jsx(Input, { ...f, mono: true, value: typed, disabled: disabled || busy, autoComplete: "off", spellCheck: false, onChange: (e) => setTyped(e.target.value) })) }), _jsx(Button, { kind: "danger", isDisabled: disabled || busy || typed.trim() !== match, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label })] }) })) : confirm === 'none' ? (_jsx("div", { className: "row danger-row", children: _jsx(Button, { kind: kind === 'plain' ? 'primary' : 'danger', isDisabled: disabled || busy, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label }) })) : asking ? (_jsxs("div", { className: "row danger-row", children: [_jsx(Button, { kind: "danger", isDisabled: disabled || busy, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label }), _jsx(Button, { isDisabled: disabled || busy, onPress: () => setAsking(false), children: "Cancel" })] })) : (_jsx("div", { className: "row danger-row", children: _jsx(Button, { kind: "danger", isDisabled: disabled || busy, onPress: () => setAsking(true), children: label }) }))] }));
56
+ return (_jsxs("div", { className: `danger-act${kind === 'plain' ? ' plain' : ''}`, children: [_jsx("h4", { children: title }), _jsx("div", { className: "set-hint", children: description }), unavailable ? (_jsx("div", { className: "set-hint mt-2", children: unavailable })) : confirm === 'type' ? (_jsx(_Fragment, { children: _jsxs("div", { className: "danger-row danger-field-row", children: [_jsx(Field, { inRow: true, label: "Confirm", hint: _jsxs(_Fragment, { children: ["Type ", _jsx("code", { className: "mono danger-name", children: match }), " to confirm."] }), children: (f) => (_jsx(Input, { ...f, mono: true, value: typed, disabled: disabled || busy, autoComplete: "off", spellCheck: false, onChange: (e) => setTyped(e.target.value) })) }), _jsx(Button, { kind: "danger", isDisabled: disabled || busy || typed.trim() !== match, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label })] }) })) : confirm === 'none' ? (_jsx("div", { className: "danger-row", children: _jsx(Button, { kind: kind === 'plain' ? 'primary' : 'danger', isDisabled: disabled || busy, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label }) })) : asking ? (_jsxs("div", { className: "danger-row", children: [_jsx(Button, { kind: "danger", isDisabled: disabled || busy, onPress: onConfirm, children: busy ? (busyLabel ?? '…') : label }), _jsx(Button, { isDisabled: disabled || busy, onPress: () => setAsking(false), children: "Cancel" })] })) : (_jsx("div", { className: "danger-row", children: _jsx(Button, { kind: "danger", isDisabled: disabled || busy, onPress: () => setAsking(true), children: label }) }))] }));
57
57
  }
@@ -1,6 +1,6 @@
1
1
  import { type FieldWiring } from './fieldWiring.js';
2
2
  export type { FieldWiring } from './fieldWiring.js';
3
- export default function Field({ label, hint, error, required, children, labelHidden, layout, className, }: {
3
+ export default function Field({ label, hint, error, required, children, labelHidden, layout, inRow, className, }: {
4
4
  /** What the field is. Always given -- there is no unlabelled case, only
5
5
  * fields whose label is hidden. */
6
6
  label: string;
@@ -39,5 +39,12 @@ export default function Field({ label, hint, error, required, children, labelHid
39
39
  * rendered somewhere the provider does not reach.
40
40
  */
41
41
  children: React.ReactNode | ((field: FieldWiring) => React.ReactNode);
42
+ /** Sitting in a row of controls rather than in a column of settings.
43
+ *
44
+ * This was a rule keyed on the container -- `.field-row > .field` -- which
45
+ * meant the package reaching through a class the caller had to know to
46
+ * write, and `.field-row` is not a class this package hands out. A prop says the
47
+ * same thing and travels with the component. */
48
+ inRow?: boolean;
42
49
  className?: string;
43
50
  }): import("react").JSX.Element;
@@ -39,7 +39,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
39
39
  */
40
40
  import { useId } from 'react';
41
41
  import { FieldWiringContext } from './fieldWiring.js';
42
- export default function Field({ label, hint, error, required, children, labelHidden, layout = 'stack', className, }) {
42
+ export default function Field({ label, hint, error, required, children, labelHidden, layout = 'stack', inRow, className, }) {
43
43
  const id = useId();
44
44
  const hintId = `${id}-hint`;
45
45
  const errorId = `${id}-error`;
@@ -53,5 +53,5 @@ export default function Field({ label, hint, error, required, children, labelHid
53
53
  'aria-describedby': [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined,
54
54
  'aria-invalid': error ? true : undefined,
55
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: "text-muted", "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 }))] }));
56
+ return (_jsxs("div", { className: `field field-${layout}-layout${error ? ' field-bad' : ''}${inRow ? ' field-in-row' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { id: labelId, className: labelHidden ? 'sr-only' : 'field-label', htmlFor: id, children: [label, required && (_jsxs("span", { className: "text-muted", "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 }))] }));
57
57
  }
@@ -0,0 +1,24 @@
1
+ import type { RichTextValue } from '../rich-text/schema.js';
2
+ /**
3
+ * Rich text, drawn.
4
+ *
5
+ * The reading half of the pair: `RichTextEditor` writes the value, this
6
+ * draws it, and `richTextSchema` is what they both agree on. A server
7
+ * component — no hooks, no handlers — so a page that only shows prose never
8
+ * loads an editor.
9
+ *
10
+ * **No HTML string anywhere in this file**, which is the whole security
11
+ * argument and the difference from `Markdown`. Nothing is parsed and nothing
12
+ * is injected, so there is no sanitiser whose configuration could be wrong.
13
+ * A value that has been through `richTextSchema` holds only the small closed
14
+ * set below; one that has not is still safe here, because a node this has no
15
+ * case for is skipped rather than trusted.
16
+ *
17
+ * **Every outward link gets `rel="noopener noreferrer"`.** The prose is
18
+ * written by somebody, and where a reader came from is not the destination's
19
+ * business.
20
+ */
21
+ export default function RichText({ value, className, }: {
22
+ value: RichTextValue;
23
+ className?: string;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Rich text, drawn.
4
+ *
5
+ * The reading half of the pair: `RichTextEditor` writes the value, this
6
+ * draws it, and `richTextSchema` is what they both agree on. A server
7
+ * component — no hooks, no handlers — so a page that only shows prose never
8
+ * loads an editor.
9
+ *
10
+ * **No HTML string anywhere in this file**, which is the whole security
11
+ * argument and the difference from `Markdown`. Nothing is parsed and nothing
12
+ * is injected, so there is no sanitiser whose configuration could be wrong.
13
+ * A value that has been through `richTextSchema` holds only the small closed
14
+ * set below; one that has not is still safe here, because a node this has no
15
+ * case for is skipped rather than trusted.
16
+ *
17
+ * **Every outward link gets `rel="noopener noreferrer"`.** The prose is
18
+ * written by somebody, and where a reader came from is not the destination's
19
+ * business.
20
+ */
21
+ export default function RichText({ value, className, }) {
22
+ return (_jsx("div", { className: className ? `rich-text ${className}` : 'rich-text', children: (value.content ?? []).map((node, index) => renderBlock(node, index)) }));
23
+ }
24
+ function renderBlock(node, key) {
25
+ switch (node.type) {
26
+ case 'paragraph':
27
+ return _jsx("p", { children: renderInline(node.content) }, key);
28
+ case 'heading':
29
+ return node.attrs.level === 2 ? (_jsx("h2", { children: renderInline(node.content) }, key)) : (_jsx("h3", { children: renderInline(node.content) }, key));
30
+ case 'bulletList':
31
+ return _jsx("ul", { children: (node.content ?? []).map((item, i) => renderItem(item, i)) }, key);
32
+ case 'orderedList':
33
+ return (_jsx("ol", { start: node.attrs?.start, children: (node.content ?? []).map((item, i) => renderItem(item, i)) }, key));
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ function renderItem(item, key) {
39
+ return (_jsx("li", { children: item.content.map((paragraph, i) => (
40
+ // The index is the right key here and in `renderInline`: this draws a
41
+ // finished value, the list is never reordered, inserted into or
42
+ // filtered, and the text is not unique — two paragraphs both saying
43
+ // "Yes" are two paragraphs.
44
+ // biome-ignore lint/suspicious/noArrayIndexKey: a finished value, never reconciled
45
+ _jsx("p", { children: renderInline(paragraph.content) }, i))) }, key));
46
+ }
47
+ function renderInline(content) {
48
+ if (!content || content.length === 0)
49
+ return null;
50
+ return content.map((node, index) => {
51
+ let element = node.text;
52
+ // Applied innermost first, so the order the marks arrive in cannot change
53
+ // the nesting: the link is always outermost, which is what a reader
54
+ // clicks and what a screen reader announces.
55
+ for (const mark of node.marks ?? []) {
56
+ if (mark.type === 'bold')
57
+ element = _jsx("strong", { children: element });
58
+ if (mark.type === 'italic')
59
+ element = _jsx("em", { children: element });
60
+ }
61
+ const link = (node.marks ?? []).find((mark) => mark.type === 'link');
62
+ if (link && link.type === 'link') {
63
+ const external = /^https?:\/\//i.test(link.attrs.href);
64
+ element = (_jsx("a", { href: link.attrs.href, ...(external ? { rel: 'noopener noreferrer' } : {}), children: element }));
65
+ }
66
+ // biome-ignore lint/suspicious/noArrayIndexKey: a finished value, never reconciled
67
+ return _jsx("span", { children: element }, index);
68
+ });
69
+ }
@@ -0,0 +1,32 @@
1
+ import type { RichTextValue } from '../rich-text/schema.js';
2
+ /**
3
+ * Writing prose, with a toolbar that offers six things.
4
+ *
5
+ * **The restriction is the component.** TipTap is ProseMirror, which will
6
+ * model tables, colours, fonts, code blocks and arbitrary nesting given the
7
+ * chance. Every one of those is switched off below. What is left — two
8
+ * heading levels, bold, italic, a link and two kinds of list — is exactly
9
+ * what `richTextSchema` admits and exactly what `RichText` can draw, so the
10
+ * three cannot disagree about what a document may contain. A consumer that
11
+ * wants a seventh thing adds it in all three places, on purpose, rather than
12
+ * discovering that a paste brought one in.
13
+ *
14
+ * **`immediatelyRender: false`** because this will be rendered on a server
15
+ * first: without it TipTap builds a document during SSR and React finds a
16
+ * different one on hydration.
17
+ *
18
+ * **The value is TipTap's JSON**, handed back on every change. The component
19
+ * holds no copy of it: the consumer owns the document, which is what lets an
20
+ * editor sit inside a larger form that saves everything at once.
21
+ *
22
+ * TipTap is an optional peer dependency. Import this entry and you need it;
23
+ * import the package's front door and you do not.
24
+ */
25
+ export default function RichTextEditor({ value, onChange, label, placeholder, className, }: {
26
+ value: RichTextValue;
27
+ onChange: (value: RichTextValue) => void;
28
+ /** What this field is, for a screen reader and for the toolbar's own name. */
29
+ label: string;
30
+ placeholder?: string;
31
+ className?: string;
32
+ }): import("react").JSX.Element;
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /* Client, because this module calls hooks and attaches handlers. It is also
4
+ the only component in the package that loads an editor, which is why it
5
+ ships from its own entry (`@wtfalch/design/editor`) rather than the front
6
+ door: a site that renders prose should not download one to do it. */
7
+ import Link from '@tiptap/extension-link';
8
+ import { Placeholder } from '@tiptap/extensions';
9
+ import { EditorContent, useEditor } from '@tiptap/react';
10
+ import StarterKit from '@tiptap/starter-kit';
11
+ import { useCallback } from 'react';
12
+ import Button from './Button.js';
13
+ /**
14
+ * Writing prose, with a toolbar that offers six things.
15
+ *
16
+ * **The restriction is the component.** TipTap is ProseMirror, which will
17
+ * model tables, colours, fonts, code blocks and arbitrary nesting given the
18
+ * chance. Every one of those is switched off below. What is left — two
19
+ * heading levels, bold, italic, a link and two kinds of list — is exactly
20
+ * what `richTextSchema` admits and exactly what `RichText` can draw, so the
21
+ * three cannot disagree about what a document may contain. A consumer that
22
+ * wants a seventh thing adds it in all three places, on purpose, rather than
23
+ * discovering that a paste brought one in.
24
+ *
25
+ * **`immediatelyRender: false`** because this will be rendered on a server
26
+ * first: without it TipTap builds a document during SSR and React finds a
27
+ * different one on hydration.
28
+ *
29
+ * **The value is TipTap's JSON**, handed back on every change. The component
30
+ * holds no copy of it: the consumer owns the document, which is what lets an
31
+ * editor sit inside a larger form that saves everything at once.
32
+ *
33
+ * TipTap is an optional peer dependency. Import this entry and you need it;
34
+ * import the package's front door and you do not.
35
+ */
36
+ export default function RichTextEditor({ value, onChange, label, placeholder, className, }) {
37
+ const editor = useEditor({
38
+ extensions: [
39
+ StarterKit.configure({
40
+ // Left on: paragraph, text, bold, italic, the two lists, listItem,
41
+ // history, and the two cursors that make dragging sane.
42
+ heading: { levels: [2, 3] },
43
+ // Off, each one a decision rather than an oversight. A code block and
44
+ // a quote are shapes a block-based consumer expresses as their own
45
+ // blocks; a horizontal rule is a spacer, and spacing belongs to the
46
+ // theme; strike and inline code are marks nobody asked for, and every
47
+ // one of them is another thing two documents can disagree about.
48
+ codeBlock: false,
49
+ blockquote: false,
50
+ horizontalRule: false,
51
+ strike: false,
52
+ code: false,
53
+ // Configured separately below, so its defaults — which allow any
54
+ // scheme — never apply.
55
+ link: false,
56
+ }),
57
+ // What an empty editor says. TipTap's own extension rather than a CSS
58
+ // rule of ours: `attr()` reads the attribute of the element the
59
+ // pseudo-element belongs to, so a rule on the paragraph cannot reach a
60
+ // `data-placeholder` on the box, and ProseMirror's empty paragraph
61
+ // holds a trailing `<br>` so `:empty` never matches it either. Both of
62
+ // those were tried, and the picture of the second is why this is here.
63
+ Placeholder.configure({ placeholder: placeholder ?? '' }),
64
+ Link.configure({
65
+ openOnClick: false,
66
+ autolink: false,
67
+ // The same three `richTextSchema` admits. This is the first of two
68
+ // gates and the schema is the one that counts.
69
+ protocols: ['http', 'https', 'mailto'],
70
+ HTMLAttributes: { rel: 'noopener noreferrer' },
71
+ }),
72
+ ],
73
+ content: value,
74
+ immediatelyRender: false,
75
+ editorProps: {
76
+ attributes: {
77
+ // `rich-text` styles the elements ProseMirror builds; the rest is
78
+ // this surface's own box, which is ours to draw. Two of the largest
79
+ // spacing step is about six lines — enough to write a paragraph in
80
+ // without the box growing under the cursor on the first one.
81
+ class: 'rich-text p-3 min-h-[calc(var(--space-15)*2)] outline-none',
82
+ // A contenteditable div is a `generic` to an accessibility tree, and
83
+ // `aria-label` is prohibited on a generic — axe said so on the first
84
+ // run, which is what the suite is for. `textbox` with
85
+ // `aria-multiline` is what a rich text area is, and it is what makes
86
+ // the label legal and announced.
87
+ role: 'textbox',
88
+ 'aria-multiline': 'true',
89
+ 'aria-label': label,
90
+ },
91
+ },
92
+ onUpdate: ({ editor: instance }) => onChange(instance.getJSON()),
93
+ });
94
+ const setLink = useCallback(() => {
95
+ if (!editor)
96
+ return;
97
+ const current = editor.getAttributes('link').href;
98
+ // A prompt rather than a dialog: it is one field, it is modal either way,
99
+ // and a dialog here would be the first piece of state this component has
100
+ // to own. Worth replacing the day a link needs a second field.
101
+ const href = window.prompt('Link to', current ?? 'https://');
102
+ if (href === null)
103
+ return;
104
+ if (href.trim() === '') {
105
+ editor.chain().focus().unsetLink().run();
106
+ return;
107
+ }
108
+ editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
109
+ }, [editor]);
110
+ /* The box.
111
+
112
+ `rounded-(--radius)`, not `rounded`: Tailwind v4 emits a literal
113
+ `0.25rem` for the bare utility and never reads `--radius-DEFAULT`, so
114
+ `rounded` is an unthemeable 4px corner where the vocabulary says 6. The
115
+ screenshots caught it as four moved corners and nothing else. Nothing
116
+ else in the package uses the bare form.
117
+
118
+ `shadow`, not `outline`: `--focus-ring` is a box-shadow value
119
+ (`0 0 0 2px var(--accent)`), so `outline: var(--focus-ring)` is invalid
120
+ and drops the whole declaration silently — which this had, and which no
121
+ screenshot would ever have shown because a baseline is never focused.
122
+ `pagination.css` records the same bug. The ring belongs to the box
123
+ rather than to the contenteditable, so it does not draw a second,
124
+ thinner rectangle inside the border. */
125
+ const box = 'grid overflow-hidden rounded-(--radius) border border-border-strong surface-control focus-within:shadow-(--focus-ring)';
126
+ if (!editor) {
127
+ return _jsx("div", { className: className ? `${box} ${className}` : box, "aria-busy": "true" });
128
+ }
129
+ const controls = [
130
+ {
131
+ label: 'Heading',
132
+ active: editor.isActive('heading', { level: 2 }),
133
+ run: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
134
+ },
135
+ {
136
+ label: 'Subheading',
137
+ active: editor.isActive('heading', { level: 3 }),
138
+ run: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
139
+ },
140
+ {
141
+ label: 'Bold',
142
+ active: editor.isActive('bold'),
143
+ run: () => editor.chain().focus().toggleBold().run(),
144
+ },
145
+ {
146
+ label: 'Italic',
147
+ active: editor.isActive('italic'),
148
+ run: () => editor.chain().focus().toggleItalic().run(),
149
+ },
150
+ { label: 'Link', active: editor.isActive('link'), run: setLink },
151
+ {
152
+ label: 'Bullets',
153
+ active: editor.isActive('bulletList'),
154
+ run: () => editor.chain().focus().toggleBulletList().run(),
155
+ },
156
+ {
157
+ label: 'Numbers',
158
+ active: editor.isActive('orderedList'),
159
+ run: () => editor.chain().focus().toggleOrderedList().run(),
160
+ },
161
+ ];
162
+ return (_jsxs("div", { className: className ? `${box} ${className}` : box, children: [_jsx("div", { className: "flex flex-wrap gap-1 border-b border-border p-1 surface-panel", role: "toolbar", "aria-label": `${label} formatting`, children: controls.map((control) => (_jsx(Button, { type: "button", size: "sm", kind: control.active ? 'primary' : 'ghost', "aria-pressed": control.active, onPress: control.run, children: control.label }, control.label))) }), _jsx(EditorContent, { editor: editor })] }));
163
+ }
@@ -1,4 +1,4 @@
1
- export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size, className, }: {
1
+ export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size, inRow, className, }: {
2
2
  label: React.ReactNode;
3
3
  /** What it does, or what turning it off costs. Under the label, in the same
4
4
  * column, so a long explanation does not push the switch off its row. */
@@ -22,5 +22,12 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
22
22
  /** The same three the rest of the controls take. `sm` for a switch in a
23
23
  * toolbar or a dense row; `lg` where it is the only thing on the screen. */
24
24
  size?: 'sm' | 'md' | 'lg';
25
+ /** Sitting in a row of controls rather than in a column of settings.
26
+ *
27
+ * This was a rule keyed on the container -- `.row > .switch-row` -- which
28
+ * meant the package reaching through a class the caller had to know to
29
+ * write, and `.row` is not a class this package hands out. A prop says the
30
+ * same thing and travels with the component. */
31
+ inRow?: boolean;
25
32
  className?: string;
26
33
  }): import("react").JSX.Element;
@@ -49,7 +49,7 @@ const KNOB = { sm: 10, md: 12, lg: 16 };
49
49
  * than pressed. Six is over the jitter and still a third of the way across
50
50
  * the shortest track. */
51
51
  const SLOP = 6;
52
- export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size = 'md', className, }) {
52
+ export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size = 'md', inRow, className, }) {
53
53
  /* React Aria's `Switch` is the `<label>`: it owns a visually-hidden
54
54
  `<input type="checkbox" role="switch">` and stamps `data-selected`,
55
55
  `data-disabled`, `data-focus-visible` and `data-pressed` on the label. So
@@ -154,7 +154,7 @@ export default function Toggle({ label, hint, checked, onChange, disabled, said,
154
154
  return 0;
155
155
  return Math.min(1, Math.max(0, (x - box.left - 2 - knobWidth / 2) / travel));
156
156
  };
157
- return (_jsxs(Switch, { ref: rowRef, className: `switch-row switch-${size}${className ? ` ${className}` : ''}`, isSelected: shown, onChange: commit, isDisabled: disabled,
157
+ return (_jsxs(Switch, { ref: rowRef, className: `switch-row switch-${size}${inRow ? ' switch-in-row' : ''}${className ? ` ${className}` : ''}`, isSelected: shown, onChange: commit, isDisabled: disabled,
158
158
  /* Read-only, not disabled, while a request is out: focus stays where it
159
159
  is and the row does not dim, it just refuses a second answer until
160
160
  the first has been taken. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The editor entry: `@wtfalch/design/editor`.
3
+ *
4
+ * Its own entry because it is the one component in the package that loads an
5
+ * editor — TipTap, which is ProseMirror — and a site that only *renders*
6
+ * prose should not download one to do it. `RichText` and the value helpers
7
+ * ship from the package's front door for exactly that reason; the schema
8
+ * ships from `@wtfalch/design/rich-text`, because it needs zod.
9
+ *
10
+ * TipTap and zod are optional peer dependencies. Import this and you install
11
+ * them; import anything else in the package and you do not.
12
+ */
13
+ export { default as RichTextEditor } from './components/RichTextEditor.js';
14
+ /** Re-exported, so writing prose needs one import rather than three. */
15
+ export { type BlockNode, type RichTextMark, type RichTextValue, blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
16
+ export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
package/dist/editor.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The editor entry: `@wtfalch/design/editor`.
3
+ *
4
+ * Its own entry because it is the one component in the package that loads an
5
+ * editor — TipTap, which is ProseMirror — and a site that only *renders*
6
+ * prose should not download one to do it. `RichText` and the value helpers
7
+ * ship from the package's front door for exactly that reason; the schema
8
+ * ships from `@wtfalch/design/rich-text`, because it needs zod.
9
+ *
10
+ * TipTap and zod are optional peer dependencies. Import this and you install
11
+ * them; import anything else in the package and you do not.
12
+ */
13
+ export { default as RichTextEditor } from './components/RichTextEditor.js';
14
+ /** Re-exported, so writing prose needs one import rather than three. */
15
+ export { blockNodeSchema, richTextMarkSchema, richTextSchema, } from './rich-text/schema.js';
16
+ export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
package/dist/index.d.ts CHANGED
@@ -55,6 +55,14 @@ export type { Props as InputProps } from './components/Input.js';
55
55
  * of, now one component a consumer can reach. */
56
56
  export { default as Kbd } from './components/Kbd.js';
57
57
  export { default as Markdown } from './components/Markdown.js';
58
+ /** Prose written with `RichTextEditor` (`@wtfalch/design/editor`), drawn.
59
+ * Here rather than in that entry because reading prose should not download
60
+ * an editor; the schema the two agree on ships with it. */
61
+ export { default as RichText } from './components/RichText.js';
62
+ /** The value's shape and the three helpers that need no validator. The schema
63
+ * itself is `@wtfalch/design/rich-text`, because zod is an optional peer. */
64
+ export type { BlockNode, RichTextMark, RichTextValue } from './rich-text/schema.js';
65
+ export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
58
66
  export { default as Menu } from './components/Menu.js';
59
67
  export type { Item as MenuItem, Section as MenuSection } from './components/Menu.js';
60
68
  export { default as Modal } from './components/Modal.js';
package/dist/index.js CHANGED
@@ -49,6 +49,11 @@ export { default as Input } from './components/Input.js';
49
49
  * of, now one component a consumer can reach. */
50
50
  export { default as Kbd } from './components/Kbd.js';
51
51
  export { default as Markdown } from './components/Markdown.js';
52
+ /** Prose written with `RichTextEditor` (`@wtfalch/design/editor`), drawn.
53
+ * Here rather than in that entry because reading prose should not download
54
+ * an editor; the schema the two agree on ships with it. */
55
+ export { default as RichText } from './components/RichText.js';
56
+ export { emptyRichText, isEmptyRichText, richTextToPlain } from './rich-text/value.js';
52
57
  export { default as Menu } from './components/Menu.js';
53
58
  export { default as Modal } from './components/Modal.js';
54
59
  /** Moving through a list that does not fit, counted in items rather than