@estiva-app/ui 0.57.2 → 0.57.4
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/Popover.d.ts +3 -2
- package/dist/Popover.d.ts.map +1 -1
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/editor.js +20 -0
- package/dist/editor.js.map +2 -2
- package/dist/index.js +14 -4
- 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 +2 -2
- package/src/ComposerTriggers.mdx +4 -0
- package/src/ComposerTriggers.test.tsx +78 -1
- package/src/Popover.mdx +3 -1
- package/src/Popover.tsx +3 -2
- package/src/SuggestionMenu.tsx +13 -2
- package/src/suggestionPopup.tsx +29 -1
|
@@ -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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"kind": "package",
|
|
5
5
|
"repo": "estiva-ui",
|
|
6
6
|
"package": "@estiva-app/ui",
|
|
7
|
-
"packageVersion": "0.57.
|
|
7
|
+
"packageVersion": "0.57.4",
|
|
8
8
|
"exports": 130,
|
|
9
9
|
"typeExports": 134,
|
|
10
10
|
"files": null
|
|
@@ -4428,7 +4428,7 @@
|
|
|
4428
4428
|
},
|
|
4429
4429
|
{
|
|
4430
4430
|
"name": "finalFocus",
|
|
4431
|
-
"takes": "RefObject<HTMLElement | null>",
|
|
4431
|
+
"takes": "RefObject<HTMLElement | null> | false",
|
|
4432
4432
|
"required": false,
|
|
4433
4433
|
"note": "Where focus goes when the panel closes. With a `trigger` it goes back to the trigger and this is not needed."
|
|
4434
4434
|
},
|
package/src/ComposerTriggers.mdx
CHANGED
|
@@ -100,6 +100,10 @@ const urgent = editor ? urgentPubkeys(editor) : undefined
|
|
|
100
100
|
- An open list is reported by `isSuggestionActive()`, so an editor that sends
|
|
101
101
|
on Enter does not send on the Enter that picked a row. A list counts only
|
|
102
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.
|
|
103
107
|
|
|
104
108
|
<Canvas of={ComposerTriggersStories.CodeAsABlock} />
|
|
105
109
|
|
|
@@ -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'
|
|
@@ -343,6 +343,83 @@ describe('more than one word', () => {
|
|
|
343
343
|
})
|
|
344
344
|
})
|
|
345
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
|
+
// The closing list does not pull focus back into the box that was left.
|
|
371
|
+
await act(async () => new Promise((resolve) => setTimeout(resolve, 50)))
|
|
372
|
+
expect(document.activeElement).toBe(other())
|
|
373
|
+
// So the other box's own Escape and Enter are its own — no grace either.
|
|
374
|
+
expect(isSuggestionOpen()).toBe(false)
|
|
375
|
+
expect(isSuggestionActive()).toBe(false)
|
|
376
|
+
// A click back in, as the view takes it (the focus command waits a frame).
|
|
377
|
+
await act(async () => editor!.view.focus())
|
|
378
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
379
|
+
// As after Escape: the rest of that bracket is text.
|
|
380
|
+
await type('line')
|
|
381
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
382
|
+
await press('Enter')
|
|
383
|
+
expect(nodes()).toEqual([])
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('keeps focus in the text when the list’s own panel is pressed (its scrollbar)', async () => {
|
|
387
|
+
render(<Page />)
|
|
388
|
+
await type('see [')
|
|
389
|
+
const panel = screen.getByRole('listbox').closest('[role="dialog"]')!
|
|
390
|
+
const pressed = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
|
391
|
+
panel.dispatchEvent(pressed)
|
|
392
|
+
expect(pressed.defaultPrevented).toBe(true)
|
|
393
|
+
// A press anywhere else on the page is left alone.
|
|
394
|
+
const elsewhere = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
|
395
|
+
other().dispatchEvent(elsewhere)
|
|
396
|
+
expect(elsewhere.defaultPrevented).toBe(false)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('keeps the list when the window loses focus, not the page', async () => {
|
|
400
|
+
render(<Page />)
|
|
401
|
+
await type('see [')
|
|
402
|
+
// What a browser sends when another window takes focus: a blur, with the editor still the active element.
|
|
403
|
+
await act(async () => {
|
|
404
|
+
editor!.view.dom.dispatchEvent(new FocusEvent('blur'))
|
|
405
|
+
})
|
|
406
|
+
expect(document.activeElement).toBe(editor!.view.dom)
|
|
407
|
+
expect(options()).toHaveLength(6)
|
|
408
|
+
expect(isSuggestionOpen()).toBe(true)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('still picks a row by mouse, which never takes focus from the text', async () => {
|
|
412
|
+
render(<Page />)
|
|
413
|
+
await type('see [')
|
|
414
|
+
const row = within(screen.getByRole('listbox')).getAllByRole('option')[2]
|
|
415
|
+
await act(async () => {
|
|
416
|
+
fireEvent.mouseDown(row)
|
|
417
|
+
})
|
|
418
|
+
expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[4].id, label: 'Ada', snippet: 'line 4', uri: ITEMS[4].uri } }])
|
|
419
|
+
expect(isSuggestionOpen()).toBe(false)
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
346
423
|
describe('a chip through HTML (copy and paste)', () => {
|
|
347
424
|
it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
|
|
348
425
|
render(<Composer />)
|
package/src/Popover.mdx
CHANGED
|
@@ -99,7 +99,9 @@ so it is the one case the caller drives:
|
|
|
99
99
|
- **It does not take focus**, on purpose: the person is still in the text, and
|
|
100
100
|
a toolbar that moved the caret out of it would end the edit it exists to
|
|
101
101
|
serve. `finalFocus` is therefore only for a panel something inside it *did*
|
|
102
|
-
take focus into — a field the person clicked.
|
|
102
|
+
take focus into — a field the person clicked. `finalFocus={false}` moves
|
|
103
|
+
nothing on close: a panel that closes because focus left for somewhere else
|
|
104
|
+
(a type-ahead list whose box was clicked out of) must not pull it back.
|
|
103
105
|
- **Render it always and toggle `open`.** Not `{rect && <Popover…>}`: mounting
|
|
104
106
|
the panel only while it is open takes Base UI's state machine with it, and
|
|
105
107
|
its exits go too — measured, Escape and re-opening both became intermittent.
|
package/src/Popover.tsx
CHANGED
|
@@ -82,9 +82,10 @@ export interface PopoverProps {
|
|
|
82
82
|
* the trigger and this is not needed. An anchored panel never took focus, so
|
|
83
83
|
* this only matters when something inside it did — a field the person tabbed
|
|
84
84
|
* or clicked into: point it at what they came from, or focus is left on the
|
|
85
|
-
* document body.
|
|
85
|
+
* document body. `false` moves nothing: a panel that closes because focus
|
|
86
|
+
* left for somewhere else must not pull it back (`SuggestionMenu`).
|
|
86
87
|
*/
|
|
87
|
-
finalFocus?: RefObject<HTMLElement | null>
|
|
88
|
+
finalFocus?: RefObject<HTMLElement | null> | false
|
|
88
89
|
/** Base UI's imperative handle. `actions.current?.close()` shuts the panel —
|
|
89
90
|
* for the Cancel and Save buttons a form panel ends with. */
|
|
90
91
|
actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
|
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' })
|
|
@@ -176,11 +186,12 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
|
|
|
176
186
|
}
|
|
177
187
|
|
|
178
188
|
return (
|
|
179
|
-
|
|
189
|
+
// finalFocus={false}: focus never left the text, and a list that closes because focus moved elsewhere must not pull it back (71a4577b).
|
|
190
|
+
<Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel} finalFocus={false}>
|
|
180
191
|
{/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own.
|
|
181
192
|
`gap-0.5`: 2px between rows, as a menu's — a section with a heading
|
|
182
193
|
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">
|
|
194
|
+
<div ref={keepFocus} id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
|
|
184
195
|
{shown.map((section, i) => (
|
|
185
196
|
<div key={section.label ?? i} className="contents">
|
|
186
197
|
{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
|
/*
|
|
@@ -16,6 +16,9 @@ import { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuRow, type
|
|
|
16
16
|
* A list counts while it shows rows, not while its trigger listens: since a
|
|
17
17
|
* space stopped closing one (f586437a), "meet @ 5pm" leaves `@` listening
|
|
18
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.
|
|
19
22
|
*/
|
|
20
23
|
let openCount = 0
|
|
21
24
|
let lastClose = 0
|
|
@@ -104,14 +107,38 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
|
|
|
104
107
|
let last: SuggestionProps<T> | null = null
|
|
105
108
|
let unsubscribe: (() => void) | undefined
|
|
106
109
|
let counted = false
|
|
110
|
+
let unlisten: (() => void) | undefined
|
|
107
111
|
const count = (shown: boolean) => {
|
|
108
112
|
if (shown === counted) return
|
|
109
113
|
counted = shown
|
|
110
114
|
openCount = shown ? openCount + 1 : Math.max(0, openCount - 1)
|
|
111
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
|
+
}
|
|
112
137
|
const exit = () => {
|
|
113
138
|
unsubscribe?.()
|
|
114
139
|
unsubscribe = undefined
|
|
140
|
+
unlisten?.()
|
|
141
|
+
unlisten = undefined
|
|
115
142
|
last = null
|
|
116
143
|
// The grace is for the Enter that picked a row and closed the list, not
|
|
117
144
|
// for a keystroke that only left it with no rows.
|
|
@@ -131,6 +158,7 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
|
|
|
131
158
|
document.body.appendChild(container)
|
|
132
159
|
container.appendChild(component.element)
|
|
133
160
|
last = props
|
|
161
|
+
listen(props)
|
|
134
162
|
const { subscribe, refresh } = options
|
|
135
163
|
if (subscribe && refresh) {
|
|
136
164
|
// The plugin asks for items only when the query changes; an answer
|