@tristan2828/ui-foundation 3.14.0 → 3.15.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.
@@ -197,7 +197,9 @@ is a filled-in example while the Widgets demo is still here).
197
197
  the plan edits in place** (`Edit in place`), shared by the form
198
198
  (through a `Controller`) and the view (through `editInPlace`), and
199
199
  the Markdown long text's `RichTextEditor` (on the form whether or
200
- not it edits in place). Every other field's control stays inline in
200
+ not it edits in place), given a `placeholder` and `maxLength` (the
201
+ schema's limit, one constant both read, like Widget's
202
+ `DESCRIPTION_MAX_LENGTH`). Every other field's control stays inline in
201
203
  the form, as `widget-form.tsx`'s Category, Tags and Available From
202
204
  do. No `Edit in place` line and no Markdown long text: no fields
203
205
  file.
@@ -736,7 +736,12 @@ const price = editInPlace({
736
736
  - **Long text written as Markdown edits as rich text** (`RichTextEditor`):
737
737
  formatted where it's read, saved as Markdown with every block the person
738
738
  didn't touch kept exactly as written. On the form too, so it's one
739
- control.
739
+ control. Give it a `placeholder` and the schema's limit as `maxLength`
740
+ (a count shows from 80% of it, in the destructive text tone once over).
741
+ Its keys: Ctrl/Cmd+K adds or edits a link (web and email addresses
742
+ only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
743
+ Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
744
+ the save.
740
745
  - **The save is the single-field save** (`useRecordUpdate` with
741
746
  `optimistic: false, toastOnError: false`, the entity's
742
747
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -1,2 +1,2 @@
1
1
  import type { RichTextEditorProps } from './rich-text-editor.js';
2
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
2
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The rich-text editor behind RichTextEditor, loaded only when one opens
3
3
  // (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
4
4
  // editor whose Markdown goes in and out through remark, the same parser
@@ -15,15 +15,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
15
15
  // - Headings render from <h3>, like <Markdown>: the page owns <h1> and
16
16
  // <h2>. A task-list item renders a real checkbox that toggles it.
17
17
  // - Edited blocks are written in the document's own bullet and rule marks.
18
- import { useEffect, useRef } from 'react';
19
- import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
18
+ // - Markdown pasted as plain text arrives formatted (still no HTML), and a
19
+ // copy out of the editor is Markdown.
20
+ // - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
21
+ // addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
22
+ // in. Ctrl/Cmd+Enter stays the field's save.
23
+ // - A placeholder in an empty document, and a count of the Markdown's
24
+ // length as it nears `maxLength`.
25
+ import { useEffect, useId, useRef, useState } from 'react';
26
+ import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
20
27
  import { history } from '@milkdown/kit/plugin/history';
21
28
  import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
22
29
  import { gfm } from '@milkdown/kit/preset/gfm';
23
30
  import { Fragment, Slice } from '@milkdown/kit/prose/model';
24
31
  import { Plugin } from '@milkdown/kit/prose/state';
32
+ import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
25
33
  import { $prose, getMarkdown } from '@milkdown/kit/utils';
26
34
  import { cn } from 'cn';
35
+ import { Button } from '../ui/button.js';
36
+ import { Field, FieldError, FieldLabel } from '../ui/field.js';
37
+ import { Input } from '../ui/input.js';
38
+ import { Popover, PopoverContent } from '../ui/popover.js';
39
+ import { linkHref } from '../../lib/link-href.js';
27
40
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
28
41
  // The commonmark preset, minus the plugin that writes `<br />`. A $remark
29
42
  // plugin is a pair (its options and the plugin), spread into `plugins`.
@@ -102,14 +115,84 @@ function taskItemView(node, view, getPos) {
102
115
  ignoreMutation: (mutation) => mutation.target === box,
103
116
  };
104
117
  }
105
- export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
118
+ // The task-list item the caret is in, ticked or unticked. False when the
119
+ // caret isn't in one.
120
+ function toggleTaskItem(view) {
121
+ const { $from } = view.state.selection;
122
+ for (let depth = $from.depth; depth > 0; depth--) {
123
+ const node = $from.node(depth);
124
+ if (node.type.name === 'list_item' && node.attrs.checked != null) {
125
+ view.dispatch(view.state.tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, checked: !node.attrs.checked }));
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+ function linkTarget(state) {
132
+ const link = state.schema.marks.link;
133
+ const { from, to, empty, $from } = state.selection;
134
+ if (!empty) {
135
+ let href = '';
136
+ state.doc.nodesBetween(from, to, (node) => {
137
+ const mark = link.isInSet(node.marks);
138
+ if (mark && !href)
139
+ href = String(mark.attrs.href);
140
+ });
141
+ return { from, to, href };
142
+ }
143
+ const mark = link.isInSet($from.marks());
144
+ if (!mark)
145
+ return { from, to, href: '' };
146
+ const start = $from.start();
147
+ let target = { from, to, href: String(mark.attrs.href) };
148
+ let runFrom = -1;
149
+ $from.parent.forEach((child, offset) => {
150
+ const childFrom = start + offset;
151
+ const childTo = childFrom + child.nodeSize;
152
+ if (!mark.isInSet(child.marks)) {
153
+ runFrom = -1;
154
+ return;
155
+ }
156
+ if (runFrom < 0)
157
+ runFrom = childFrom;
158
+ if (runFrom <= from && from <= childTo)
159
+ target = { from: runFrom, to: childTo, href: target.href };
160
+ });
161
+ return target;
162
+ }
163
+ // Sets (or, with `href` empty, removes) the link on the target. With
164
+ // nothing selected and no link there, the address goes in as the link's
165
+ // own text.
166
+ function applyLink(view, target, href) {
167
+ const link = view.state.schema.marks.link;
168
+ const tr = view.state.tr;
169
+ if (target.from === target.to) {
170
+ if (!href)
171
+ return;
172
+ tr.insertText(href, target.from).addMark(target.from, target.from + href.length, link.create({ href }));
173
+ }
174
+ else {
175
+ tr.removeMark(target.from, target.to, link);
176
+ if (href)
177
+ tr.addMark(target.from, target.to, link.create({ href }));
178
+ }
179
+ view.dispatch(tr.scrollIntoView());
180
+ }
181
+ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
106
182
  const rootRef = useRef(null);
107
183
  const editorRef = useRef(null);
184
+ const [linkBox, setLinkBox] = useState(null);
185
+ const [length, setLength] = useState(defaultValue.length);
108
186
  // The latest props, for the editor's callbacks, which are set up once.
109
- const latest = useRef({ onChange, onProblem, readOnly });
187
+ const latest = useRef({ onChange, onProblem, readOnly, placeholder });
110
188
  useEffect(() => {
111
- latest.current = { onChange, onProblem, readOnly };
189
+ latest.current = { onChange, onProblem, readOnly, placeholder };
112
190
  });
191
+ const ids = useId();
192
+ const counterId = `${ids}-count`;
193
+ // The count shows from 80% of the limit: below that it's noise.
194
+ const showCount = maxLength !== undefined && length >= maxLength * 0.8;
195
+ const describedBy = [ariaDescribedBy, showCount ? counterId : undefined].filter(Boolean).join(' ');
113
196
  const attributes = {
114
197
  'aria-multiline': 'true',
115
198
  'aria-readonly': String(readOnly),
@@ -121,10 +204,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
121
204
  attributes['aria-label'] = ariaLabel;
122
205
  if (ariaLabelledBy)
123
206
  attributes['aria-labelledby'] = ariaLabelledBy;
124
- if (ariaDescribedBy)
125
- attributes['aria-describedby'] = ariaDescribedBy;
207
+ if (describedBy)
208
+ attributes['aria-describedby'] = describedBy;
126
209
  if (ariaInvalid)
127
210
  attributes['aria-invalid'] = 'true';
211
+ if (placeholder)
212
+ attributes['aria-placeholder'] = placeholder;
128
213
  const attributesKey = JSON.stringify(attributes);
129
214
  // The editor is made once, from the Markdown it opened with.
130
215
  useEffect(() => {
@@ -143,6 +228,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
143
228
  const merged = mergeMarkdown(original, baseline, current);
144
229
  latest.current.onProblem?.(null);
145
230
  latest.current.onChange(merged);
231
+ setLength(merged.length);
146
232
  }
147
233
  catch (error) {
148
234
  if (!(error instanceof MarkdownMergeError))
@@ -161,6 +247,80 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
161
247
  transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
162
248
  },
163
249
  }));
250
+ // Opens the link box at the start of what the link applies to.
251
+ const openLink = (view) => {
252
+ if (!view.editable)
253
+ return;
254
+ const target = linkTarget(view.state);
255
+ const point = view.coordsAtPos(target.from);
256
+ setLinkBox({
257
+ ...target,
258
+ anchor: {
259
+ getBoundingClientRect: () => new DOMRect(point.left, point.top, 0, point.bottom - point.top),
260
+ contextElement: view.dom,
261
+ },
262
+ });
263
+ };
264
+ // Paste, copy, the editor's own keys and the placeholder.
265
+ const input = $prose((ctx) => new Plugin({
266
+ props: {
267
+ // Plain text is read as Markdown, so `## Notes` or `**bold**`
268
+ // copied from another app arrives formatted; raw HTML in it
269
+ // still arrives as text. A paste with HTML of its own (from a
270
+ // web page) is ProseMirror's, through transformPasted above.
271
+ handlePaste: (view, event) => {
272
+ const data = event.clipboardData;
273
+ if (!data || !view.editable || data.getData('text/html'))
274
+ return false;
275
+ const text = data.getData('text/plain');
276
+ if (!text || view.state.selection.$from.parent.type.spec.code)
277
+ return false;
278
+ const parsed = ctx.get(parserCtx)(text);
279
+ if (!parsed || typeof parsed === 'string')
280
+ return false;
281
+ // Text with no Markdown in it (one plain paragraph) goes in
282
+ // exactly as typed, spaces at its ends kept.
283
+ const only = parsed.childCount === 1 ? parsed.firstChild : null;
284
+ if (only?.type.name === 'paragraph' && only.childCount === 1 && only.textContent === text.trim()) {
285
+ view.dispatch(view.state.tr.insertText(text).scrollIntoView());
286
+ return true;
287
+ }
288
+ view.dispatch(view.state.tr.replaceSelection(Slice.maxOpen(htmlToText(parsed.content))).scrollIntoView());
289
+ return true;
290
+ },
291
+ // A copy is Markdown, so it pastes into a plain text field
292
+ // (or another Markdown editor) as written.
293
+ clipboardTextSerializer: (slice) => {
294
+ const doc = slice.content.firstChild?.isBlock
295
+ ? ctx.get(schemaCtx).topNodeType.createAndFill(undefined, slice.content)
296
+ : null;
297
+ return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
298
+ },
299
+ handleKeyDown: (view, event) => {
300
+ const mod = event.metaKey || event.ctrlKey;
301
+ if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
302
+ event.preventDefault();
303
+ openLink(view);
304
+ return true;
305
+ }
306
+ // Ctrl/Cmd+Shift+Enter in a task item ticks it, and goes no
307
+ // further. Anywhere else it reaches the field, which saves.
308
+ if (mod && event.shiftKey && event.key === 'Enter' && view.editable && toggleTaskItem(view)) {
309
+ event.preventDefault();
310
+ event.stopPropagation();
311
+ return true;
312
+ }
313
+ return false;
314
+ },
315
+ decorations: (state) => {
316
+ const text = latest.current.placeholder;
317
+ const only = state.doc.childCount === 1 ? state.doc.firstChild : null;
318
+ if (!text || !only || only.type.name !== 'paragraph' || only.content.size > 0)
319
+ return null;
320
+ return DecorationSet.create(state.doc, [Decoration.node(0, only.nodeSize, { 'data-placeholder': text })]);
321
+ },
322
+ },
323
+ }));
164
324
  const make = Editor.make()
165
325
  .config((ctx) => {
166
326
  ctx.set(rootCtx, root);
@@ -179,7 +339,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
179
339
  .use(COMMONMARK)
180
340
  .use(gfm)
181
341
  .use(history)
182
- .use(changes);
342
+ .use(changes)
343
+ .use(input);
183
344
  void make.create().then((editor) => {
184
345
  if (cancelled) {
185
346
  void editor.destroy();
@@ -212,10 +373,52 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
212
373
  view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
213
374
  });
214
375
  }, [readOnly, attributesKey]);
215
- return (_jsx("div", { ref: rootRef, "data-slot": "rich-text-editor", className: cn('min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 type-body text-foreground break-words transition-colors', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', 'has-[[aria-readonly=true]]:text-muted-foreground',
216
- // The same type roles <Markdown> uses, so text reads the same
217
- // being edited as being read.
218
- '[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
219
- // Raw HTML from the text: shown as its characters, never as markup.
220
- '[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground', className) }));
376
+ const closeLink = () => {
377
+ setLinkBox(null);
378
+ editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
379
+ };
380
+ return (_jsxs(_Fragment, { children: [_jsx("div", { ref: rootRef, "data-slot": "rich-text-editor", className: cn('min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 type-body text-foreground break-words transition-colors', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', 'has-[[aria-readonly=true]]:text-muted-foreground',
381
+ // The same type roles <Markdown> uses, so text reads the same
382
+ // being edited as being read.
383
+ '[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
384
+ // Raw HTML from the text: shown as its characters, never as markup.
385
+ '[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
386
+ // The placeholder, in an empty document only (a decoration).
387
+ '[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]', className) }), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
388
+ ? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
389
+ : `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
390
+ const editor = editorRef.current;
391
+ editor?.action((ctx) => applyLink(ctx.get(editorViewCtx), linkBox, href));
392
+ closeLink();
393
+ } })) })] }));
394
+ }
395
+ // The link box: the address, Apply, and Remove link for an existing one.
396
+ // Enter (Ctrl/Cmd+Enter too) applies the link and stays its own: editing
397
+ // in place listens for Ctrl/Cmd+Enter through the portal, and would save
398
+ // the field without it. Esc is the popover's: it closes the box only.
399
+ function LinkForm({ box, onApply }) {
400
+ const [typed, setTyped] = useState(box.href);
401
+ const [error, setError] = useState(null);
402
+ const inputRef = useRef(null);
403
+ const inputId = useId();
404
+ const errorId = `${inputId}-error`;
405
+ const apply = () => {
406
+ const href = linkHref(typed);
407
+ if (href === null) {
408
+ setError('Enter a web or email address, like https://example.com.');
409
+ return;
410
+ }
411
+ onApply(href);
412
+ };
413
+ const onKeyDown = (event) => {
414
+ if (event.key === 'Enter' && event.target === inputRef.current) {
415
+ event.preventDefault();
416
+ event.stopPropagation();
417
+ apply();
418
+ }
419
+ };
420
+ return (_jsxs(PopoverContent, { anchor: box.anchor, align: "start", "aria-label": box.href ? 'Edit link' : 'Add link', initialFocus: inputRef, finalFocus: false, className: "w-80", onKeyDown: onKeyDown, children: [_jsxs(Field, { "data-invalid": error ? true : undefined, children: [_jsx(FieldLabel, { htmlFor: inputId, children: "Link address" }), _jsx(Input, { ref: inputRef, id: inputId, value: typed, placeholder: "https://", autoComplete: "off", "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined, onChange: (event) => {
421
+ setTyped(event.target.value);
422
+ setError(null);
423
+ } }), error && _jsx(FieldError, { id: errorId, children: error })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", onClick: apply, children: "Apply" }), box.href && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => onApply(''), children: "Remove link" }))] })] }));
221
424
  }
@@ -10,6 +10,14 @@ export type RichTextEditorProps = {
10
10
  /** Shown but not editable (while a save is in flight). */
11
11
  readOnly?: boolean;
12
12
  autoFocus?: boolean;
13
+ /** Shown while the document is empty (and given to screen readers as `aria-placeholder`). */
14
+ placeholder?: string;
15
+ /**
16
+ * The field's limit on the saved Markdown's length. From 80% of it, a
17
+ * count shows under the editor, in the destructive text tone once over.
18
+ * Typing isn't stopped: the form's schema still refuses a save over it.
19
+ */
20
+ maxLength?: number;
13
21
  className?: string;
14
22
  /** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
15
23
  'aria-label'?: string;
@@ -2,7 +2,7 @@ import * as React from "react";
2
2
  import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
3
3
  declare function Popover({ ...props }: PopoverPrimitive.Root.Props): React.JSX.Element;
4
4
  declare function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props): React.JSX.Element;
5
- declare function PopoverContent({ className, align, alignOffset, side, sideOffset, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">): React.JSX.Element;
5
+ declare function PopoverContent({ className, align, alignOffset, side, sideOffset, anchor, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "anchor">): React.JSX.Element;
6
6
  declare function PopoverHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
7
7
  declare function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props): React.JSX.Element;
8
8
  declare function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props): React.JSX.Element;
@@ -8,8 +8,12 @@ function Popover({ ...props }) {
8
8
  function PopoverTrigger({ ...props }) {
9
9
  return _jsx(PopoverPrimitive.Trigger, { "data-slot": "popover-trigger", ...props });
10
10
  }
11
- function PopoverContent({ className, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, ...props }) {
12
- return (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Positioner, { align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, className: "isolate z-50", children: _jsx(PopoverPrimitive.Popup, { "data-slot": "popover-content", className: cn("z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props }) }) }));
11
+ // `anchor` is this repo's patch to the upstream primitive: a popover with
12
+ // no trigger of its own, placed at a point (the rich-text editor's link
13
+ // box, at the caret). A local patch: re-running `shadcn add popover` here
14
+ // would overwrite it. Diff against upstream before taking a new version.
15
+ function PopoverContent({ className, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, anchor, ...props }) {
16
+ return (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Positioner, { anchor: anchor, align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, className: "isolate z-50", children: _jsx(PopoverPrimitive.Popup, { "data-slot": "popover-content", className: cn("z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props }) }) }));
13
17
  }
14
18
  function PopoverHeader({ className, ...props }) {
15
19
  return (_jsx("div", { "data-slot": "popover-header", className: cn("flex flex-col gap-0.5 text-sm", className), ...props }));
@@ -0,0 +1,2 @@
1
+ /** The `href` to save, `''` to remove the link, or `null` when it's refused. */
2
+ export declare function linkHref(typed: string): string | null;
@@ -0,0 +1,22 @@
1
+ // The address a link in the rich-text editor points at, from what the
2
+ // person typed. Web and email addresses only: a bare `example.com` becomes
3
+ // `https://example.com` and a bare `name@example.com` a `mailto:`; a path
4
+ // on this site (`/x`) or a heading (`#x`) is kept. Any other scheme
5
+ // (`javascript:`, `data:`, `file:`) is refused, so a link can't run code
6
+ // or reach a local file.
7
+ /** The `href` to save, `''` to remove the link, or `null` when it's refused. */
8
+ export function linkHref(typed) {
9
+ const value = typed.trim();
10
+ if (value === '')
11
+ return '';
12
+ if (/^(https?:\/\/|mailto:|tel:)/i.test(value))
13
+ return value;
14
+ // A scheme of its own (`javascript:`), not a port (`example.com:8080`).
15
+ if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(value))
16
+ return null;
17
+ if (value.startsWith('/') || value.startsWith('#'))
18
+ return value;
19
+ if (/^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(value))
20
+ return `mailto:${value}`;
21
+ return `https://${value}`;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.14.0",
3
+ "version": "3.15.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {