@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.
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/editor.js +38 -3
- package/dist/editor.js.map +2 -2
- package/dist/index.js +8 -1
- package/dist/index.js.map +2 -2
- package/dist/suggestionPopup.d.ts +1 -1
- package/dist/suggestionPopup.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/ComposerTriggers.mdx +18 -3
- package/src/ComposerTriggers.test.tsx +212 -1
- package/src/ComposerTriggers.tsx +17 -0
- package/src/SuggestionMenu.tsx +11 -1
- package/src/suggestionPopup.tsx +46 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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,
|
|
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
package/registry.json
CHANGED
package/src/ComposerTriggers.mdx
CHANGED
|
@@ -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 />)
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -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]
|
package/src/SuggestionMenu.tsx
CHANGED
|
@@ -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 />}
|
package/src/suggestionPopup.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) => {
|