@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 +36 -0
- package/dist/components/DangerZone.js +1 -1
- package/dist/components/Field.d.ts +8 -1
- package/dist/components/Field.js +2 -2
- package/dist/components/RichText.d.ts +24 -0
- package/dist/components/RichText.js +69 -0
- package/dist/components/RichTextEditor.d.ts +32 -0
- package/dist/components/RichTextEditor.js +163 -0
- package/dist/components/Toggle.d.ts +8 -1
- package/dist/components/Toggle.js +2 -2
- package/dist/editor.d.ts +16 -0
- package/dist/editor.js +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/rich-text/schema.d.ts +219 -0
- package/dist/rich-text/schema.js +88 -0
- package/dist/rich-text/value.d.ts +25 -0
- package/dist/rich-text/value.js +41 -0
- package/dist/rich-text.d.ts +11 -0
- package/dist/rich-text.js +11 -0
- package/dist/styles/index.css +132 -21
- package/dist/tf.css +132 -21
- package/dist/valet.css +132 -21
- package/package.json +44 -4
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-
|
|
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;
|
package/dist/components/Field.js
CHANGED
|
@@ -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. */
|
package/dist/editor.d.ts
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 { 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
|