@estiva-app/ui 0.57.2 → 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;AAkBhI;;;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,CA6DlH"}
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.2",
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.2",
7
+ "packageVersion": "0.57.3",
8
8
  "exports": 130,
9
9
  "typeExports": 134,
10
10
  "files": null
@@ -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,80 @@ 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
+ // 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
+
346
420
  describe('a chip through HTML (copy and paste)', () => {
347
421
  it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
348
422
  render(<Composer />)
@@ -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
  /*
@@ -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