@estiva-app/ui 0.57.1 → 0.57.3

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.
@@ -1,4 +1,4 @@
1
- import type { SuggestionOptions } from '@tiptap/suggestion';
1
+ import { type SuggestionOptions } from '@tiptap/suggestion';
2
2
  import { type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu';
3
3
  /**
4
4
  * Open, or closed less than 100ms ago — what an Enter handler asks. Choosing a
@@ -1 +1 @@
1
- {"version":3,"file":"suggestionPopup.d.ts","sourceRoot":"","sources":["../src/suggestionPopup.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAA0B,iBAAiB,EAAmB,MAAM,oBAAoB,CAAA;AACpG,OAAO,EAA6C,KAAK,iBAAiB,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAchI;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED,mFAAmF;AACnF,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACvC,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;IACjB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,qBAAqB,CAAC,CAAC,CAAC,EAAE,CAAA;IAC7E,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAA;IAC5B,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,iBAAiB,CAAA;IACnC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IAChD,mGAAmG;IACnG,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,CAAC,EAAE,CAAA;CACjC;AA0BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAmDlH"}
1
+ {"version":3,"file":"suggestionPopup.d.ts","sourceRoot":"","sources":["../src/suggestionPopup.tsx"],"names":[],"mappings":"AAGA,OAAO,EAA+C,KAAK,iBAAiB,EAAwB,MAAM,oBAAoB,CAAA;AAC9H,OAAO,EAA6C,KAAK,iBAAiB,EAAE,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAqBhI;;;GAGG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAE1C;AAED,mFAAmF;AACnF,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACvC,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAA;IACjB,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAA;IACjB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,qBAAqB,CAAC,CAAC,CAAC,EAAE,CAAA;IAC7E,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,CAAA;IAC5B,GAAG,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,iBAAiB,CAAA;IACnC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAA;IAChD,mGAAmG;IACnG,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,CAAC,EAAE,CAAA;CACjC;AA0BD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAsFlH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.57.1",
3
+ "version": "0.57.3",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.57.1",
7
+ "packageVersion": "0.57.3",
8
8
  "exports": 130,
9
9
  "typeExports": 134,
10
10
  "files": null
@@ -77,6 +77,14 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
77
77
  functions, so they read the app's current data. `@` shows the first six
78
78
  names holding the query; `[` the last six whose label or `search` (default:
79
79
  `snippet`) holds it.
80
+ - **A query can be more than one word.** A space does not close a list, so a
81
+ name or a title is typed whole. The list hides while nothing matches and
82
+ comes back if the words match again; it stops listening at `]` (for `[`),
83
+ at another `@` (for `@` and `!@`), at a new line, at Escape for the rest of
84
+ that query, at a pick, or when the caret leaves. A space straight after the
85
+ key is prose ("meet @ 5pm", "- [ ] task") and opens nothing. A long query reaches
86
+ `sections(query)` on every keystroke: an app that asks a server for it
87
+ pauses first, as `referenceSearch` does.
80
88
  - **`[` in sections, filled in as answers land:** pass `sections(query)` in
81
89
  place of `items` — labelled groups the app has already narrowed and ordered;
82
90
  an empty group is not drawn — and `subscribe(listener)`, which the app calls
@@ -90,7 +98,12 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
90
98
  - **A row that opens another list** runs `typeTrigger('@')`: it types the key
91
99
  once the `/query` is gone.
92
100
  - An open list is reported by `isSuggestionActive()`, so an editor that sends
93
- on Enter does not send on the Enter that picked a row.
101
+ on Enter does not send on the Enter that picked a row. A list counts only
102
+ while it shows rows: with none, Enter sends and Escape is the app's.
103
+ - **Leaving the box ends the list.** Click or Tab into another box and the
104
+ list closes as it does on Escape, so that box's Escape is its own; coming
105
+ back finds no list, and Enter sends. A press inside the list (its scrollbar)
106
+ keeps the caret in the text, and switching to another window keeps the list.
94
107
 
95
108
  <Canvas of={ComposerTriggersStories.CodeAsABlock} />
96
109
 
@@ -110,6 +123,8 @@ says, the tags it earns and sending stay the app's.
110
123
  | `!@` | Opens the urgent list |
111
124
  | `[` | Opens the reference list |
112
125
  | `/` after a space or at the line's start | Opens Format and the app's sections |
126
+ | Space | Part of the query: the list stays open |
127
+ | `]` after `[` | Ends the query; what was typed stays text |
113
128
  | ↑ ↓ | Move the highlight, wrapping at the ends |
114
- | Enter | Inserts the highlighted row, or runs it |
115
- | Escape | Closes the list |
129
+ | Enter | Inserts the highlighted row, or runs it; with no row, a new line |
130
+ | Escape | Closes the list until the caret leaves that query |
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { act } from 'react'
8
8
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
9
- import { render, screen, cleanup, within } from '@testing-library/react'
9
+ import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'
10
10
  import type { Editor } from '@tiptap/core'
11
11
  import { EditorContent, useEditor } from '@tiptap/react'
12
12
  import StarterKit from '@tiptap/starter-kit'
@@ -26,6 +26,7 @@ import {
26
26
  type MentionPerson,
27
27
  type SlashSection,
28
28
  } from './ComposerTriggers'
29
+ import { isSuggestionActive, isSuggestionOpen } from './suggestionPopup'
29
30
 
30
31
  const KEY = 'b'.repeat(64)
31
32
  const PEOPLE: MentionPerson[] = [
@@ -88,8 +89,13 @@ const nodes = () => {
88
89
  }
89
90
 
90
91
  // jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it.
92
+ // Nor `getClientRects`, which the editor asks for when it scrolls to the caret, after the call has returned.
91
93
  beforeAll(() => {
92
94
  Element.prototype.scrollIntoView = vi.fn()
95
+ Element.prototype.getClientRects = () => [] as unknown as DOMRectList
96
+ // …and a caret in text is measured through a Range.
97
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
98
+ Range.prototype.getBoundingClientRect = () => new DOMRect()
93
99
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
94
100
  })
95
101
 
@@ -206,6 +212,211 @@ describe('[', () => {
206
212
  })
207
213
  })
208
214
 
215
+ // f586437a (Miky, 2026-10-06): a space keeps the list listening; `]`, Escape, a pick or a new line ends it.
216
+ describe('more than one word', () => {
217
+ // The start of a title finds it, and so does a sentence holding it — so only the stop can close the list.
218
+ function Loose() {
219
+ const made = useEditor({
220
+ extensions: [
221
+ StarterKit,
222
+ Chip,
223
+ ReferenceTrigger.configure({
224
+ sections: (query) => [{ label: 'Things', items: ITEMS.filter((item) => item.snippet.startsWith(query) || query.includes(item.snippet)) }],
225
+ nodeName: 'messageMention',
226
+ ariaLabel: 'Things',
227
+ sectionLabel: 'Things',
228
+ }),
229
+ ],
230
+ })
231
+ editor = made
232
+ return <EditorContent editor={made} />
233
+ }
234
+
235
+ it('narrows [ across a space, and the pick replaces every word typed', async () => {
236
+ render(<Loose />)
237
+ await type('see [line')
238
+ expect(options()).toHaveLength(8)
239
+ await type(' 3')
240
+ expect(options()).toEqual(['Ada: line 3'])
241
+ await press('Enter')
242
+ expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[3].id, label: 'Ada', snippet: 'line 3', uri: ITEMS[3].uri } }])
243
+ // Nothing typed after `[` is left behind the chip.
244
+ expect(editor!.getText()).not.toMatch(/\[|line/)
245
+ })
246
+
247
+ it('hides the list while nothing matches and draws it again when the words do', async () => {
248
+ render(<Loose />)
249
+ await type('[line 9')
250
+ expect(screen.queryByRole('listbox')).toBeNull()
251
+ await act(async () => {
252
+ editor!.commands.deleteRange({ from: editor!.state.selection.from - 1, to: editor!.state.selection.from })
253
+ })
254
+ await type('3')
255
+ expect(options()).toEqual(['Ada: line 3'])
256
+ })
257
+
258
+ it('leaves Enter a new line while no row shows', async () => {
259
+ render(<Loose />)
260
+ await type('[line 9')
261
+ expect(screen.queryByRole('listbox')).toBeNull()
262
+ await press('Enter')
263
+ // No trigger took the key, so the editor's own Enter split the line.
264
+ expect(editor!.state.doc.childCount).toBe(2)
265
+ expect(nodes()).toEqual([])
266
+ })
267
+
268
+ it('stops at ], so a bracket in a sentence stays text', async () => {
269
+ render(<Loose />)
270
+ await type('see [line 3 above')
271
+ expect(options()).toEqual(['Ada: line 3'])
272
+ await type('] and more')
273
+ expect(screen.queryByRole('listbox')).toBeNull()
274
+ await press('Enter')
275
+ expect(nodes()).toEqual([])
276
+ })
277
+
278
+ it('stays closed after Escape for the rest of that bracket', async () => {
279
+ render(<Loose />)
280
+ await type('[line 3')
281
+ expect(options()).toEqual(['Ada: line 3'])
282
+ await press('Escape')
283
+ await type(' still line 3')
284
+ expect(screen.queryByRole('listbox')).toBeNull()
285
+ })
286
+
287
+ it('stops at a new line', async () => {
288
+ render(<Loose />)
289
+ await type('[line 3')
290
+ expect(options()).toEqual(['Ada: line 3'])
291
+ // A Shift+Enter line, inserted without the scroll jsdom cannot measure.
292
+ await act(async () => {
293
+ editor!.commands.insertContent([{ type: 'hardBreak' }, { type: 'text', text: 'line 3' }])
294
+ })
295
+ expect(screen.queryByRole('listbox')).toBeNull()
296
+ })
297
+
298
+ it('counts a list as open only while it shows rows, so an Enter with none still sends', async () => {
299
+ // The grace after the last test's list closed is module-wide: let it pass.
300
+ await new Promise((resolve) => setTimeout(resolve, 120))
301
+ render(<Composer />)
302
+ await type('meet @ad')
303
+ expect(isSuggestionOpen()).toBe(true)
304
+ await type(' at noon')
305
+ expect(screen.queryByRole('listbox')).toBeNull()
306
+ // A keystroke that left no rows closed nothing, so no grace either.
307
+ expect(isSuggestionOpen()).toBe(false)
308
+ expect(isSuggestionActive()).toBe(false)
309
+ // Words that match again count again.
310
+ await act(async () => {
311
+ editor!.commands.deleteRange({ from: editor!.state.selection.from - 8, to: editor!.state.selection.from })
312
+ })
313
+ await type('a lo')
314
+ expect(isSuggestionOpen()).toBe(true)
315
+ })
316
+
317
+ it('takes a space straight after the key for prose, not a query', async () => {
318
+ render(<Composer />)
319
+ // Every two-word name holds a space, so "@ " would list them all.
320
+ await type('meet @ ')
321
+ expect(screen.queryByRole('listbox')).toBeNull()
322
+ await type('lovelace')
323
+ expect(screen.queryByRole('listbox')).toBeNull()
324
+ expect(isSuggestionOpen()).toBe(false)
325
+ })
326
+
327
+ it('opens nothing for a task box, "[ ]"', async () => {
328
+ render(<Loose />)
329
+ await type('[ ] line')
330
+ expect(screen.queryByRole('listbox')).toBeNull()
331
+ })
332
+
333
+ it('finds a person by first and last name, and a second @ starts over', async () => {
334
+ render(<Composer />)
335
+ // A row's text starts with its avatar's initials.
336
+ await type('@ada lo')
337
+ expect(options()).toEqual([expect.stringContaining('Ada Lovelace')])
338
+ await type('ve @be')
339
+ expect(options()).toEqual([expect.stringContaining('Bea')])
340
+ await press('Enter')
341
+ expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'bea', label: 'Bea', pubkey: null } }])
342
+ expect(editor!.getText()).toBe('@ada love @Bea ')
343
+ })
344
+ })
345
+
346
+ describe('leaving the box', () => {
347
+ // A page with a second box, as Ship's issue page: the message box and the description.
348
+ function Page() {
349
+ return (
350
+ <>
351
+ <Composer />
352
+ <input aria-label="Other box" />
353
+ </>
354
+ )
355
+ }
356
+ const other = () => screen.getByRole<HTMLInputElement>('textbox', { name: 'Other box' })
357
+
358
+ it('ends the list when focus moves to another box, and coming back finds none', async () => {
359
+ // The grace after the last test's list closed is module-wide: let it pass.
360
+ await new Promise((resolve) => setTimeout(resolve, 120))
361
+ render(<Page />)
362
+ await type('see [')
363
+ // The caret is in the text before it leaves (the focus command waits a frame).
364
+ await act(async () => editor!.view.focus())
365
+ expect(document.activeElement).toBe(editor!.view.dom)
366
+ expect(options()).toHaveLength(6)
367
+ expect(isSuggestionOpen()).toBe(true)
368
+ await act(async () => other().focus())
369
+ expect(screen.queryByRole('listbox')).toBeNull()
370
+ // So the other box's own Escape and Enter are its own — no grace either.
371
+ expect(isSuggestionOpen()).toBe(false)
372
+ expect(isSuggestionActive()).toBe(false)
373
+ // A click back in, as the view takes it (the focus command waits a frame).
374
+ await act(async () => editor!.view.focus())
375
+ expect(screen.queryByRole('listbox')).toBeNull()
376
+ // As after Escape: the rest of that bracket is text.
377
+ await type('line')
378
+ expect(screen.queryByRole('listbox')).toBeNull()
379
+ await press('Enter')
380
+ expect(nodes()).toEqual([])
381
+ })
382
+
383
+ it('keeps focus in the text when the list’s own panel is pressed (its scrollbar)', async () => {
384
+ render(<Page />)
385
+ await type('see [')
386
+ const panel = screen.getByRole('listbox').closest('[role="dialog"]')!
387
+ const pressed = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
388
+ panel.dispatchEvent(pressed)
389
+ expect(pressed.defaultPrevented).toBe(true)
390
+ // A press anywhere else on the page is left alone.
391
+ const elsewhere = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
392
+ other().dispatchEvent(elsewhere)
393
+ expect(elsewhere.defaultPrevented).toBe(false)
394
+ })
395
+
396
+ it('keeps the list when the window loses focus, not the page', async () => {
397
+ render(<Page />)
398
+ await type('see [')
399
+ // What a browser sends when another window takes focus: a blur, with the editor still the active element.
400
+ await act(async () => {
401
+ editor!.view.dom.dispatchEvent(new FocusEvent('blur'))
402
+ })
403
+ expect(document.activeElement).toBe(editor!.view.dom)
404
+ expect(options()).toHaveLength(6)
405
+ expect(isSuggestionOpen()).toBe(true)
406
+ })
407
+
408
+ it('still picks a row by mouse, which never takes focus from the text', async () => {
409
+ render(<Page />)
410
+ await type('see [')
411
+ const row = within(screen.getByRole('listbox')).getAllByRole('option')[2]
412
+ await act(async () => {
413
+ fireEvent.mouseDown(row)
414
+ })
415
+ expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[4].id, label: 'Ada', snippet: 'line 4', uri: ITEMS[4].uri } }])
416
+ expect(isSuggestionOpen()).toBe(false)
417
+ })
418
+ })
419
+
209
420
  describe('a chip through HTML (copy and paste)', () => {
210
421
  it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
211
422
  render(<Composer />)
@@ -81,10 +81,26 @@ function optionsOf<T>(editor: Editor, name: string): T | undefined {
81
81
  return editor.extensionManager.extensions.find((one) => one.name === name)?.options as T | undefined
82
82
  }
83
83
 
84
+ /**
85
+ * A list keeps listening across spaces, so a name or a title can be more than
86
+ * one word (f586437a, Miky 2026-10-06). It stops at a new line, Escape, a pick
87
+ * or the cursor leaving — Suggestion's own — and once the query holds `stop`:
88
+ * `]` for `[`, so "see [the notes] above" ends as text. While nothing matches,
89
+ * the list is hidden rather than closed, and comes back if the words match again.
90
+ * A space straight after the key is prose ("meet @ 5pm", "- [ ] task"), not a
91
+ * query: every two-word name holds a space.
92
+ */
93
+ const listening = (stop: string) => ({
94
+ allowSpaces: true,
95
+ shouldShow: ({ query }: { query: string }) => !/^\s/.test(query) && !query.includes(stop),
96
+ })
97
+
84
98
  /** `@`'s list and pick, or `!@`'s, for the node `name` — the extension's own, so a renamed one still finds its people. */
85
99
  function personSuggestion(urgent: boolean, name: string) {
86
100
  return {
87
101
  ...(urgent ? { char: '!@' } : {}),
102
+ // A second `@` starts another mention; the first one has ended.
103
+ ...listening('@'),
88
104
  items: ({ query, editor }: { query: string; editor: Editor }) => filterPeople(optionsOf<PersonMentionOptions>(editor, name)?.people() ?? [], query),
89
105
  render: suggestionPopup(peopleMenu(urgent)),
90
106
  command: ({ editor, range, props: person }: { editor: Editor; range: { from: number; to: number }; props: MentionPerson }) => {
@@ -366,6 +382,7 @@ export const ReferenceTrigger = Extension.create<ReferenceTriggerOptions>({
366
382
  editor: this.editor,
367
383
  char: '[',
368
384
  pluginKey: new PluginKey('referenceTrigger'),
385
+ ...listening(']'),
369
386
  // As a mention's: only where the chip may go.
370
387
  allow: ({ state, range }) => {
371
388
  const type = state.schema.nodes[options.nodeName]
@@ -138,6 +138,16 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
138
138
  if (!editorElement || !open) return
139
139
  editorElement.setAttribute('aria-activedescendant', `${base}-${highlight}`)
140
140
  }, [editorElement, open, base, highlight])
141
+ // A press anywhere in the panel — its padding, its scrollbar — keeps focus in
142
+ // the text, as a row's does: the editor ends its list when it loses focus.
143
+ // On the list's own mount, because the panel portals in after it opens.
144
+ const keepFocus = useCallback((list: HTMLDivElement | null) => {
145
+ const panel = list?.closest('[role="dialog"]')
146
+ if (!panel) return
147
+ const keep = (event: Event) => event.preventDefault()
148
+ panel.addEventListener('mousedown', keep)
149
+ return () => panel.removeEventListener('mousedown', keep)
150
+ }, [])
141
151
 
142
152
  const scrollRef = useCallback((el: HTMLElement | null) => {
143
153
  el?.scrollIntoView({ block: 'nearest' })
@@ -180,7 +190,7 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
180
190
  {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own.
181
191
  `gap-0.5`: 2px between rows, as a menu's — a section with a heading
182
192
  had them from `MenuSection`, and rows without one touched. */}
183
- <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
193
+ <div ref={keepFocus} id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
184
194
  {shown.map((section, i) => (
185
195
  <div key={section.label ?? i} className="contents">
186
196
  {i > 0 && <MenuSeparator />}
@@ -1,7 +1,7 @@
1
1
  // @estiva-escape(component-has-a-page, component-has-a-story): draws nothing of its own — it opens a SuggestionMenu from a Tiptap suggestion plugin, and SuggestionMenu's page and stories show it (UIG-31)
2
2
  import { forwardRef, useRef, useImperativeHandle } from 'react'
3
3
  import { ReactRenderer } from '@tiptap/react'
4
- import type { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
4
+ import { exitSuggestion, type SuggestionKeyDownProps, type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion'
5
5
  import { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
6
6
 
7
7
  /*
@@ -12,6 +12,13 @@ import { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuRow, type
12
12
  * moment while one hands over to the other (`/` choosing "Mention" types `@`).
13
13
  * An editor asks before it acts on Enter or Escape, so a key that chose a row
14
14
  * does not also send the message or cancel the edit.
15
+ *
16
+ * A list counts while it shows rows, not while its trigger listens: since a
17
+ * space stopped closing one (f586437a), "meet @ 5pm" leaves `@` listening
18
+ * behind a hidden list to the end of the line, and that Enter must still send.
19
+ *
20
+ * And only while its editor has focus (71a4577b): a list left by a click ends,
21
+ * or it would stay counted and swallow Escape in the box clicked into.
15
22
  */
16
23
  let openCount = 0
17
24
  let lastClose = 0
@@ -99,13 +106,45 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
99
106
  let container: HTMLDivElement | null = null
100
107
  let last: SuggestionProps<T> | null = null
101
108
  let unsubscribe: (() => void) | undefined
109
+ let counted = false
110
+ let unlisten: (() => void) | undefined
111
+ const count = (shown: boolean) => {
112
+ if (shown === counted) return
113
+ counted = shown
114
+ openCount = shown ? openCount + 1 : Math.max(0, openCount - 1)
115
+ }
116
+ // The plugin's state outlives a blur — no transaction runs — so leaving the
117
+ // box ends the query here, as Escape does: coming back finds no list, and
118
+ // Enter sends (Miky, 2026-10-06). Leaving the window is not leaving the box,
119
+ // and a press in the list's own panel never takes focus (SuggestionMenu).
120
+ const listen = (props: SuggestionProps<T>) => {
121
+ const { view } = props.editor
122
+ const dom = view.dom
123
+ const onBlur = () => {
124
+ // A window that loses focus leaves its active element where it was.
125
+ if (dom.contains(document.activeElement) || !last) return
126
+ const from = last.range.from
127
+ // Not a pick: no grace for the other box's Enter.
128
+ count(false)
129
+ for (const plugin of view.state.plugins) {
130
+ const state = plugin.getState(view.state) as { active?: boolean; range?: { from: number } } | undefined
131
+ if (state?.active && state.range?.from === from && plugin.spec.key) exitSuggestion(view, plugin.spec.key)
132
+ }
133
+ }
134
+ dom.addEventListener('blur', onBlur)
135
+ unlisten = () => dom.removeEventListener('blur', onBlur)
136
+ }
102
137
  const exit = () => {
103
138
  unsubscribe?.()
104
139
  unsubscribe = undefined
140
+ unlisten?.()
141
+ unlisten = undefined
105
142
  last = null
143
+ // The grace is for the Enter that picked a row and closed the list, not
144
+ // for a keystroke that only left it with no rows.
145
+ if (counted) lastClose = Date.now()
146
+ count(false)
106
147
  if (!component) return
107
- openCount = Math.max(0, openCount - 1)
108
- lastClose = Date.now()
109
148
  component.destroy()
110
149
  container?.remove()
111
150
  component = null
@@ -113,12 +152,13 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
113
152
  }
114
153
  return {
115
154
  onStart: (props) => {
116
- openCount++
155
+ count(props.items.length > 0)
117
156
  component = new ReactRenderer<SuggestionMenuHandle, Props<T>>(Popup as never, { props: { ...props, options }, editor: props.editor })
118
157
  container = document.createElement('div')
119
158
  document.body.appendChild(container)
120
159
  container.appendChild(component.element)
121
160
  last = props
161
+ listen(props)
122
162
  const { subscribe, refresh } = options
123
163
  if (subscribe && refresh) {
124
164
  // The plugin asks for items only when the query changes; an answer
@@ -126,12 +166,14 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
126
166
  unsubscribe = subscribe(() => {
127
167
  if (!last || !component) return
128
168
  last = { ...last, items: refresh(last.query) }
169
+ count(last.items.length > 0)
129
170
  component.updateProps({ ...last, options })
130
171
  })
131
172
  }
132
173
  },
133
174
  onUpdate: (props) => {
134
175
  last = props
176
+ count(props.items.length > 0)
135
177
  component?.updateProps({ ...props, options })
136
178
  },
137
179
  onKeyDown: ({ event }: SuggestionKeyDownProps) => {