@tristan2828/ui-foundation 3.16.2 → 3.18.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.
@@ -742,9 +742,15 @@ const price = editInPlace({
742
742
  only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
743
743
  Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
744
744
  the save. A formatting toolbar sits above the text (bold to link, each
745
- button naming its shortcut); it's part of the field, so moving to it
746
- doesn't save. `toolbar={false}` leaves it out where a value is short
747
- and the shortcuts are enough.
745
+ button naming its shortcut), and a smaller one floats over selected
746
+ words (bold, italic, strikethrough, code, link); Alt+F10 reaches the
747
+ floating one, else the fixed one. Both are part of the field, so moving
748
+ to them doesn't save, and Esc hides the floating one before it gives up
749
+ the edit. "/" typed at a line's start lists the blocks the line can
750
+ become (headings, lists, quote, code block); typing filters, Enter or
751
+ Tab picks, and Esc closes the list before it gives up the edit.
752
+ `toolbar={false}` leaves out the toolbars and the slash menu where a
753
+ value is short and the shortcuts are enough.
748
754
  - **The save is the single-field save** (`useRecordUpdate` with
749
755
  `optimistic: false, toastOnError: false`, the entity's
750
756
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -22,7 +22,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
22
22
  // in. Ctrl/Cmd+Enter stays the field's save.
23
23
  // - A placeholder in an empty document, and a count of the Markdown's
24
24
  // length as it nears `maxLength`.
25
- // - A fixed toolbar above the text (rich-text-toolbar.tsx), unless
25
+ // - A fixed toolbar above the text (rich-text-toolbar.tsx), and a
26
+ // floating one over selected words (rich-text-floating-toolbar.tsx),
27
+ // unless `toolbar={false}`. Alt+F10 reaches the floating one when it
28
+ // shows, else the fixed one.
29
+ // - A slash menu (rich-text-slash.ts, rich-text-slash-menu.tsx): "/" at a
30
+ // line's start lists the blocks it can become, also unless
26
31
  // `toolbar={false}`.
27
32
  import { useEffect, useId, useRef, useState } from 'react';
28
33
  import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
@@ -30,7 +35,7 @@ import { history } from '@milkdown/kit/plugin/history';
30
35
  import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
31
36
  import { gfm } from '@milkdown/kit/preset/gfm';
32
37
  import { Fragment, Slice } from '@milkdown/kit/prose/model';
33
- import { Plugin } from '@milkdown/kit/prose/state';
38
+ import { NodeSelection, Plugin } from '@milkdown/kit/prose/state';
34
39
  import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
35
40
  import { $prose, getMarkdown } from '@milkdown/kit/utils';
36
41
  import { cn } from 'cn';
@@ -40,7 +45,10 @@ import { Input } from '../ui/input.js';
40
45
  import { Popover, PopoverContent } from '../ui/popover.js';
41
46
  import { linkHref } from '../../lib/link-href.js';
42
47
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
48
+ import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
43
49
  import { toggleFormat } from './rich-text-formats.js';
50
+ import { applySlash, closeSlash, slashItems, slashOptionId, slashPlugin, slashQuery, } from './rich-text-slash.js';
51
+ import { RichTextSlashMenu } from './rich-text-slash-menu.js';
44
52
  import { RichTextToolbar } from './rich-text-toolbar.js';
45
53
  // The commonmark preset, minus the plugin that writes `<br />`. A $remark
46
54
  // plugin is a pair (its options and the plugin), spread into `plugins`.
@@ -189,6 +197,24 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
189
197
  const [length, setLength] = useState(defaultValue.length);
190
198
  // The editor's state after each change, for the toolbar's pressed buttons.
191
199
  const [editorState, setEditorState] = useState(null);
200
+ const [editorView, setEditorView] = useState(null);
201
+ // For the floating toolbar: focus is in the text (or on the floating
202
+ // toolbar), the mouse is down choosing words, and the selection Esc hid
203
+ // it for.
204
+ const [focused, setFocused] = useState(false);
205
+ const [selecting, setSelecting] = useState(false);
206
+ const [dismissed, setDismissed] = useState(null);
207
+ const toolbarRef = useRef(null);
208
+ const floatingRef = useRef(null);
209
+ // The field's element, which the floating toolbar is placed against.
210
+ const [field, setField] = useState(null);
211
+ // Whether the floating toolbar shows, for the editor's keys (set up once).
212
+ const floatingOpenRef = useRef(false);
213
+ // The slash menu's highlighted item, for the query it was moved on (a
214
+ // new query starts again from the first).
215
+ const [slashActive, setSlashActive] = useState({ key: '', index: 0 });
216
+ // The open slash menu, for the editor's keys (set up once).
217
+ const slashMenuRef = useRef(null);
192
218
  // Opens the link box: made with the editor, used by the toolbar's Link.
193
219
  const openLinkRef = useRef(() => { });
194
220
  // The latest props, for the editor's callbacks, which are set up once.
@@ -220,6 +246,23 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
220
246
  attributes['aria-invalid'] = 'true';
221
247
  if (placeholder)
222
248
  attributes['aria-placeholder'] = placeholder;
249
+ if (toolbar)
250
+ attributes['aria-keyshortcuts'] = 'Alt+F10';
251
+ // The slash menu: open while a "/" typed at a line's start is followed
252
+ // by something some block answers to, and the field has focus.
253
+ const slash = toolbar && !readOnly && focused && editorState ? slashQuery(editorState) : null;
254
+ const slashMatches = slash ? slashItems(slash.query) : [];
255
+ const slashOpen = slash !== null && slashMatches.length > 0;
256
+ const slashMenuKey = slash ? `${slash.from}:${slash.query}` : '';
257
+ const slashIndex = slashActive.key === slashMenuKey ? Math.min(slashActive.index, slashMatches.length - 1) : 0;
258
+ const slashListId = `${ids}-blocks`;
259
+ // The text is the combobox's input: it names the list and the
260
+ // highlighted item, and focus stays in it.
261
+ if (slashOpen) {
262
+ attributes['aria-autocomplete'] = 'list';
263
+ attributes['aria-controls'] = slashListId;
264
+ attributes['aria-activedescendant'] = slashOptionId(slashListId, slashIndex);
265
+ }
223
266
  const attributesKey = JSON.stringify(attributes);
224
267
  // The editor is made once, from the Markdown it opened with.
225
268
  useEffect(() => {
@@ -252,6 +295,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
252
295
  if (cancelled)
253
296
  return;
254
297
  setEditorState(view.state);
298
+ // Esc hides the floating toolbar for one selection only.
299
+ if (view.state.selection.empty)
300
+ setDismissed(null);
255
301
  if (!view.state.doc.eq(previous.doc))
256
302
  emit();
257
303
  },
@@ -276,6 +322,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
276
322
  };
277
323
  openLinkRef.current = openLink;
278
324
  // Paste, copy, the editor's own keys and the placeholder.
325
+ const slashing = $prose(() => slashPlugin());
279
326
  const input = $prose((ctx) => new Plugin({
280
327
  props: {
281
328
  // Plain text is read as Markdown, so `## Notes` or `**bold**`
@@ -310,7 +357,49 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
310
357
  : null;
311
358
  return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
312
359
  },
360
+ handleDOMEvents: {
361
+ focus: () => {
362
+ setFocused(true);
363
+ return false;
364
+ },
365
+ // Moving to the floating toolbar keeps it showing.
366
+ blur: (_view, event) => {
367
+ if (!floatingRef.current?.contains(event.relatedTarget))
368
+ setFocused(false);
369
+ return false;
370
+ },
371
+ // The floating toolbar waits for the mouse to be up: a drag
372
+ // is still choosing the words.
373
+ mousedown: (_view, event) => {
374
+ if (event.button !== 0)
375
+ return false;
376
+ setDismissed(null);
377
+ setSelecting(true);
378
+ window.addEventListener('mouseup', () => setSelecting(false), { once: true });
379
+ return false;
380
+ },
381
+ },
313
382
  handleKeyDown: (view, event) => {
383
+ setSelecting(false);
384
+ // Alt+F10: to the floating toolbar when it shows, else the
385
+ // fixed one (the convention editors share).
386
+ if (event.altKey && event.key === 'F10') {
387
+ const bar = floatingOpenRef.current ? floatingRef.current : toolbarRef.current;
388
+ const button = bar?.querySelector('button[tabindex="0"]');
389
+ if (!button)
390
+ return false;
391
+ event.preventDefault();
392
+ button.focus();
393
+ return true;
394
+ }
395
+ // Esc hides the floating toolbar first; only the next one
396
+ // reaches the field (editing in place gives up on it).
397
+ if (event.key === 'Escape' && floatingOpenRef.current) {
398
+ event.preventDefault();
399
+ const { from, to } = view.state.selection;
400
+ setDismissed({ from, to });
401
+ return true;
402
+ }
314
403
  const mod = event.metaKey || event.ctrlKey;
315
404
  if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
316
405
  event.preventDefault();
@@ -344,6 +433,34 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
344
433
  ...options,
345
434
  editable: () => !latest.current.readOnly,
346
435
  attributes,
436
+ // The slash menu's keys, as view props: before Milkdown's own
437
+ // keymap, whose Enter would split the line.
438
+ handleKeyDown: (view, event) => {
439
+ const menu = slashMenuRef.current;
440
+ if (!menu)
441
+ return false;
442
+ const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
443
+ const plain = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey;
444
+ if (step && plain) {
445
+ event.preventDefault();
446
+ const count = menu.items.length;
447
+ setSlashActive({ key: menu.key, index: (menu.index + step + count) % count });
448
+ return true;
449
+ }
450
+ if ((event.key === 'Enter' || event.key === 'Tab') && plain) {
451
+ event.preventDefault();
452
+ applySlash(view, menu.span, menu.items[menu.index].id);
453
+ return true;
454
+ }
455
+ // Esc closes the menu only: the field's own Esc (editing in
456
+ // place gives up the edit) waits for the next one.
457
+ if (event.key === 'Escape') {
458
+ event.preventDefault();
459
+ view.dispatch(closeSlash(view.state));
460
+ return true;
461
+ }
462
+ return false;
463
+ },
347
464
  nodeViews: {
348
465
  heading: headingView,
349
466
  list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
@@ -354,6 +471,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
354
471
  .use(gfm)
355
472
  .use(history)
356
473
  .use(changes)
474
+ .use(slashing)
357
475
  .use(input);
358
476
  void make.create().then((editor) => {
359
477
  if (cancelled) {
@@ -362,13 +480,16 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
362
480
  }
363
481
  editorRef.current = editor;
364
482
  baseline = editor.action(getMarkdown());
365
- setEditorState(editor.action((ctx) => ctx.get(editorViewCtx).state));
483
+ const view = editor.action((ctx) => ctx.get(editorViewCtx));
484
+ setEditorView(view);
485
+ setEditorState(view.state);
366
486
  if (autoFocus)
367
487
  editor.action((ctx) => ctx.get(editorViewCtx).focus());
368
488
  });
369
489
  return () => {
370
490
  cancelled = true;
371
491
  setEditorState(null);
492
+ setEditorView(null);
372
493
  const editor = editorRef.current;
373
494
  editorRef.current = null;
374
495
  if (editor)
@@ -402,11 +523,32 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
402
523
  view.focus();
403
524
  });
404
525
  };
526
+ // The floating toolbar: words selected, focus in the field, the mouse up,
527
+ // and nothing else open over the text.
528
+ const selection = editorState?.selection;
529
+ const floatingOpen = Boolean(toolbar &&
530
+ !readOnly &&
531
+ editorView &&
532
+ focused &&
533
+ !selecting &&
534
+ linkBox === null &&
535
+ selection &&
536
+ !selection.empty &&
537
+ !(selection instanceof NodeSelection) &&
538
+ !selection.$from.parent.type.spec.code &&
539
+ !(dismissed && dismissed.from === selection.from && dismissed.to === selection.to));
540
+ useEffect(() => {
541
+ floatingOpenRef.current = floatingOpen;
542
+ });
543
+ const returnToText = () => editorView?.focus();
544
+ useEffect(() => {
545
+ slashMenuRef.current = slashOpen && slash ? { key: slashMenuKey, span: slash, items: slashMatches, index: slashIndex } : null;
546
+ });
405
547
  const closeLink = () => {
406
548
  setLinkBox(null);
407
549
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
408
550
  };
409
- return (_jsxs(_Fragment, { children: [_jsxs("div", { "data-slot": "rich-text-editor", className: cn('flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', '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', className), children: [toolbar && (_jsx(RichTextToolbar, { state: editorState, readOnly: readOnly,
551
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: setField, "data-slot": "rich-text-editor", className: cn('relative flex w-full min-w-0 flex-col rounded-lg border border-input bg-transparent type-body text-foreground break-words transition-[border-color,box-shadow]', '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', className), children: [toolbar && (_jsx(RichTextToolbar, { ref: toolbarRef, state: editorState, readOnly: readOnly,
410
552
  // Only once the editor is made: until then the editable element,
411
553
  // and its id, aren't in the page (axe: aria-valid-attr-value).
412
554
  controls: editorState ? editableId : undefined, onFormat: runFormat })), _jsx("div", { ref: rootRef, className: cn('min-h-24 px-2.5 py-2',
@@ -421,7 +563,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
421
563
  // Raw HTML from the text: shown as its characters, never as markup.
422
564
  '[&_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',
423
565
  // The placeholder, in an empty document only (a decoration).
424
- '[&_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)]') })] }), 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
566
+ '[&_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)]') }), slashOpen && slash && editorView && (_jsx(RichTextSlashMenu, { id: slashListId, view: editorView, at: slash.to, field: field, items: slashMatches, active: slashIndex, onHighlight: (index) => setSlashActive({ key: slashMenuKey, index }), onPick: (format) => applySlash(editorView, slash, format) })), floatingOpen && editorView && editorState && (_jsx(RichTextFloatingToolbar, { ref: floatingRef, view: editorView, state: editorState, field: field, controls: editableId, onFormat: runFormat, onDismiss: () => setDismissed({ from: editorState.selection.from, to: editorState.selection.to }), onReturn: returnToText, onBlur: (event) => {
567
+ const next = event.relatedTarget;
568
+ if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
569
+ setFocused(false);
570
+ } }))] }), 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
425
571
  ? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
426
572
  : `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
427
573
  const editor = editorRef.current;
@@ -19,9 +19,12 @@ export type RichTextEditorProps = {
19
19
  */
20
20
  maxLength?: number;
21
21
  /**
22
- * The formatting toolbar above the text (bold, headings, lists, link, …),
23
- * each button with its shortcut. On by default; `false` leaves the text
24
- * alone, its shortcuts still working.
22
+ * The formatting toolbars: one fixed above the text (bold, headings,
23
+ * lists, link, …), each button with its shortcut, and one floating over
24
+ * selected words (bold to link). Alt+F10 reaches them from the text.
25
+ * With them, "/" at a line's start lists the blocks the line can become
26
+ * (the slash menu). On by default; `false` leaves the text alone, its
27
+ * shortcuts still working.
25
28
  */
26
29
  toolbar?: boolean;
27
30
  className?: string;
@@ -0,0 +1,25 @@
1
+ import { type FocusEvent, type Ref } from 'react';
2
+ import type { EditorState } from '@milkdown/kit/prose/state';
3
+ import type { EditorView } from '@milkdown/kit/prose/view';
4
+ import type { FormatId } from './rich-text-formats.js';
5
+ export interface RichTextFloatingToolbarProps {
6
+ /** The editor, to measure the selection. */
7
+ view: EditorView;
8
+ /** The editor's state now: the selection, and which formats are on. */
9
+ state: EditorState;
10
+ /** The field's element it's placed against (positioned: `relative`). */
11
+ field: HTMLElement | null;
12
+ /** The editable element's id, for `aria-controls`. */
13
+ controls: string;
14
+ /** A button pressed: run the format on the editor and give it focus back. */
15
+ onFormat: (id: FormatId) => void;
16
+ /** Esc: hide it until the selection changes. */
17
+ onDismiss: () => void;
18
+ /** Esc or Tab: focus back to the text. */
19
+ onReturn: () => void;
20
+ /** Focus leaving one of its buttons, for the editor to tell whether it left the field. */
21
+ onBlur: (event: FocusEvent) => void;
22
+ /** Its element, for Alt+F10 to reach it and for the editor's own blur. */
23
+ ref?: Ref<HTMLDivElement>;
24
+ }
25
+ export declare function RichTextFloatingToolbar({ view, state, field, controls, onFormat, onDismiss, onReturn, onBlur, ref, }: RichTextFloatingToolbarProps): import("react").JSX.Element;
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The rich-text editor's floating toolbar: the formats for selected words
3
+ // (Bold, Italic, Strikethrough, Code · Link), over the selection. The
4
+ // fixed toolbar (rich-text-toolbar.tsx) stays; this one saves the trip up
5
+ // to it. The editor decides when it shows (milkdown-editor.tsx):
6
+ //
7
+ // - While words are selected in the text and the field has focus, once
8
+ // the mouse is up (not while a drag is still choosing them). Not in a
9
+ // code block (no formats apply there), read-only, or with the link box
10
+ // open.
11
+ // - Above the selection, or below it when there's no room for it inside
12
+ // the text (the first line), so it never covers the fixed toolbar.
13
+ // - A mouse press never takes focus from the text, as on the fixed one.
14
+ // - Keyboard: Alt+F10 in the text reaches it; the arrow keys, Home and
15
+ // End move along it (one tab stop, as on the fixed one); Esc or Tab goes
16
+ // back to the text, the selection kept. Esc (here or in the text) hides
17
+ // it until the selection changes.
18
+ //
19
+ // It sits inside the field's own element, placed against it, not in a
20
+ // portal: it scrolls with the text, is part of the field for editing in
21
+ // place and inside a dialog, and needs none of a popover's focus guards
22
+ // (Base UI's Popover adds tabbable `aria-hidden` guards while focus is
23
+ // outside it, which for this toolbar is always, and axe fails them).
24
+ import { useLayoutEffect, useState } from 'react';
25
+ import { cn } from 'cn';
26
+ import { FormatButtons } from './rich-text-toolbar.js';
27
+ // Its gap from the words.
28
+ const GAP = 8;
29
+ // The selection's box on screen: the words themselves on one line, the
30
+ // text's width over several.
31
+ function selectionRect(view, from, to) {
32
+ const size = view.state.doc.content.size;
33
+ const start = view.coordsAtPos(Math.min(from, size), 1);
34
+ const end = view.coordsAtPos(Math.min(to, size), -1);
35
+ const top = Math.min(start.top, end.top);
36
+ const bottom = Math.max(start.bottom, end.bottom);
37
+ if (end.top < start.bottom)
38
+ return new DOMRect(Math.min(start.left, end.left), top, Math.abs(end.left - start.left), bottom - top);
39
+ const text = view.dom.getBoundingClientRect();
40
+ return new DOMRect(text.left, top, text.width, bottom - top);
41
+ }
42
+ // Where it goes, against `field` (its positioned parent): centred over
43
+ // the words, below them when there's no room above inside the text, and
44
+ // never past the field's sides.
45
+ function place(view, from, to, field, bar) {
46
+ const words = selectionRect(view, from, to);
47
+ const box = field.getBoundingClientRect();
48
+ const text = view.dom.getBoundingClientRect();
49
+ const { offsetWidth: width, offsetHeight: height } = bar;
50
+ const above = words.top - height - GAP >= text.top;
51
+ const top = above ? words.top - height - GAP : words.bottom + GAP;
52
+ const centre = words.left + words.width / 2 - width / 2;
53
+ const left = Math.max(0, Math.min(centre - box.left, box.width - width));
54
+ return { top: top - box.top, left, side: above ? 'top' : 'bottom' };
55
+ }
56
+ export function RichTextFloatingToolbar({ view, state, field, controls, onFormat, onDismiss, onReturn, onBlur, ref, }) {
57
+ const [bar, setBar] = useState(null);
58
+ const [placement, setPlacement] = useState(null);
59
+ const { from, to } = state.selection;
60
+ // Placed after each change (a mark makes the words wider), and again
61
+ // when the window's width reflows the text.
62
+ useLayoutEffect(() => {
63
+ if (!bar || !field)
64
+ return;
65
+ const update = () => setPlacement(place(view, from, to, field, bar));
66
+ update();
67
+ window.addEventListener('resize', update);
68
+ return () => window.removeEventListener('resize', update);
69
+ }, [view, state, from, to, field, bar]);
70
+ const onKeyDown = (event) => {
71
+ if (event.key === 'Escape') {
72
+ // Its own: not the field's (editing in place would give up the edit).
73
+ event.preventDefault();
74
+ event.stopPropagation();
75
+ onDismiss();
76
+ onReturn();
77
+ }
78
+ else if (event.key === 'Tab') {
79
+ event.preventDefault();
80
+ onReturn();
81
+ }
82
+ };
83
+ return (_jsx("div", { ref: (element) => {
84
+ setBar(element);
85
+ if (typeof ref === 'function')
86
+ ref(element);
87
+ else if (ref)
88
+ ref.current = element;
89
+ }, role: "toolbar", "aria-label": "Format selection", "aria-controls": controls, "data-slot": "rich-text-floating-toolbar", "data-side": placement?.side, style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex items-center gap-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10', 'animate-in fade-in-0 zoom-in-95 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
90
+ // Measured before it's seen, so it never shows in the wrong place.
91
+ !placement && 'invisible'), onKeyDown: onKeyDown, onBlur: onBlur, children: _jsx(FormatButtons, { formats: "selection", state: state, readOnly: false, onFormat: onFormat }) }));
92
+ }
@@ -0,0 +1,12 @@
1
+ import { type LucideIcon } from 'lucide-react';
2
+ import type { FormatId } from './rich-text-formats.js';
3
+ export type FormatItem = {
4
+ id: FormatId;
5
+ label: string;
6
+ icon: LucideIcon;
7
+ /** Milkdown's own shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd. */
8
+ keys?: string;
9
+ /** More for the tooltip than the shortcut. */
10
+ hint?: string;
11
+ };
12
+ export declare const FORMAT_GROUPS: FormatItem[][];
@@ -0,0 +1,27 @@
1
+ // The rich-text editor's formats as people see them: each one's name, icon
2
+ // and shortcut, in the groups the fixed toolbar shows. Shared by the
3
+ // toolbars (rich-text-toolbar.tsx) and the slash menu
4
+ // (rich-text-slash-menu.tsx).
5
+ import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
6
+ export const FORMAT_GROUPS = [
7
+ [
8
+ { id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
9
+ { id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
10
+ { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
11
+ { id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
12
+ ],
13
+ [
14
+ { id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
15
+ { id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
16
+ ],
17
+ [
18
+ { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
19
+ { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
20
+ { id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
21
+ ],
22
+ [
23
+ { id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
24
+ { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
25
+ ],
26
+ [{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
27
+ ];
@@ -0,0 +1,18 @@
1
+ import type { EditorView } from '@milkdown/kit/prose/view';
2
+ import type { FormatItem } from './rich-text-format-items.js';
3
+ import type { FormatId } from './rich-text-formats.js';
4
+ export interface RichTextSlashMenuProps {
5
+ /** The list's id, for the text's `aria-controls`. */
6
+ id: string;
7
+ view: EditorView;
8
+ /** The caret: where it's placed. */
9
+ at: number;
10
+ /** The field's element it's placed against (positioned: `relative`). */
11
+ field: HTMLElement | null;
12
+ items: FormatItem[];
13
+ /** The highlighted item: Enter picks it. */
14
+ active: number;
15
+ onHighlight: (index: number) => void;
16
+ onPick: (id: FormatId) => void;
17
+ }
18
+ export declare function RichTextSlashMenu({ id, view, at, field, items, active, onHighlight, onPick }: RichTextSlashMenuProps): import("react").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The rich-text editor's slash menu: the blocks a paragraph can become
3
+ // (Heading to Code block), listed under the caret after a "/" typed at a
4
+ // line's start (rich-text-slash.ts). ARIA's combobox pattern with the
5
+ // text as the input: focus never leaves the text. The text names this
6
+ // list (`aria-controls`) and the highlighted item
7
+ // (`aria-activedescendant`); the arrow keys move the highlight, Enter or
8
+ // Tab picks, Esc closes it and leaves what was typed (milkdown-editor.tsx).
9
+ //
10
+ // Placed against the field's element like the floating toolbar, not in a
11
+ // portal (rich-text-floating-toolbar.tsx says why): under the caret, or
12
+ // over it when the window has no room below.
13
+ import { useLayoutEffect, useState } from 'react';
14
+ import { cn } from 'cn';
15
+ import { slashOptionId } from './rich-text-slash.js';
16
+ // Its gap from the caret's line.
17
+ const GAP = 4;
18
+ function place(view, at, field, menu) {
19
+ const caret = view.coordsAtPos(at);
20
+ const box = field.getBoundingClientRect();
21
+ const { offsetWidth: width, offsetHeight: height } = menu;
22
+ const below = caret.bottom + GAP + height <= window.innerHeight || caret.top - GAP - height < 0;
23
+ const top = below ? caret.bottom + GAP : caret.top - GAP - height;
24
+ const left = Math.max(0, Math.min(caret.left - box.left, box.width - width));
25
+ return { top: top - box.top, left, side: below ? 'bottom' : 'top' };
26
+ }
27
+ export function RichTextSlashMenu({ id, view, at, field, items, active, onHighlight, onPick }) {
28
+ const [menu, setMenu] = useState(null);
29
+ const [placement, setPlacement] = useState(null);
30
+ // Placed as the caret moves and the list narrows, and again when the
31
+ // window's size changes.
32
+ useLayoutEffect(() => {
33
+ if (!menu || !field)
34
+ return;
35
+ const update = () => setPlacement(place(view, at, field, menu));
36
+ update();
37
+ window.addEventListener('resize', update);
38
+ return () => window.removeEventListener('resize', update);
39
+ }, [view, at, field, menu, items.length]);
40
+ // The highlighted item stays in sight.
41
+ useLayoutEffect(() => {
42
+ menu?.querySelector(`#${CSS.escape(slashOptionId(id, active))}`)?.scrollIntoView({ block: 'nearest' });
43
+ }, [menu, id, active]);
44
+ return (_jsx("div", { ref: setMenu, id: id, role: "listbox", "aria-label": "Blocks", "data-slot": "rich-text-slash-menu", "data-side": placement?.side, style: placement ? { top: placement.top, left: placement.left } : undefined, className: cn('absolute z-50 flex max-h-72 w-56 flex-col gap-0.5 overflow-y-auto rounded-lg bg-popover p-1 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10', 'animate-in fade-in-0 zoom-in-95 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2',
45
+ // Measured before it's seen, so it never shows in the wrong place.
46
+ !placement && 'invisible'), children: items.map((item, index) => {
47
+ const Icon = item.icon;
48
+ return (_jsxs("div", { id: slashOptionId(id, index), role: "option", "aria-selected": index === active, className: "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 select-none aria-selected:bg-muted",
49
+ // A press never takes focus from the text.
50
+ onMouseDown: (event) => event.preventDefault(), onMouseMove: () => index !== active && onHighlight(index), onClick: () => onPick(item.id), children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0" }), item.label] }, item.id));
51
+ }) }));
52
+ }
@@ -0,0 +1,30 @@
1
+ import { Plugin, type EditorState, type Transaction } from '@milkdown/kit/prose/state';
2
+ import type { EditorView } from '@milkdown/kit/prose/view';
3
+ import { type FormatItem } from './rich-text-format-items.js';
4
+ import { type FormatId } from './rich-text-formats.js';
5
+ /** Where the "/" that opened the menu is, while it's open. */
6
+ type SlashState = {
7
+ from: number;
8
+ } | null;
9
+ /** The open menu's span in the text ("/" to the caret) and what's typed after the "/". */
10
+ export type SlashQuery = {
11
+ from: number;
12
+ to: number;
13
+ query: string;
14
+ };
15
+ /** The open menu's span and query, or null when it's closed. */
16
+ export declare function slashQuery(state: EditorState): SlashQuery | null;
17
+ /** The transaction that closes the menu, leaving what was typed as text (Esc). */
18
+ export declare function closeSlash(state: EditorState): Transaction;
19
+ /** The id of the menu's item at `index`, for the text's `aria-activedescendant`. */
20
+ export declare function slashOptionId(listId: string, index: number): string;
21
+ /** The plugin: opens on a typed "/", follows the text, closes once the span stops qualifying. */
22
+ export declare function slashPlugin(): Plugin<SlashState>;
23
+ /** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
24
+ export declare function slashItems(query: string): FormatItem[];
25
+ /**
26
+ * Picks an item: the "/…" goes and the paragraph becomes that block, in one
27
+ * transaction (one undo step, which brings the "/…" back).
28
+ */
29
+ export declare function applySlash(view: EditorView, span: SlashQuery, id: FormatId): void;
30
+ export {};
@@ -0,0 +1,111 @@
1
+ // The slash menu's logic: "/" typed at the start of a top-level paragraph
2
+ // opens it, what's typed after the "/" filters it, and picking an item
3
+ // turns the paragraph into that block, the "/…" gone. The menu itself:
4
+ // rich-text-slash-menu.tsx; its keys and ARIA: milkdown-editor.tsx.
5
+ //
6
+ // Only a "/" typed there opens it: one already in the text (a path,
7
+ // `/usr/bin`) never does, and neither does a "/" mid-line or inside a
8
+ // list, a quote or a code block.
9
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
10
+ import { FORMAT_GROUPS } from './rich-text-format-items.js';
11
+ import { toggleFormat } from './rich-text-formats.js';
12
+ const slashKey = new PluginKey('rich-text-slash');
13
+ // Longer than any item's name: past it, the person is writing, not picking.
14
+ const MAX_QUERY = 24;
15
+ // A top-level paragraph's start: where a "/" opens the menu.
16
+ function atParagraphStart(state, pos) {
17
+ const $pos = state.doc.resolve(pos);
18
+ return $pos.depth === 1 && $pos.parent.type.name === 'paragraph' && $pos.parentOffset === 0;
19
+ }
20
+ // The menu's span, if the "/" at `from` still opens it: the caret after it
21
+ // in the same paragraph, nothing selected, and what follows the "/" plain
22
+ // text that doesn't start with a space.
23
+ function queryAt(state, from) {
24
+ const { selection, doc } = state;
25
+ if (!selection.empty || from >= doc.content.size || !atParagraphStart(state, from))
26
+ return null;
27
+ const to = selection.from;
28
+ if (to <= from || !doc.resolve(from).sameParent(selection.$from))
29
+ return null;
30
+ const text = doc.textBetween(from, to, '\n', '\n');
31
+ if (!text.startsWith('/'))
32
+ return null;
33
+ const query = text.slice(1);
34
+ if (query.length > MAX_QUERY || /^\s|\n/.test(query))
35
+ return null;
36
+ return { from, to, query };
37
+ }
38
+ /** The open menu's span and query, or null when it's closed. */
39
+ export function slashQuery(state) {
40
+ const open = slashKey.getState(state);
41
+ return open ? queryAt(state, open.from) : null;
42
+ }
43
+ /** The transaction that closes the menu, leaving what was typed as text (Esc). */
44
+ export function closeSlash(state) {
45
+ return state.tr.setMeta(slashKey, 'close');
46
+ }
47
+ /** The id of the menu's item at `index`, for the text's `aria-activedescendant`. */
48
+ export function slashOptionId(listId, index) {
49
+ return `${listId}-${index}`;
50
+ }
51
+ /** The plugin: opens on a typed "/", follows the text, closes once the span stops qualifying. */
52
+ export function slashPlugin() {
53
+ return new Plugin({
54
+ key: slashKey,
55
+ state: {
56
+ init: () => null,
57
+ apply: (tr, value, _previous, next) => {
58
+ const meta = tr.getMeta(slashKey);
59
+ if (meta === 'close')
60
+ return null;
61
+ const from = meta ? meta.from : value ? tr.mapping.map(value.from) : null;
62
+ // Closed for good once it stops qualifying: the caret coming back
63
+ // later doesn't reopen it.
64
+ return from !== null && queryAt(next, from) ? { from } : null;
65
+ },
66
+ },
67
+ props: {
68
+ handleTextInput: (view, from, to, text) => {
69
+ if (text !== '/' || from !== to || !atParagraphStart(view.state, from))
70
+ return false;
71
+ view.dispatch(view.state.tr.insertText('/', from, to).setMeta(slashKey, { from }));
72
+ return true;
73
+ },
74
+ },
75
+ });
76
+ }
77
+ // What each block answers to besides its name.
78
+ const KEYWORDS = {
79
+ heading: ['h1', 'title'],
80
+ subheading: ['h2'],
81
+ bullets: ['ul', 'unordered', 'bullet'],
82
+ numbers: ['ol', 'ordered', 'number'],
83
+ tasks: ['todo', 'checkbox', 'checklist'],
84
+ quote: ['blockquote'],
85
+ codeBlock: ['pre'],
86
+ };
87
+ // The menu's items: the blocks, in the toolbar's order.
88
+ const BLOCKS = FORMAT_GROUPS.flat().filter((item) => KEYWORDS[item.id]);
89
+ /** The blocks matching what's typed after the "/": in the name, or the start of a keyword. */
90
+ export function slashItems(query) {
91
+ const typed = query.trim().toLowerCase();
92
+ if (!typed)
93
+ return BLOCKS;
94
+ return BLOCKS.filter((item) => item.label.toLowerCase().includes(typed) || KEYWORDS[item.id].some((word) => word.startsWith(typed)));
95
+ }
96
+ /**
97
+ * Picks an item: the "/…" goes and the paragraph becomes that block, in one
98
+ * transaction (one undo step, which brings the "/…" back).
99
+ */
100
+ export function applySlash(view, span, id) {
101
+ const tr = view.state.tr.delete(span.from, span.to).setMeta(slashKey, 'close');
102
+ let formatted = null;
103
+ toggleFormat(view.state.apply(tr), (next) => (formatted = next), id);
104
+ const done = formatted;
105
+ if (done) {
106
+ for (const step of done.steps)
107
+ tr.step(step);
108
+ tr.setSelection(done.selection.getBookmark().resolve(tr.doc));
109
+ }
110
+ view.dispatch(tr.scrollIntoView());
111
+ }
@@ -1,5 +1,21 @@
1
+ import { type Ref } from 'react';
1
2
  import type { EditorState } from '@milkdown/kit/prose/state';
3
+ import { type FormatItem } from './rich-text-format-items.js';
2
4
  import { type FormatId } from './rich-text-formats.js';
5
+ declare const SETS: {
6
+ all: FormatItem[][];
7
+ selection: FormatItem[][];
8
+ };
9
+ export type FormatButtonsProps = {
10
+ /** Every format, or the ones for selected words. */
11
+ formats: keyof typeof SETS;
12
+ /** The editor's state now, to show which formats are on. Null until it's made. */
13
+ state: EditorState | null;
14
+ readOnly: boolean;
15
+ /** A button pressed: run the format on the editor and give it focus back. */
16
+ onFormat: (id: FormatId) => void;
17
+ };
18
+ export declare function FormatButtons({ formats, state, readOnly, onFormat }: FormatButtonsProps): import("react").JSX.Element;
3
19
  export interface RichTextToolbarProps {
4
20
  /** The editor's state now, to show which formats are on. Null until it's made. */
5
21
  state: EditorState | null;
@@ -8,5 +24,8 @@ export interface RichTextToolbarProps {
8
24
  controls?: string;
9
25
  /** A button pressed: run the format on the editor and give it focus back. */
10
26
  onFormat: (id: FormatId) => void;
27
+ /** The toolbar's element, for Alt+F10 to reach it from the text. */
28
+ ref?: Ref<HTMLDivElement>;
11
29
  }
12
- export declare function RichTextToolbar({ state, readOnly, controls, onFormat }: RichTextToolbarProps): import("react").JSX.Element;
30
+ export declare function RichTextToolbar({ state, readOnly, controls, onFormat, ref }: RichTextToolbarProps): import("react").JSX.Element;
31
+ export {};
@@ -10,35 +10,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  // (`aria-pressed`), and names its shortcut (`aria-keyshortcuts`, and the
11
11
  // tooltip).
12
12
  // - Read-only (while a save is in flight), every button is disabled.
13
- import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
13
+ // - Alt+F10 in the text reaches it (the floating toolbar first, when it's
14
+ // showing: rich-text-floating-toolbar.tsx).
14
15
  import { Fragment, useRef, useState } from 'react';
15
16
  import { Separator } from '../ui/separator.js';
16
17
  import { Toggle } from '../ui/toggle.js';
17
18
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
19
+ import { FORMAT_GROUPS } from './rich-text-format-items.js';
18
20
  import { formatOn } from './rich-text-formats.js';
19
- const GROUPS = [
20
- [
21
- { id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
22
- { id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
23
- { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
24
- { id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
25
- ],
26
- [
27
- { id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
28
- { id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
29
- ],
30
- [
31
- { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
32
- { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
33
- { id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
34
- ],
35
- [
36
- { id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
37
- { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
38
- ],
39
- [{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
40
- ];
41
- const ITEMS = GROUPS.flat();
42
21
  function isApple() {
43
22
  return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
44
23
  }
@@ -56,13 +35,24 @@ function shownKeys(keys, apple) {
56
35
  .map((part) => symbols[part] ?? part)
57
36
  .join('');
58
37
  }
59
- export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
38
+ // Which buttons a toolbar has: every format (the fixed one), or the ones
39
+ // that apply to selected words (the floating one).
40
+ const SETS = {
41
+ all: FORMAT_GROUPS,
42
+ selection: [FORMAT_GROUPS[0], FORMAT_GROUPS[4]],
43
+ };
44
+ // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
45
+ // toolbar pattern); the arrow keys, Home and End move along the row. The
46
+ // toolbar's element (`role="toolbar"`, its name) is the caller's.
47
+ export function FormatButtons({ formats, state, readOnly, onFormat }) {
60
48
  const apple = isApple();
49
+ const groups = SETS[formats];
50
+ const items = groups.flat();
61
51
  const buttons = useRef([]);
62
52
  // The one button Tab reaches: the last one used.
63
53
  const [current, setCurrent] = useState(0);
64
54
  const move = (index) => {
65
- const next = (index + ITEMS.length) % ITEMS.length;
55
+ const next = (index + items.length) % items.length;
66
56
  setCurrent(next);
67
57
  buttons.current[next]?.focus();
68
58
  };
@@ -71,7 +61,7 @@ export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
71
61
  ArrowRight: () => move(current + 1),
72
62
  ArrowLeft: () => move(current - 1),
73
63
  Home: () => move(0),
74
- End: () => move(ITEMS.length - 1),
64
+ End: () => move(items.length - 1),
75
65
  };
76
66
  const action = keys[event.key];
77
67
  if (!action || event.metaKey || event.ctrlKey || event.altKey)
@@ -79,20 +69,23 @@ export function RichTextToolbar({ state, readOnly, controls, onFormat }) {
79
69
  event.preventDefault();
80
70
  action();
81
71
  };
82
- return (_jsx(TooltipProvider, { delay: 400, children: _jsx("div", { role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", onKeyDown: onKeyDown, children: GROUPS.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
83
- const index = ITEMS.indexOf(item);
84
- const Icon = item.icon;
85
- const tip = [
86
- item.label,
87
- item.keys && `(${shownKeys(item.keys, apple)})`,
88
- item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
89
- ]
90
- .filter(Boolean)
91
- .join(' ');
92
- return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
93
- buttons.current[index] = element;
94
- }, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
95
- // A mouse press keeps focus, and the selection, in the text.
96
- onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
97
- })] }, groupIndex))) }) }));
72
+ return (_jsx(TooltipProvider, { delay: 400, children: groups.map((group, groupIndex) => (_jsxs(Fragment, { children: [groupIndex > 0 && _jsx(Separator, { orientation: "vertical", className: "mx-1 h-5 self-center" }), group.map((item) => {
73
+ const index = items.indexOf(item);
74
+ const Icon = item.icon;
75
+ const tip = [
76
+ item.label,
77
+ item.keys && `(${shownKeys(item.keys, apple)})`,
78
+ item.hint && `· ${item.hint.replace(/Mod\+Shift\+Enter/, shownKeys('Mod+Shift+Enter', apple))}`,
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: _jsx(Toggle, { ref: (element) => {
83
+ buttons.current[index] = element;
84
+ }, size: "icon-sm", "aria-label": item.label, "aria-keyshortcuts": item.keys ? ariaKeys(item.keys, apple) : undefined, pressed: state ? formatOn(state, item.id) : false, disabled: readOnly, tabIndex: index === current ? 0 : -1, className: "aria-pressed:bg-muted aria-pressed:text-foreground",
85
+ // A mouse press keeps focus, and the selection, in the text.
86
+ onMouseDown: (event) => event.preventDefault(), onFocus: () => setCurrent(index), onKeyDown: onKeyDown, onPressedChange: () => onFormat(item.id) }), children: _jsx(Icon, {}) }), _jsx(TooltipContent, { children: tip })] }, item.id));
87
+ })] }, groupIndex))) }));
88
+ }
89
+ export function RichTextToolbar({ state, readOnly, controls, onFormat, ref }) {
90
+ return (_jsx("div", { ref: ref, role: "toolbar", "aria-label": "Formatting", "aria-controls": controls, "data-slot": "rich-text-toolbar", className: "flex flex-wrap items-center gap-0.5 border-b border-input px-1 py-1", children: _jsx(FormatButtons, { formats: "all", state: state, readOnly: readOnly, onFormat: onFormat }) }));
98
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.16.2",
3
+ "version": "3.18.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": {