@estiva-app/ui 0.44.1 → 0.46.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.
Files changed (82) hide show
  1. package/dist/CollapsibleSection.d.ts +7 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/InlineChip.d.ts +1 -1
  4. package/dist/NavItem.d.ts +11 -1
  5. package/dist/NavItem.d.ts.map +1 -1
  6. package/dist/SectionHeader.d.ts +9 -1
  7. package/dist/SectionHeader.d.ts.map +1 -1
  8. package/dist/SelectionToolbar.d.ts +62 -0
  9. package/dist/SelectionToolbar.d.ts.map +1 -0
  10. package/dist/SuggestionMenu.d.ts +75 -0
  11. package/dist/SuggestionMenu.d.ts.map +1 -0
  12. package/dist/editor.d.ts +12 -0
  13. package/dist/editor.d.ts.map +1 -0
  14. package/dist/editor.js +357 -0
  15. package/dist/editor.js.map +7 -0
  16. package/dist/eslint/index.js +4 -3
  17. package/dist/eslint/index.js.map +2 -2
  18. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +274 -133
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +3 -0
  24. package/dist/looks.d.ts.map +1 -1
  25. package/dist/registry/{app-YMABQII6.js → app-LDGOH6UW.js} +3 -3
  26. package/dist/registry/{build-3EANARLQ.js → build-P6X3PTZP.js} +6 -2
  27. package/dist/registry/build.d.ts +4 -0
  28. package/dist/registry/build.d.ts.map +1 -1
  29. package/dist/registry/{chunk-SIB44CES.js → chunk-P55P2QFD.js} +9 -4
  30. package/dist/registry/chunk-P55P2QFD.js.map +7 -0
  31. package/dist/registry/{chunk-EVJW26UR.js → chunk-TQ6UQER3.js} +10 -10
  32. package/dist/registry/chunk-TQ6UQER3.js.map +7 -0
  33. package/dist/registry/cli.js +2 -2
  34. package/dist/registry/index.js +2 -2
  35. package/dist/shortcuts.d.ts +27 -0
  36. package/dist/shortcuts.d.ts.map +1 -0
  37. package/dist/suggestionPopup.d.ts +41 -0
  38. package/dist/suggestionPopup.d.ts.map +1 -0
  39. package/package.json +27 -2
  40. package/registry.json +391 -11
  41. package/src/Chip.tsx +1 -1
  42. package/src/CollapsibleSection.mdx +5 -0
  43. package/src/CollapsibleSection.stories.tsx +5 -0
  44. package/src/CollapsibleSection.tsx +10 -3
  45. package/src/CommandPalette.tsx +1 -1
  46. package/src/InlineChip.test.tsx +1 -1
  47. package/src/InlineChip.tsx +1 -1
  48. package/src/NavItem.mdx +9 -0
  49. package/src/NavItem.stories.tsx +23 -0
  50. package/src/NavItem.tsx +56 -6
  51. package/src/Reaction.tsx +1 -1
  52. package/src/SectionHeader.mdx +9 -0
  53. package/src/SectionHeader.stories.tsx +15 -0
  54. package/src/SectionHeader.tsx +28 -7
  55. package/src/SelectionToolbar.mdx +78 -0
  56. package/src/SelectionToolbar.stories.tsx +63 -0
  57. package/src/SelectionToolbar.test.ts +105 -0
  58. package/src/SelectionToolbar.tsx +487 -0
  59. package/src/SuggestionMenu.mdx +107 -0
  60. package/src/SuggestionMenu.stories.tsx +202 -0
  61. package/src/SuggestionMenu.test.tsx +128 -0
  62. package/src/SuggestionMenu.tsx +189 -0
  63. package/src/editor.ts +11 -0
  64. package/src/eslint/no-rebuilt-behaviour.test.ts +1 -1
  65. package/src/eslint/no-rebuilt-behaviour.ts +2 -2
  66. package/src/eslint/no-restyled-part.ts +2 -1
  67. package/src/how.test.ts +1 -1
  68. package/src/index.ts +2 -0
  69. package/src/keptSelection.test.ts +67 -0
  70. package/src/looks.ts +4 -0
  71. package/src/registry/app.ts +8 -8
  72. package/src/registry/build.ts +10 -2
  73. package/src/registry/registry.test.ts +14 -9
  74. package/src/shortcuts.ts +40 -0
  75. package/src/suggestionPopup.tsx +119 -0
  76. package/stories/Choosing.mdx +2 -0
  77. package/tailwind-preset.js +1 -0
  78. package/tokens.css +19 -4
  79. package/dist/registry/chunk-EVJW26UR.js.map +0 -7
  80. package/dist/registry/chunk-SIB44CES.js.map +0 -7
  81. /package/dist/registry/{app-YMABQII6.js.map → app-LDGOH6UW.js.map} +0 -0
  82. /package/dist/registry/{build-3EANARLQ.js.map → build-P6X3PTZP.js.map} +0 -0
