@tristan2828/ui-foundation 3.17.0 → 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.
@@ -746,8 +746,11 @@ const price = editInPlace({
746
746
  words (bold, italic, strikethrough, code, link); Alt+F10 reaches the
747
747
  floating one, else the fixed one. Both are part of the field, so moving
748
748
  to them doesn't save, and Esc hides the floating one before it gives up
749
- the edit. `toolbar={false}` leaves both out where a value is short and
750
- the shortcuts are enough.
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.
751
754
  - **The save is the single-field save** (`useRecordUpdate` with
752
755
  `optimistic: false, toastOnError: false`, the entity's
753
756
  `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
@@ -26,6 +26,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
26
26
  // floating one over selected words (rich-text-floating-toolbar.tsx),
27
27
  // unless `toolbar={false}`. Alt+F10 reaches the floating one when it
28
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
31
+ // `toolbar={false}`.
29
32
  import { useEffect, useId, useRef, useState } from 'react';
30
33
  import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
31
34
  import { history } from '@milkdown/kit/plugin/history';
@@ -44,6 +47,8 @@ import { linkHref } from '../../lib/link-href.js';
44
47
  import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
45
48
  import { RichTextFloatingToolbar } from './rich-text-floating-toolbar.js';
46
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';
47
52
  import { RichTextToolbar } from './rich-text-toolbar.js';
48
53
  // The commonmark preset, minus the plugin that writes `<br />`. A $remark
49
54
  // plugin is a pair (its options and the plugin), spread into `plugins`.
@@ -205,6 +210,11 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
205
210
  const [field, setField] = useState(null);
206
211
  // Whether the floating toolbar shows, for the editor's keys (set up once).
207
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);
208
218
  // Opens the link box: made with the editor, used by the toolbar's Link.
209
219
  const openLinkRef = useRef(() => { });
210
220
  // The latest props, for the editor's callbacks, which are set up once.
@@ -238,6 +248,21 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
238
248
  attributes['aria-placeholder'] = placeholder;
239
249
  if (toolbar)
240
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
+ }
241
266
  const attributesKey = JSON.stringify(attributes);
242
267
  // The editor is made once, from the Markdown it opened with.
243
268
  useEffect(() => {
@@ -297,6 +322,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
297
322
  };
298
323
  openLinkRef.current = openLink;
299
324
  // Paste, copy, the editor's own keys and the placeholder.
325
+ const slashing = $prose(() => slashPlugin());
300
326
  const input = $prose((ctx) => new Plugin({
301
327
  props: {
302
328
  // Plain text is read as Markdown, so `## Notes` or `**bold**`
@@ -407,6 +433,34 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
407
433
  ...options,
408
434
  editable: () => !latest.current.readOnly,
409
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
+ },
410
464
  nodeViews: {
411
465
  heading: headingView,
412
466
  list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
@@ -417,6 +471,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
417
471
  .use(gfm)
418
472
  .use(history)
419
473
  .use(changes)
474
+ .use(slashing)
420
475
  .use(input);
421
476
  void make.create().then((editor) => {
422
477
  if (cancelled) {
@@ -486,6 +541,9 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
486
541
  floatingOpenRef.current = floatingOpen;
487
542
  });
488
543
  const returnToText = () => editorView?.focus();
544
+ useEffect(() => {
545
+ slashMenuRef.current = slashOpen && slash ? { key: slashMenuKey, span: slash, items: slashMatches, index: slashIndex } : null;
546
+ });
489
547
  const closeLink = () => {
490
548
  setLinkBox(null);
491
549
  editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
@@ -505,7 +563,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
505
563
  // Raw HTML from the text: shown as its characters, never as markup.
506
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',
507
565
  // The placeholder, in an empty document only (a decoration).
508
- '[&_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)]') }), 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) => {
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) => {
509
567
  const next = event.relatedTarget;
510
568
  if (!editorView.dom.contains(next) && !floatingRef.current?.contains(next))
511
569
  setFocused(false);
@@ -21,8 +21,10 @@ export type RichTextEditorProps = {
21
21
  /**
22
22
  * The formatting toolbars: one fixed above the text (bold, headings,
23
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. On
25
- * by default; `false` leaves the text alone, its shortcuts still working.
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.
26
28
  */
27
29
  toolbar?: boolean;
28
30
  className?: string;
@@ -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,16 +1,7 @@
1
- import { type LucideIcon } from 'lucide-react';
2
1
  import { type Ref } from 'react';
3
2
  import type { EditorState } from '@milkdown/kit/prose/state';
3
+ import { type FormatItem } from './rich-text-format-items.js';
4
4
  import { type FormatId } from './rich-text-formats.js';
5
- type FormatItem = {
6
- id: FormatId;
7
- label: string;
8
- icon: LucideIcon;
9
- /** Milkdown's own shortcut, as `aria-keyshortcuts` writes it with Mod for Ctrl/Cmd. */
10
- keys?: string;
11
- /** More for the tooltip than the shortcut. */
12
- hint?: string;
13
- };
14
5
  declare const SETS: {
15
6
  all: FormatItem[][];
16
7
  selection: FormatItem[][];
@@ -12,34 +12,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // - Read-only (while a save is in flight), every button is disabled.
13
13
  // - Alt+F10 in the text reaches it (the floating toolbar first, when it's
14
14
  // showing: rich-text-floating-toolbar.tsx).
15
- import { BoldIcon, CodeIcon, Heading1Icon, Heading2Icon, ItalicIcon, LinkIcon, ListChecksIcon, ListIcon, ListOrderedIcon, SquareCodeIcon, StrikethroughIcon, TextQuoteIcon, } from 'lucide-react';
16
15
  import { Fragment, useRef, useState } from 'react';
17
16
  import { Separator } from '../ui/separator.js';
18
17
  import { Toggle } from '../ui/toggle.js';
19
18
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip.js';
19
+ import { FORMAT_GROUPS } from './rich-text-format-items.js';
20
20
  import { formatOn } from './rich-text-formats.js';
21
- const GROUPS = [
22
- [
23
- { id: 'bold', label: 'Bold', icon: BoldIcon, keys: 'Mod+B' },
24
- { id: 'italic', label: 'Italic', icon: ItalicIcon, keys: 'Mod+I' },
25
- { id: 'strike', label: 'Strikethrough', icon: StrikethroughIcon, keys: 'Mod+Alt+X' },
26
- { id: 'code', label: 'Code', icon: CodeIcon, keys: 'Mod+E' },
27
- ],
28
- [
29
- { id: 'heading', label: 'Heading', icon: Heading1Icon, keys: 'Mod+Alt+1' },
30
- { id: 'subheading', label: 'Subheading', icon: Heading2Icon, keys: 'Mod+Alt+2' },
31
- ],
32
- [
33
- { id: 'bullets', label: 'Bulleted list', icon: ListIcon, keys: 'Mod+Alt+8' },
34
- { id: 'numbers', label: 'Numbered list', icon: ListOrderedIcon, keys: 'Mod+Alt+7' },
35
- { id: 'tasks', label: 'Task list', icon: ListChecksIcon, hint: 'tick an item: Mod+Shift+Enter' },
36
- ],
37
- [
38
- { id: 'quote', label: 'Quote', icon: TextQuoteIcon, keys: 'Mod+Shift+B' },
39
- { id: 'codeBlock', label: 'Code block', icon: SquareCodeIcon, keys: 'Mod+Alt+C' },
40
- ],
41
- [{ id: 'link', label: 'Link', icon: LinkIcon, keys: 'Mod+K' }],
42
- ];
43
21
  function isApple() {
44
22
  return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
45
23
  }
@@ -60,8 +38,8 @@ function shownKeys(keys, apple) {
60
38
  // Which buttons a toolbar has: every format (the fixed one), or the ones
61
39
  // that apply to selected words (the floating one).
62
40
  const SETS = {
63
- all: GROUPS,
64
- selection: [GROUPS[0], GROUPS[4]],
41
+ all: FORMAT_GROUPS,
42
+ selection: [FORMAT_GROUPS[0], FORMAT_GROUPS[4]],
65
43
  };
66
44
  // A toolbar's row of buttons: one tab stop, the last one used (ARIA's
67
45
  // toolbar pattern); the arrow keys, Home and End move along the row. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.17.0",
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": {