@@ -0,0 +1,487 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { Editor } from '@tiptap/react'
3
+ import { Extension } from '@tiptap/core'
4
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
5
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
6
+ import { IconBold, IconCode, IconItalic, IconLink, IconUnderline } from '@tabler/icons-react'
7
+ import { Form } from './Form'
8
+ import { Popover } from './Popover'
9
+ import { TextInput } from './TextInput'
10
+ import { Toolbar, ToolbarButton } from './Toolbar'
11
+ import { shortcutLabel } from './shortcuts'
12
+
13
+ /**
14
+ * The selection, kept visible while something else has the focus — Peek's
15
+ * `src/extensions/keptSelection.ts` (PEE-19), moved into the package with the
16
+ * `SelectionToolbar` that needs it (UIG-31, Katerina's ruling 29 September).
17
+ *
18
+ * The toolbar's address field takes the focus, and with it the document's
19
+ * one selection, so the words being linked lost their highlight the moment
20
+ * the field opened (measured 2026-09-07). Linear keeps it, and Katerina asked
21
+ * for the same. The editor's own selection never moved — ProseMirror keeps it
22
+ * while unfocused — so this only *draws* it: an inline decoration over the
23
+ * range, in `bg-selected` — the token Peek's composer paints `::selection` in,
24
+ * mapped through edits and cleared when the field closes.
25
+ *
26
+ * A decoration rather than a second selection, because a document has one
27
+ * and the field needs it. Editor-internal by design (the editor owns its own
28
+ * drawing); nothing here floats, focuses or handles a key.
29
+ */
30
+
31
+ export interface KeptRange {
32
+ from: number
33
+ to: number
34
+ }
35
+
36
+ /** The plugin's key: read what is being kept, or dispatch a change to it. */
37
+ export const keptSelectionKey = new PluginKey<KeptRange | null>('keptSelection')
38
+
39
+ /** The extension an editor registers so a selection can stay drawn while a field has the focus. */
40
+ export const KeptSelection = Extension.create({
41
+ name: 'keptSelection',
42
+
43
+ addProseMirrorPlugins() {
44
+ return [
45
+ new Plugin<KeptRange | null>({
46
+ key: keptSelectionKey,
47
+ state: {
48
+ init: () => null,
49
+ apply(tr, previous) {
50
+ const meta = tr.getMeta(keptSelectionKey) as KeptRange | null | undefined
51
+ if (meta !== undefined) return meta
52
+ if (!previous) return null
53
+ // Follow the words through an edit, so applying the link to them
54
+ // (or typing before them) does not leave the highlight behind.
55
+ return { from: tr.mapping.map(previous.from), to: tr.mapping.map(previous.to) }
56
+ },
57
+ },
58
+ props: {
59
+ decorations(state) {
60
+ const range = keptSelectionKey.getState(state)
61
+ if (!range || range.from === range.to) return null
62
+ return DecorationSet.create(state.doc, [
63
+ Decoration.inline(range.from, range.to, { class: 'kept-selection bg-bg-selected' }),
64
+ ])
65
+ },
66
+ },
67
+ }),
68
+ ]
69
+ },
70
+ })
71
+
72
+ /** Draw the selection as it is now, until `releaseSelection`. */
73
+ export function keepSelection(editor: Editor) {
74
+ const { from, to } = editor.state.selection
75
+ editor.view.dispatch(editor.state.tr.setMeta(keptSelectionKey, { from, to }))
76
+ }
77
+
78
+ /** Stop drawing it. Nothing kept, nothing dispatched. */
79
+ export function releaseSelection(editor: Editor) {
80
+ if (editor.isDestroyed || !keptSelectionKey.getState(editor.state)) return
81
+ editor.view.dispatch(editor.state.tr.setMeta(keptSelectionKey, null))
82
+ }
83
+
84
+ /** What is being drawn, or null. */
85
+ export function keptSelection(editor: Editor): KeptRange | null {
86
+ return keptSelectionKey.getState(editor.state) ?? null
87
+ }
88
+
89
+ /** A mark the strip can toggle — the suite's inline vocabulary (SPEC §13.1) less link and reference. */
90
+ export type MarkId = 'bold' | 'italic' | 'underline' | 'code'
91
+
92
+ const MARKS: Record<MarkId, { label: string; keys: string }> = {
93
+ bold: { label: 'Bold', keys: 'Mod-B' },
94
+ italic: { label: 'Italic', keys: 'Mod-I' },
95
+ underline: { label: 'Underline', keys: 'Mod-U' },
96
+ code: { label: 'Code', keys: 'Mod-E' },
97
+ }
98
+
99
+ /**
100
+ * Toggle one mark over the current selection. The core `toggleMark`, which
101
+ * each mark's own `toggleBold` and the like wrap: the package names no Tiptap
102
+ * extension, only the marks the suite's format has (SPEC §13.1).
103
+ */
104
+ function toggleMark(editor: Editor, id: MarkId): boolean {
105
+ return editor.chain().focus().toggleMark(id).run()
106
+ }
107
+
108
+ /** The address the selection sits in, or '' — what the link field opens with. */
109
+ export function currentLink(editor: Editor): string {
110
+ const href: unknown = editor.getAttributes('link').href
111
+ return typeof href === 'string' ? href : ''
112
+ }
113
+
114
+ /**
115
+ * What somebody typed into the link field → an `href`, or null when it cannot
116
+ * be one. A bare host (`google.com`) gets `https://`. A scheme is kept only when
117
+ * SPEC §13.1 lets a reader follow it (http, https, mailto); `javascript:` and
118
+ * the like are refused here. `localhost:5173` is a host and a port: a scheme is
119
+ * never followed by a digit.
120
+ */
121
+ export function normalizeHref(raw: string): string | null {
122
+ const s = raw.trim()
123
+ if (!s || /\s/.test(s)) return null
124
+ if (/^(https?:|mailto:)/i.test(s)) return s
125
+ if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(s)) return null
126
+ return `https://${s}`
127
+ }
128
+
129
+ /**
130
+ * Apply the link field to the selection. Empty removes the link; an address
131
+ * sets it, over the whole link when the caret sits inside one. `false` means
132
+ * the text could not be an address, and the field should stay open.
133
+ */
134
+ export function applyLink(editor: Editor, raw: string): boolean {
135
+ const chain = editor.chain().focus().extendMarkRange('link')
136
+ // The core commands the Link extension's `setLink` and `unsetLink` wrap, with
137
+ // the same meta; `normalizeHref` has already refused what it would refuse.
138
+ if (!raw.trim()) return chain.unsetMark('link', { extendEmptyMarkRange: true }).setMeta('preventAutolink', true).run()
139
+ const href = normalizeHref(raw)
140
+ if (!href) return false
141
+ return chain.setMark('link', { href }).setMeta('preventAutolink', true).run()
142
+ }
143
+
144
+ /**
145
+ * Bold / italic / underline / link over selected text — Peek's
146
+ * `SelectionToolbar` (PEE-18, PEE-19), moved into the package at Katerina's
147
+ * word (29 September, UIG-31) when Ship's description became its second user.
148
+ * Unchanged but for two props: `marks`, which buttons the strip has (Peek's
149
+ * three by default; Ship adds code), and `link`, whether it has the Link button
150
+ * and its field (Peek's editor has a link mark; an editor without one leaves it
151
+ * off). The editor must register `KeptSelection` when `link` is on.
152
+ *
153
+ * The marks apply to a run you have already written, which is why they are
154
+ * not in the `/` menu: typing `/` replaces the selection, so by definition
155
+ * there is nothing selected to apply them to.
156
+ *
157
+ * **The chrome is the shared `Popover`, anchored** (P26). It was a `Menu`, and
158
+ * it never was one: a menu gives its contents roving focus and typeahead,
159
+ * which would fight the link field this panel exists for. An anchored
160
+ * `Popover` is the mode built for exactly this — it takes no focus, so the
161
+ * caret stays in the text. It is laid out as a row instead of a column
162
+ * through `className`, which `cn` merges last. The panel still portals out of
163
+ * the composer, so no overflow clips it, and Floating UI clamps it on screen.
164
+ *
165
+ * **The anchor is the selection's own point** since 0.12.2. This toolbar is
166
+ * *centred* over the selection (her design, PEE-19, after Linear), and the
167
+ * package's `Popover` aligns left or right only — Base UI has `center`, the
168
+ * package does not expose it (ADOPTION **B21**). So the placement arithmetic
169
+ * and the hand-measured `SIZE` box stay for now, and the rect handed over is
170
+ * positioned so the panel lands on the same pixel it always has: the
171
+ * package's own 4px gap is subtracted from this file's 8px, and the rect's
172
+ * left edge is the panel's. When `align="center"` exists, `SIZE`, `GAP` and
173
+ * `placement` all go and the anchor becomes the selection's own rect.
174
+ *
175
+ * **Two faces** (PEE-19 — Katerina, 2026-09-07, after Linear). `marks` is the
176
+ * row of four buttons. `link` is one text field for the address: it opens from
177
+ * the Link button, and opens by itself when the settled selection sits inside
178
+ * a link, with that link's address in it. There is no open, embed or delete
179
+ * button, by her call: the field is the whole control.
180
+ *
181
+ * **What applies the field** (her rule, same day): Enter, and clicking away —
182
+ * anywhere outside the toolbar, the editor included. Enter on an empty field
183
+ * removes the link; clicking away with an empty field changes nothing, since
184
+ * removing is deliberate and leaving is not. Escape drops what was typed and
185
+ * puts the caret back. Once applied, the caret lands after the link, and
186
+ * typing goes on as plain text — the mark is not inclusive (editorKit).
187
+ *
188
+ * While the field has focus the editor has not, so everything below that
189
+ * hides the toolbar on blur or on an empty editor selection stands down in
190
+ * `link` mode. The editor keeps its selection while unfocused; `focus()` in
191
+ * the apply command puts it back.
192
+ *
193
+ * **Why it waits.** Showing on every selection change put the toolbar under the
194
+ * pointer mid-drag, and made it blink on a plain click — ProseMirror restores
195
+ * the previous selection when the editor takes focus and collapses it a tick
196
+ * later, so there is a real, non-empty range in between. Both go away by
197
+ * refusing to show while the pointer is down and re-reading the selection after
198
+ * a short settle rather than trusting the event that woke us.
199
+ */
200
+
201
+ const ICONS: Record<MarkId, typeof IconBold> = {
202
+ bold: IconBold,
203
+ italic: IconItalic,
204
+ underline: IconUnderline,
205
+ code: IconCode,
206
+ }
207
+
208
+ type Mode = 'marks' | 'link'
209
+
210
+ /** How long a selection must stand still before the toolbar appears. */
211
+ const SETTLE_MS = 250
212
+ const GAP = 8
213
+ /** The gap the package's `Popover` adds of its own, subtracted above so the
214
+ * panel keeps this file's 8px. Read from `Popover.tsx`, not guessed. */
215
+ const POPOVER_GAP = 4
216
+
217
+ export interface SelectionToolbarProps {
218
+ /** The editor whose selection the strip follows. `null` while it loads. */
219
+ editor: Editor | null
220
+ /** Which marks the strip toggles, in order. Default bold, italic, underline. */
221
+ marks?: readonly MarkId[]
222
+ /** The Link button and its address field. Needs a link mark in the editor and `KeptSelection`. Default off. */
223
+ link?: boolean
224
+ }
225
+
226
+ export function SelectionToolbar({ editor, marks = ['bold', 'italic', 'underline'], link = false }: SelectionToolbarProps) {
227
+ /** The selection's top edge and horizontal centre; the face decides the rest. */
228
+ const [anchor, setAnchor] = useState<{ top: number; centre: number } | null>(null)
229
+ const [mode, setMode] = useState<Mode>('marks')
230
+ const [url, setUrl] = useState('')
231
+ // Re-render as marks turn on and off while the caret moves through the
232
+ // selection, so the pressed states stay honest.
233
+ const [, setTick] = useState(0)
234
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
235
+ const pointerDown = useRef(false)
236
+ const field = useRef<HTMLInputElement>(null)
237
+ /** What the field holds while it is open; null while it is closed. The
238
+ * listeners below are bound once, so they read this rather than state. */
239
+ const draft = useRef<string | null>(null)
240
+ // Read by the same listeners. Kept in an effect, declared before the focus
241
+ // effect below so the ref already says `link` when the field's focus blurs
242
+ // the editor.
243
+ const modeRef = useRef<Mode>('marks')
244
+ useEffect(() => {
245
+ modeRef.current = mode
246
+ }, [mode])
247
+ // Read by `settle`, which is bound once: words already a link open the field only when there is one.
248
+ const linkOn = useRef(link)
249
+ linkOn.current = link
250
+
251
+ const hide = useCallback(() => {
252
+ clearTimeout(timer.current)
253
+ draft.current = null
254
+ if (editor) releaseSelection(editor)
255
+ setAnchor(null)
256
+ setMode('marks')
257
+ }, [editor])
258
+
259
+ /**
260
+ * Leaving the field applies it (Enter or a click away). An address links the
261
+ * words and the caret moves past them; an empty field on the way out is a
262
+ * shrug, not a removal. Idempotent: the outside click reaches this twice,
263
+ * once from the editor press and once from the Menu's own listener.
264
+ */
265
+ const commit = useCallback(() => {
266
+ const text = draft.current
267
+ if (text === null || !editor) return
268
+ draft.current = null
269
+ if (text.trim() && applyLink(editor, text)) {
270
+ editor.commands.setTextSelection(editor.state.selection.to)
271
+ }
272
+ hide()
273
+ }, [editor, hide])
274
+
275
+ /** Read the selection as it is *now* and place the toolbar, or hide it. */
276
+ const settle = useCallback(() => {
277
+ if (!editor || editor.isDestroyed) return
278
+ // Keeping the selection drawn is itself a transaction, which schedules
279
+ // this; by then the field has the focus, and reading `hasFocus()` here
280
+ // would close it.
281
+ if (modeRef.current === 'link') return
282
+ const { state, view } = editor
283
+ const { empty, from, to } = state.selection
284
+ if (empty || from === to || !view.hasFocus() || pointerDown.current) {
285
+ setAnchor(null)
286
+ setMode('marks')
287
+ return
288
+ }
289
+ const start = view.coordsAtPos(from)
290
+ const end = view.coordsAtPos(to)
291
+ const centre = (Math.min(start.left, end.left) + Math.max(start.right, end.right)) / 2
292
+ // Words that are already a link open on the field, with their address in it.
293
+ const inLink = linkOn.current && editor.isActive('link')
294
+ const address = inLink ? currentLink(editor) : ''
295
+ draft.current = inLink ? address : null
296
+ // The field is about to take the focus; the words stay highlighted.
297
+ if (inLink) keepSelection(editor)
298
+ setMode(inLink ? 'link' : 'marks')
299
+ setUrl(address)
300
+ setAnchor({ top: start.top, centre })
301
+ }, [editor])
302
+
303
+ useEffect(() => {
304
+ if (!editor) return
305
+
306
+ const schedule = () => {
307
+ // The field owns the keyboard and the focus; the editor's own events
308
+ // stand down until it closes.
309
+ if (modeRef.current === 'link') return
310
+ setTick((t) => t + 1)
311
+ clearTimeout(timer.current)
312
+ // A collapsed selection hides at once — waiting to hide is what made the
313
+ // click blink. Only *showing* is deferred.
314
+ if (editor.state.selection.empty) {
315
+ setAnchor(null)
316
+ return
317
+ }
318
+ timer.current = setTimeout(settle, SETTLE_MS)
319
+ }
320
+
321
+ // The field taking focus is the one blur that must not hide us.
322
+ const onBlur = () => {
323
+ if (modeRef.current !== 'link') hide()
324
+ }
325
+
326
+ // Only a press *inside the editor* starts a selection. A press on the
327
+ // toolbar itself must not hide it — doing so unmounted the button before
328
+ // its click could land, so nothing ever applied.
329
+ const startsSelection = (e: MouseEvent) =>
330
+ e.target instanceof Node && editor.view.dom.contains(e.target)
331
+
332
+ const onPointerDown = (e: MouseEvent) => {
333
+ if (!startsSelection(e)) return
334
+ // A press in the editor while the field is open is a click away: apply
335
+ // first, before the editor moves the caret under the press.
336
+ commit()
337
+ pointerDown.current = true
338
+ hide()
339
+ }
340
+ const onPointerUp = () => {
341
+ if (!pointerDown.current) return
342
+ pointerDown.current = false
343
+ schedule()
344
+ }
345
+
346
+ editor.on('selectionUpdate', schedule)
347
+ editor.on('transaction', schedule)
348
+ editor.on('blur', onBlur)
349
+ // @estiva-escape(no-hand-rolled-behaviour): watches presses in the editor to follow a text selection; the Popover it opens in has no trigger to own that (Peek's UIG-8 escape, moved here with the part, UIG-31)
350
+ document.addEventListener('mousedown', onPointerDown, true)
351
+ document.addEventListener('mouseup', onPointerUp, true)
352
+
353
+ return () => {
354
+ clearTimeout(timer.current)
355
+ editor.off('selectionUpdate', schedule)
356
+ editor.off('transaction', schedule)
357
+ editor.off('blur', onBlur)
358
+ document.removeEventListener('mousedown', onPointerDown, true)
359
+ document.removeEventListener('mouseup', onPointerUp, true)
360
+ }
361
+ }, [editor, settle, hide, commit])
362
+
363
+ // The field takes focus once it is on screen. Not `autoFocus`, and not in
364
+ // the same frame: the Menu renders once hidden to measure itself, and a
365
+ // hidden input cannot take focus — measured 2026-09-07, the field opened
366
+ // unfocused on a re-selected link. An address already in it is selected
367
+ // whole, so typing replaces it.
368
+ useEffect(() => {
369
+ if (mode !== 'link' || !anchor) return
370
+ const frame = requestAnimationFrame(() => {
371
+ field.current?.focus()
372
+ field.current?.select()
373
+ })
374
+ return () => cancelAnimationFrame(frame)
375
+ }, [mode, anchor])
376
+
377
+ if (!editor || !anchor) return null
378
+
379
+ const openField = () => {
380
+ const address = currentLink(editor)
381
+ draft.current = address
382
+ keepSelection(editor)
383
+ setUrl(address)
384
+ setMode('link')
385
+ }
386
+ /** Enter. Unlike a click away, an empty field here means remove. */
387
+ const submit = () => {
388
+ if (!url.trim()) {
389
+ applyLink(editor, '')
390
+ hide()
391
+ editor.commands.focus()
392
+ return
393
+ }
394
+ commit()
395
+ }
396
+ const discard = () => {
397
+ hide()
398
+ editor.commands.focus()
399
+ }
400
+
401
+ /* A zero-sized rect at the selection's own top edge and centre. The panel's
402
+ width is no longer this file's business: `align="center"` puts the
403
+ panel's middle over this point (B21, package 0.12.2), so the measured
404
+ `SIZE` table and the placement arithmetic that stood in for it are gone.
405
+ The top is this file's GAP less the package's own 4, so the panel keeps
406
+ the 8px it has always had. */
407
+ const anchorRect = new DOMRect(anchor.centre, anchor.top - (GAP - POPOVER_GAP), 0, 0)
408
+
409
+ return (
410
+ <Popover
411
+ anchor={anchorRect}
412
+ open
413
+ side="top"
414
+ align="center"
415
+ onOpenChange={(next) => {
416
+ if (!next) commit()
417
+ }}
418
+ ariaLabel="Text formatting"
419
+ // The panel gives up its column; the strip inside draws the row. The 4px
420
+ // around it is `contentClassName`'s: on `className` it was added to the
421
+ // package's 8px from 0.12.6 on, and the toolbar grew 8px a side
422
+ // (Katerina, 2026-09-13; migration docs D68, Finding 60). This is the
423
+ // package's own `Popover/A toolbar` story, class for class.
424
+ className="w-auto min-w-0"
425
+ contentClassName="p-1"
426
+ >
427
+ {mode === 'link' ? (
428
+ /* One field, so no strip: a toolbar of one control is a toolbar in
429
+ name only, and `ToolbarInput` cannot take the ref this field is
430
+ focused by (ADOPTION B25). */
431
+ // The package Form (UIG-7): Enter applies the link. `free` and `flex`: one field, which keeps its own height.
432
+ <Form onSubmit={submit} layout="free" className="flex">
433
+ <TextInput
434
+ ref={field}
435
+ value={url}
436
+ onChange={(e) => {
437
+ setUrl(e.target.value)
438
+ draft.current = e.target.value
439
+ }}
440
+ onKeyDown={(e) => {
441
+ if (e.key === 'Escape') {
442
+ // Before the Menu's own Escape, which would apply instead.
443
+ e.stopPropagation()
444
+ discard()
445
+ }
446
+ }}
447
+ placeholder="Enter link URL"
448
+ aria-label="Link URL"
449
+ className="w-[264px]"
450
+ />
451
+ </Form>
452
+ ) : (
453
+ /* Four marks and a link button: one Tab stop, ← → along them (D29). */
454
+ <Toolbar aria-label="Text formatting" surface={false}>
455
+ {marks.map((id) => {
456
+ const Icon = ICONS[id]
457
+ const active = editor.isActive(id)
458
+ return (
459
+ <ToolbarButton
460
+ key={id}
461
+ aria-label={MARKS[id].label}
462
+ pressed={active}
463
+ tooltip={MARKS[id].label}
464
+ tooltipShortcut={shortcutLabel(MARKS[id].keys)}
465
+ // Keep the selection: focus must not leave the editor on press.
466
+ onMouseDown={(e) => e.preventDefault()}
467
+ onClick={() => toggleMark(editor, id)}
468
+ >
469
+ <Icon size={16} stroke={1.5} />
470
+ </ToolbarButton>
471
+ )
472
+ })}
473
+ {link && (
474
+ <ToolbarButton
475
+ aria-label="Link"
476
+ tooltip="Link"
477
+ onMouseDown={(e) => e.preventDefault()}
478
+ onClick={openField}
479
+ >
480
+ <IconLink size={16} stroke={1.5} />
481
+ </ToolbarButton>
482
+ )}
483
+ </Toolbar>
484
+ )}
485
+ </Popover>
486
+ )
487
+ }
@@ -0,0 +1,107 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as SuggestionMenuStories from './SuggestionMenu.stories'
3
+
4
+ <Meta of={SuggestionMenuStories} />
5
+
6
+ # SuggestionMenu
7
+
8
+ The list a text editor opens as you type — `/` for commands, `@` for people —
9
+ while focus stays in the text.
10
+
11
+ <Canvas of={SuggestionMenuStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **A type-ahead inside an editor**: a character opens a list at the caret,
16
+ typing narrows it, ↑ ↓ Enter choose, and the words keep the caret the whole
17
+ time. This is the case it exists for.
18
+ - Sections with headings, divided by a hairline — commands grouped by what they
19
+ do. A section without a `label` is rows alone.
20
+ - Tall rows with a face and a second line, and Enter's hint on the highlighted
21
+ one — a list of people.
22
+
23
+ <Canvas of={SuggestionMenuStories.TallRows} />
24
+
25
+ ## When not
26
+
27
+ - A list you open with a button and choose from → **Menu**.
28
+ - One value from a set, in a form → **Select**.
29
+ - Searching across everything, in its own field → **CommandPalette**.
30
+ - Picking several values in a field of their own → **ChipInput**.
31
+
32
+ ## How
33
+
34
+ In an editor, from `@estiva-app/ui/editor`: `suggestionPopup` is the `render`
35
+ of a Tiptap suggestion plugin, and draws this list.
36
+
37
+ ```tsx
38
+ import Suggestion from '@tiptap/suggestion'
39
+ import { suggestionPopup } from '@estiva-app/ui/editor'
40
+
41
+ interface Command { id: string; label: string; keys?: string }
42
+
43
+ Suggestion<Command>({
44
+ editor: editor,
45
+ char: '/',
46
+ items: ({ query }) => commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase())),
47
+ command: ({ editor, range, props }) => run(editor, range, props),
48
+ render: suggestionPopup<Command>({
49
+ ariaLabel: 'Commands',
50
+ width: 'w-[300px]',
51
+ maxHeight: 'max-h-[400px]',
52
+ sections: (items) => [
53
+ { label: 'Format', items: items.filter(isFormat) },
54
+ { label: 'Insert', items: items.filter(isInsert) },
55
+ ],
56
+ itemKey: (c) => c.id,
57
+ row: (c) => ({ label: c.label, shortcut: c.keys }),
58
+ }),
59
+ })
60
+ ```
61
+
62
+ - **`row` returns what a row shows**: `label`, and any of `description`,
63
+ `leading` (a face, an icon), `shortcut` (the keys you could type instead, drawn
64
+ at the right) and `hint` (drawn while highlighted — `<EnterHint />`). `size:
65
+ 'tall'` for a row with a face and a second line.
66
+ - **`maxHeight` is load-bearing.** Without a cap, a long list is taller than the
67
+ room above the caret and below it, and the box goes to the side at full window
68
+ height. With it, the rows scroll inside the box.
69
+ - **Before an editor acts on Enter or Escape, it asks.** `isSuggestionActive()`
70
+ is true while a list is open and for 100ms after one closes, so the Enter that
71
+ chose a row does not also send. `isSuggestionOpen()` has no grace period, for
72
+ Escape: the first press closes the list, the second is somebody's own.
73
+ - **Outside an editor**, draw `SuggestionMenu` itself with a `rect` to hang from,
74
+ and call `handle.current.onKeyDown(event)` from wherever the keys arrive.
75
+
76
+ <Canvas of={SuggestionMenuStories.InAnEditor} />
77
+
78
+ ## What it owns
79
+
80
+ It does these by itself. Write none of them around it.
81
+
82
+ | It does | So you never write | Refused when written by hand |
83
+ |---|---|---|
84
+ | Moves through its items with the arrow keys, and wraps at the ends | an arrow-key handler | ✓ |
85
+ | Says what it is to assistive technology: a listbox of options, and the editor pointing at the active one | `role="listbox"`, `role="option"`, `aria-activedescendant` | ✓ |
86
+ | Hangs from the caret: above it, below when there is no room, never off the window | position arithmetic | |
87
+ | Scrolls the highlighted row into view, and back to the first row when the list changes | `scrollIntoView` | |
88
+ | Chooses on a press without taking focus from the text | `preventDefault` on each row | |
89
+
90
+ <Canvas of={SuggestionMenuStories.Scrolling} />
91
+
92
+ ## Keys
93
+
94
+ The keys arrive in the editor and are handed on.
95
+
96
+ | Input | What happens |
97
+ |---|---|
98
+ | ↓ / ↑ | the next or previous row, wrapping at the ends |
99
+ | Enter | chooses the highlighted row |
100
+ | Escape | closes the list; the next Escape is the editor's |
101
+ | any other key | stays the editor's — typing narrows the list |
102
+
103
+ <Canvas of={SuggestionMenuStories.DrivenByKeys} />
104
+
105
+ ## Props
106
+
107
+ <Controls of={SuggestionMenuStories.Default} />