@estiva-app/ui 0.44.1 → 0.45.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 (54) hide show
  1. package/dist/SelectionToolbar.d.ts +62 -0
  2. package/dist/SelectionToolbar.d.ts.map +1 -0
  3. package/dist/SuggestionMenu.d.ts +75 -0
  4. package/dist/SuggestionMenu.d.ts.map +1 -0
  5. package/dist/editor.d.ts +12 -0
  6. package/dist/editor.d.ts.map +1 -0
  7. package/dist/editor.js +357 -0
  8. package/dist/editor.js.map +7 -0
  9. package/dist/eslint/index.js +2 -2
  10. package/dist/eslint/index.js.map +2 -2
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +201 -99
  14. package/dist/index.js.map +4 -4
  15. package/dist/registry/{app-YMABQII6.js → app-LDGOH6UW.js} +3 -3
  16. package/dist/registry/{build-3EANARLQ.js → build-P6X3PTZP.js} +6 -2
  17. package/dist/registry/build.d.ts +4 -0
  18. package/dist/registry/build.d.ts.map +1 -1
  19. package/dist/registry/{chunk-SIB44CES.js → chunk-P55P2QFD.js} +9 -4
  20. package/dist/registry/chunk-P55P2QFD.js.map +7 -0
  21. package/dist/registry/{chunk-EVJW26UR.js → chunk-TQ6UQER3.js} +10 -10
  22. package/dist/registry/chunk-TQ6UQER3.js.map +7 -0
  23. package/dist/registry/cli.js +2 -2
  24. package/dist/registry/index.js +2 -2
  25. package/dist/shortcuts.d.ts +27 -0
  26. package/dist/shortcuts.d.ts.map +1 -0
  27. package/dist/suggestionPopup.d.ts +41 -0
  28. package/dist/suggestionPopup.d.ts.map +1 -0
  29. package/package.json +27 -2
  30. package/registry.json +341 -3
  31. package/src/SelectionToolbar.mdx +78 -0
  32. package/src/SelectionToolbar.stories.tsx +63 -0
  33. package/src/SelectionToolbar.test.ts +105 -0
  34. package/src/SelectionToolbar.tsx +487 -0
  35. package/src/SuggestionMenu.mdx +107 -0
  36. package/src/SuggestionMenu.stories.tsx +202 -0
  37. package/src/SuggestionMenu.test.tsx +128 -0
  38. package/src/SuggestionMenu.tsx +189 -0
  39. package/src/editor.ts +11 -0
  40. package/src/eslint/no-rebuilt-behaviour.test.ts +1 -1
  41. package/src/eslint/no-rebuilt-behaviour.ts +2 -2
  42. package/src/how.test.ts +1 -1
  43. package/src/index.ts +2 -0
  44. package/src/keptSelection.test.ts +67 -0
  45. package/src/registry/app.ts +8 -8
  46. package/src/registry/build.ts +10 -2
  47. package/src/registry/registry.test.ts +14 -9
  48. package/src/shortcuts.ts +40 -0
  49. package/src/suggestionPopup.tsx +119 -0
  50. package/stories/Choosing.mdx +2 -0
  51. package/dist/registry/chunk-EVJW26UR.js.map +0 -7
  52. package/dist/registry/chunk-SIB44CES.js.map +0 -7
  53. /package/dist/registry/{app-YMABQII6.js.map → app-LDGOH6UW.js.map} +0 -0
  54. /package/dist/registry/{build-3EANARLQ.js.map → build-P6X3PTZP.js.map} +0 -0
@@ -0,0 +1,202 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
+ import { Extension } from '@tiptap/core'
5
+ import { EditorContent, useEditor } from '@tiptap/react'
6
+ import StarterKit from '@tiptap/starter-kit'
7
+ import Suggestion from '@tiptap/suggestion'
8
+ import { EnterHint } from './Menu'
9
+ import { richTextClassName } from './RichText'
10
+ import { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
11
+ import { suggestionPopup } from './suggestionPopup'
12
+
13
+ /**
14
+ * The list a text editor opens as you type — `/` for commands, `@` for people.
15
+ * Focus stays in the text: the editor hands its ↑, ↓ and Enter to the list.
16
+ */
17
+ const meta = {
18
+ title: 'Primitives/SuggestionMenu',
19
+ component: SuggestionMenu,
20
+ parameters: { layout: 'padded' },
21
+ } satisfies Meta
22
+
23
+ export default meta
24
+ type Story = StoryObj
25
+
26
+ interface Command {
27
+ id: string
28
+ label: string
29
+ keys?: string
30
+ }
31
+
32
+ const FORMAT: Command[] = [
33
+ { id: 'text', label: 'Text' },
34
+ { id: 'heading', label: 'Heading', keys: '#' },
35
+ { id: 'subheading', label: 'Subheading', keys: '##' },
36
+ { id: 'quote', label: 'Quote', keys: '>' },
37
+ { id: 'code', label: 'Code', keys: '`' },
38
+ { id: 'bullets', label: 'Bulleted list', keys: '-' },
39
+ { id: 'numbers', label: 'Numbered list', keys: '1.' },
40
+ ]
41
+ const INSERT: Command[] = [
42
+ { id: 'item-one', label: 'Item one' },
43
+ { id: 'item-two', label: 'Item two' },
44
+ ]
45
+ const commandRow = (c: Command): SuggestionMenuRow => ({ label: c.label, shortcut: c.keys })
46
+
47
+ /**
48
+ * A line of text with the caret's place marked, and the list hung from it —
49
+ * the way the editor hangs it. The list is drawn from a rect, so the story
50
+ * measures a stand-in for the caret once it is on screen.
51
+ */
52
+ function AtACaret<T>({ children, ...props }: { children?: ReactNode } & Omit<Parameters<typeof SuggestionMenu<T>>[0], 'rect'>) {
53
+ const caret = useRef<HTMLSpanElement>(null)
54
+ const [rect, setRect] = useState<DOMRect | null>(null)
55
+ useLayoutEffect(() => setRect(caret.current?.getBoundingClientRect() ?? null), [])
56
+ return (
57
+ <div className="flex h-[520px] items-end pb-6 text-body-2 text-text-primary">
58
+ <p>
59
+ Some text, then a slash
60
+ <span ref={caret}>/</span>
61
+ {children}
62
+ </p>
63
+ <SuggestionMenu<T> {...props} rect={rect} />
64
+ </div>
65
+ )
66
+ }
67
+
68
+ /** Two sections: headings, keys you could type instead, a hairline between. */
69
+ export const Default: Story = {
70
+ render: () => (
71
+ <AtACaret<Command>
72
+ ariaLabel="Commands"
73
+ width="w-[300px]"
74
+ maxHeight="max-h-[400px]"
75
+ sections={[
76
+ { label: 'Format', items: FORMAT },
77
+ { label: 'Insert', items: INSERT },
78
+ ]}
79
+ itemKey={(c) => c.id}
80
+ row={commandRow}
81
+ onSelect={() => {}}
82
+ />
83
+ ),
84
+ }
85
+
86
+ /** Tall rows: a face, a second line, and Enter's hint on the highlighted one. */
87
+ export const TallRows: Story = {
88
+ render: () => (
89
+ <AtACaret<string>
90
+ ariaLabel="Items"
91
+ width="w-[360px]"
92
+ maxHeight="max-h-[360px]"
93
+ sections={[{ label: 'Items', items: ['Item one', 'Item two', 'Item three'], className: 'px-3 py-1' }]}
94
+ itemKey={(s) => s}
95
+ row={(s) => ({
96
+ label: s,
97
+ description: 'A second line',
98
+ leading: <IconSquareRounded size={32} stroke={1.5} className="text-text-muted" />,
99
+ hint: <EnterHint />,
100
+ size: 'tall',
101
+ })}
102
+ onSelect={() => {}}
103
+ />
104
+ ),
105
+ }
106
+
107
+ /** More rows than fit: the box stops at `maxHeight` and scrolls in the package's scroll area. */
108
+ export const Scrolling: Story = {
109
+ render: () => (
110
+ <AtACaret<string>
111
+ ariaLabel="Items"
112
+ width="w-[300px]"
113
+ maxHeight="max-h-[240px]"
114
+ sections={[{ label: 'Items', items: Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`) }]}
115
+ itemKey={(s) => s}
116
+ row={(s) => ({ label: s })}
117
+ onSelect={() => {}}
118
+ />
119
+ ),
120
+ }
121
+
122
+ /** The keys, driven from outside as an editor drives them: ↓ twice moves the highlight to the third row. */
123
+ export const DrivenByKeys: Story = {
124
+ render: () => {
125
+ function Driven() {
126
+ const handle = useRef<SuggestionMenuHandle>(null)
127
+ const [picked, setPicked] = useState('nothing yet')
128
+ const sections = useMemo<SuggestionMenuSection<Command>[]>(() => [{ label: 'Format', items: FORMAT }], [])
129
+ useLayoutEffect(() => {
130
+ const press = (key: string) => handle.current?.onKeyDown(new KeyboardEvent('keydown', { key }))
131
+ requestAnimationFrame(() => {
132
+ press('ArrowDown')
133
+ press('ArrowDown')
134
+ })
135
+ }, [])
136
+ return (
137
+ <AtACaret<Command> handle={handle} ariaLabel="Commands" width="w-[300px]" maxHeight="max-h-[400px]" sections={sections} itemKey={(c) => c.id} row={commandRow} onSelect={(c) => setPicked(c.label)}>
138
+ <span className="ml-4 text-text-secondary">Chosen: {picked}</span>
139
+ </AtACaret>
140
+ )
141
+ }
142
+ return <Driven />
143
+ },
144
+ }
145
+
146
+ /*
147
+ * In a real editor: `suggestionPopup` connects a Tiptap suggestion plugin to
148
+ * this list. Type `/` anywhere after a space; ↑ ↓ Enter choose, Escape closes,
149
+ * and typing narrows the list.
150
+ */
151
+ const Commands = Extension.create({
152
+ name: 'storyCommands',
153
+ addProseMirrorPlugins() {
154
+ return [
155
+ Suggestion<Command>({
156
+ editor: this.editor,
157
+ char: '/',
158
+ items: ({ query }) => [...FORMAT, ...INSERT].filter((c) => c.label.toLowerCase().includes(query.toLowerCase())),
159
+ command: ({ editor, range, props }) => {
160
+ const chain = editor.chain().focus().deleteRange(range)
161
+ if (props.id === 'heading') chain.setHeading({ level: 1 }).run()
162
+ else if (props.id === 'subheading') chain.setHeading({ level: 2 }).run()
163
+ else if (props.id === 'quote') chain.setBlockquote().run()
164
+ else if (props.id === 'code') chain.setCodeBlock().run()
165
+ else if (props.id === 'bullets') chain.toggleBulletList().run()
166
+ else if (props.id === 'numbers') chain.toggleOrderedList().run()
167
+ else chain.setParagraph().run()
168
+ },
169
+ render: suggestionPopup<Command>({
170
+ ariaLabel: 'Commands',
171
+ width: 'w-[300px]',
172
+ maxHeight: 'max-h-[400px]',
173
+ sections: (items) => [
174
+ { label: 'Format', items: items.filter((c) => FORMAT.includes(c)) },
175
+ { label: 'Insert', items: items.filter((c) => INSERT.includes(c)) },
176
+ ],
177
+ itemKey: (c) => c.id,
178
+ row: commandRow,
179
+ }),
180
+ }),
181
+ ]
182
+ },
183
+ })
184
+
185
+ /** A real editor with `/` wired through `suggestionPopup` (`@estiva-app/ui/editor`). Click in and type `/`. */
186
+ export const InAnEditor: Story = {
187
+ render: () => {
188
+ function Live() {
189
+ const editor = useEditor({
190
+ extensions: [StarterKit, Commands],
191
+ content: '<p>Click here, then type / after a space.</p>',
192
+ editorProps: { attributes: { class: richTextClassName('default', 'outline-none min-h-24'), 'aria-label': 'Text' } },
193
+ })
194
+ return (
195
+ <div className="flex h-[520px] items-end pb-6">
196
+ <EditorContent editor={editor} className="w-full rounded-md border border-border-default bg-bg-surface p-3" />
197
+ </div>
198
+ )
199
+ }
200
+ return <Live />
201
+ },
202
+ }
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The list an editor drives (UIG-31). jsdom cannot type into an editor, so this
4
+ * drives the part the way the editor does — through `handle` — with a plain
5
+ * element standing in for the editor's own. The real editor is proved in a
6
+ * browser through the `InAnEditor` story.
7
+ */
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
9
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
10
+ import { createRef } from 'react'
11
+ import { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuSection } from './SuggestionMenu'
12
+
13
+ afterEach(cleanup)
14
+ // jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it.
15
+ beforeAll(() => {
16
+ Element.prototype.scrollIntoView = vi.fn()
17
+ })
18
+
19
+ const RECT = { top: 400, bottom: 420, left: 100, right: 110, width: 10, height: 20, x: 100, y: 400, toJSON: () => ({}) } as DOMRect
20
+ const SECTIONS: SuggestionMenuSection<string>[] = [
21
+ { label: 'First', items: ['One', 'Two'] },
22
+ { label: 'Second', items: ['Three'] },
23
+ ]
24
+
25
+ function draw({ sections = SECTIONS, editor, rect = RECT, onSelect = vi.fn() }: { sections?: SuggestionMenuSection<string>[]; editor?: HTMLElement; rect?: DOMRect | null; onSelect?: (s: string) => void } = {}) {
26
+ const handle = createRef<SuggestionMenuHandle>()
27
+ const result = render(
28
+ <SuggestionMenu handle={handle} rect={rect} editorElement={editor} ariaLabel="Commands" width="w-[300px]" maxHeight="max-h-[400px]" sections={sections} itemKey={(s) => s} row={(s) => ({ label: s })} onSelect={onSelect} />,
29
+ )
30
+ const press = (key: string) => {
31
+ let used = false
32
+ act(() => {
33
+ used = handle.current!.onKeyDown(new KeyboardEvent('keydown', { key }))
34
+ })
35
+ return used
36
+ }
37
+ return { ...result, handle, press, onSelect }
38
+ }
39
+
40
+ const active = () => screen.getAllByRole('option').findIndex((o) => o.getAttribute('aria-selected') === 'true')
41
+
42
+ describe('SuggestionMenu', () => {
43
+ it('is a named listbox of options, in sections', async () => {
44
+ draw()
45
+ await screen.findByRole('listbox', { name: 'Commands' })
46
+ expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['One', 'Two', 'Three'])
47
+ expect(screen.getByRole('group', { name: 'First' })).toBeTruthy()
48
+ expect(screen.getByRole('group', { name: 'Second' })).toBeTruthy()
49
+ })
50
+
51
+ it('draws nothing when there is nothing to show', () => {
52
+ draw({ sections: [{ label: 'First', items: [] }] })
53
+ expect(screen.queryByRole('listbox')).toBeNull()
54
+ })
55
+
56
+ it('walks with ↓ and ↑ across sections, wrapping at both ends', async () => {
57
+ const { press } = draw()
58
+ await screen.findByRole('listbox')
59
+ expect(active()).toBe(0)
60
+ expect(press('ArrowDown')).toBe(true)
61
+ expect(active()).toBe(1)
62
+ press('ArrowDown')
63
+ expect(active()).toBe(2)
64
+ press('ArrowDown')
65
+ expect(active()).toBe(0)
66
+ press('ArrowUp')
67
+ expect(active()).toBe(2)
68
+ })
69
+
70
+ it('chooses the highlighted row on Enter, and leaves every other key to the editor', async () => {
71
+ const { press, onSelect } = draw()
72
+ await screen.findByRole('listbox')
73
+ press('ArrowDown')
74
+ expect(press('Enter')).toBe(true)
75
+ expect(onSelect).toHaveBeenCalledWith('Two')
76
+ expect(press('a')).toBe(false)
77
+ expect(press('Escape')).toBe(false)
78
+ })
79
+
80
+ it('chooses on a press, without taking the focus', async () => {
81
+ const { onSelect } = draw()
82
+ await screen.findByRole('listbox')
83
+ const row = screen.getByRole('option', { name: 'Three' })
84
+ const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
85
+ row.dispatchEvent(down)
86
+ expect(onSelect).toHaveBeenCalledWith('Three')
87
+ expect(down.defaultPrevented).toBe(true)
88
+ })
89
+
90
+ it('moves the highlight with the pointer', async () => {
91
+ draw()
92
+ await screen.findByRole('listbox')
93
+ fireEvent.mouseEnter(screen.getByRole('option', { name: 'Three' }))
94
+ expect(active()).toBe(2)
95
+ })
96
+
97
+ it('goes back to the first row when the list changes', async () => {
98
+ const { press, rerender, handle } = draw()
99
+ await screen.findByRole('listbox')
100
+ press('ArrowDown')
101
+ expect(active()).toBe(1)
102
+ rerender(<SuggestionMenu handle={handle} rect={RECT} ariaLabel="Commands" width="w-[300px]" maxHeight="max-h-[400px]" sections={[{ items: ['Two', 'Three'] }]} itemKey={(s) => s} row={(s) => ({ label: s })} onSelect={() => {}} />)
103
+ expect(active()).toBe(0)
104
+ })
105
+
106
+ it('tells the editor it completes into the list, and which row is active', async () => {
107
+ const editor = document.createElement('div')
108
+ const { press } = draw({ editor })
109
+ const listbox = await screen.findByRole('listbox')
110
+ expect(editor.getAttribute('aria-autocomplete')).toBe('list')
111
+ expect(editor.getAttribute('aria-haspopup')).toBe('listbox')
112
+ expect(editor.getAttribute('aria-controls')).toBe(listbox.id)
113
+ const options = screen.getAllByRole('option')
114
+ expect(editor.getAttribute('aria-activedescendant')).toBe(options[0].id)
115
+ press('ArrowDown')
116
+ expect(editor.getAttribute('aria-activedescendant')).toBe(options[1].id)
117
+ })
118
+
119
+ it('leaves nothing on the editor once it is gone', async () => {
120
+ const editor = document.createElement('div')
121
+ const { unmount } = draw({ editor })
122
+ await screen.findByRole('listbox')
123
+ unmount()
124
+ for (const name of ['aria-autocomplete', 'aria-haspopup', 'aria-controls', 'aria-activedescendant']) {
125
+ expect(editor.hasAttribute(name), name).toBe(false)
126
+ }
127
+ })
128
+ })
@@ -0,0 +1,189 @@
1
+ import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useState, type ReactNode, type Ref } from 'react'
2
+ import { MenuItem, MenuSection, MenuSeparator, type MenuItemProps } from './Menu'
3
+ import { Popover } from './Popover'
4
+
5
+ /**
6
+ * The list a text editor opens as you type — `/` for commands, `@` for people,
7
+ * `[` for a reference. UIG-31: Peek's four type-ahead menus, made one part.
8
+ *
9
+ * **Why it is not a `Combobox` or a `Menu`.** Base UI's lists need a text field
10
+ * of their own, and the text being completed is an editor, not an `<input>`.
11
+ * So focus never leaves the text: the editor hands its arrow keys and Enter to
12
+ * this list through `handle`, and the list tells the editor which row is
13
+ * active (`aria-activedescendant`), the pattern Base UI's `Combobox` builds.
14
+ * If Base UI ever adds a list that can be driven from outside its own field,
15
+ * this part moves onto it.
16
+ *
17
+ * **What it owns**, all of it written once here instead of in every menu:
18
+ *
19
+ * - the box: the package `Popover`, anchored to the caret, above it and below
20
+ * when there is no room, clamped to the window, scrolling in the package's
21
+ * `ScrollArea` past `maxHeight`;
22
+ * - the highlighted row: ↑ and ↓ (wrapping at the ends), Enter to choose, the
23
+ * pointer moving the highlight, a press choosing without taking focus from
24
+ * the text, the highlight back on the first row whenever the list changes,
25
+ * and the highlighted row scrolled into view;
26
+ * - what a screen reader is told: the list is a `listbox`, each row an
27
+ * `option`, and the editor says it completes into this list and which row
28
+ * is active — all removed again when the list closes.
29
+ *
30
+ * What stays with the app: which rows exist, what choosing one does, and what
31
+ * each row shows (its label, face, keys, hint) — `row` returns the package
32
+ * `MenuItem`'s own props. With nothing to show there is no box at all.
33
+ */
34
+ export interface SuggestionMenuRow {
35
+ label: string
36
+ description?: string
37
+ leading?: ReactNode
38
+ /** What you would type — drawn as keys at the right, e.g. `#` for a heading. */
39
+ shortcut?: string
40
+ /** Drawn at the right while the row is highlighted, e.g. `<EnterHint />`. */
41
+ hint?: ReactNode
42
+ /** `tall` for a row with a face and a second line — `MenuItem`'s own sizes. */
43
+ size?: MenuItemProps['size']
44
+ }
45
+
46
+ export interface SuggestionMenuSection<T> {
47
+ /** The section's heading. Without one, the rows stand alone. */
48
+ label?: string
49
+ items: readonly T[]
50
+ /** On the heading row, as `MenuSection`'s. */
51
+ className?: string
52
+ }
53
+
54
+ /** What the editor calls while the list is open. */
55
+ export interface SuggestionMenuHandle {
56
+ /** ↑, ↓ and Enter. `true` when the list used the key, so the editor must not. */
57
+ onKeyDown: (event: KeyboardEvent) => boolean
58
+ }
59
+
60
+ export interface SuggestionMenuProps<T> {
61
+ /** The caret's rect, from the editor. `null` closes the box. */
62
+ rect: DOMRect | null
63
+ /** The editor's own element — told which row is active. Absent in a story. */
64
+ editorElement?: HTMLElement | null
65
+ /** Names the list — "Commands", "People". */
66
+ ariaLabel: string
67
+ /** The box's width, e.g. `w-[300px]`. */
68
+ width: string
69
+ /** The cap before the rows scroll, e.g. `max-h-[400px]`. */
70
+ maxHeight: string
71
+ sections: readonly SuggestionMenuSection<T>[]
72
+ /** A stable key for a row. */
73
+ itemKey: (item: T) => string
74
+ /** What a row shows. */
75
+ row: (item: T) => SuggestionMenuRow
76
+ onSelect: (item: T) => void
77
+ handle?: Ref<SuggestionMenuHandle>
78
+ }
79
+
80
+ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHeight, sections, itemKey, row, onSelect, handle }: SuggestionMenuProps<T>) {
81
+ const shown = useMemo(() => sections.filter((section) => section.items.length > 0), [sections])
82
+ const items = useMemo(() => shown.flatMap((section) => section.items), [shown])
83
+ const keys = items.map(itemKey).join('\n')
84
+ const [highlight, setHighlight] = useState(0)
85
+ const base = useId()
86
+ const listboxId = `${base}-list`
87
+
88
+ // A new list is a new list: the old index may not exist in it.
89
+ useEffect(() => setHighlight(0), [keys])
90
+
91
+ useImperativeHandle(handle, () => ({
92
+ // The editor hands its arrow keys to this list: Base UI's lists need a text field of their own, and an editor is not one.
93
+ onKeyDown: (event) => {
94
+ if (!items.length) return false
95
+ if (event.key === 'ArrowDown') {
96
+ setHighlight((h) => (h + 1) % items.length)
97
+ return true
98
+ }
99
+ if (event.key === 'ArrowUp') {
100
+ setHighlight((h) => (h - 1 + items.length) % items.length)
101
+ return true
102
+ }
103
+ if (event.key === 'Enter') {
104
+ const item = items[highlight]
105
+ if (item !== undefined) onSelect(item)
106
+ return true
107
+ }
108
+ return false
109
+ },
110
+ }), [items, highlight, onSelect])
111
+
112
+ /*
113
+ * The editor completes into this list while it is open: it says so, names
114
+ * the list, and points at the active row. Removed on close — an editor left
115
+ * saying it controls a list that is gone is worse than saying nothing.
116
+ */
117
+ const open = rect !== null && items.length > 0
118
+ useEffect(() => {
119
+ if (!editorElement || !open) return
120
+ editorElement.setAttribute('aria-autocomplete', 'list')
121
+ editorElement.setAttribute('aria-haspopup', 'listbox')
122
+ editorElement.setAttribute('aria-controls', listboxId)
123
+ return () => {
124
+ for (const name of ['aria-autocomplete', 'aria-haspopup', 'aria-controls', 'aria-activedescendant']) editorElement.removeAttribute(name)
125
+ }
126
+ }, [editorElement, open, listboxId])
127
+ useEffect(() => {
128
+ if (!editorElement || !open) return
129
+ editorElement.setAttribute('aria-activedescendant', `${base}-${highlight}`)
130
+ }, [editorElement, open, base, highlight])
131
+
132
+ const scrollRef = useCallback((el: HTMLElement | null) => {
133
+ el?.scrollIntoView({ block: 'nearest' })
134
+ }, [])
135
+
136
+ if (items.length === 0) return null
137
+
138
+ const drawRow = (item: T) => {
139
+ const index = items.indexOf(item)
140
+ const selected = index === highlight
141
+ const { label, description, leading, shortcut, hint, size } = row(item)
142
+ return (
143
+ // A row of a list the editor drives from its caret: focus stays in the text, so the row is no Tab stop.
144
+ <MenuItem
145
+ key={itemKey(item)}
146
+ role="option"
147
+ id={`${base}-${index}`}
148
+ aria-selected={selected}
149
+ tabIndex={-1}
150
+ ref={selected ? scrollRef : undefined}
151
+ size={size}
152
+ label={label}
153
+ description={description}
154
+ leading={leading}
155
+ shortcut={shortcut}
156
+ hint={hint}
157
+ selected={selected}
158
+ onMouseEnter={() => setHighlight(index)}
159
+ onMouseDown={(event) => {
160
+ // Choosing must not take focus from the text.
161
+ event.preventDefault()
162
+ onSelect(item)
163
+ }}
164
+ />
165
+ )
166
+ }
167
+
168
+ return (
169
+ <Popover anchor={rect} open={rect !== null} side="top" align="left" className={width} maxHeight={maxHeight} ariaLabel={ariaLabel}>
170
+ {/* The list the editor drives from its caret: a listbox, because Base UI's lists need a text field of their own. */}
171
+ <div id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col">
172
+ {shown.map((section, i) => (
173
+ <div key={section.label ?? i} className="contents">
174
+ {i > 0 && <MenuSeparator />}
175
+ {section.label ? (
176
+ <div role="group" aria-label={section.label}>
177
+ <MenuSection label={section.label} className={section.className}>
178
+ {section.items.map(drawRow)}
179
+ </MenuSection>
180
+ </div>
181
+ ) : (
182
+ section.items.map(drawRow)
183
+ )}
184
+ </div>
185
+ ))}
186
+ </div>
187
+ </Popover>
188
+ )
189
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@estiva-app/ui/editor` — the parts that work inside a Tiptap editor (UIG-31,
3
+ * Katerina's ruling 29 September: one editor corner in the package, option A).
4
+ *
5
+ * Its own entry because it imports Tiptap, which only an app with an editor
6
+ * installs: the main entry stays free of it, and the Tiptap peers are optional,
7
+ * as the eslint ones are. The rest of the package it draws with — `Popover`,
8
+ * `Toolbar`, `MenuItem`, `SuggestionMenu` — is the main entry's own, not a copy.
9
+ */
10
+ export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
+ export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
@@ -25,7 +25,7 @@ const tester = new RuleTester({
25
25
 
26
26
  const component = (body: string) => `export function Probe() {\n return (\n${body}\n )\n}\n`
27
27
  const effect = (body: string) => `useEffect(() => {\n${body}\n}, [])\n`
28
- const LIST = ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`.'
28
+ const LIST = ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`; for the list a text editor opens as you type, `SuggestionMenu`.'
29
29
 
30
30
  tester.run('no-rebuilt-behaviour', noRebuiltBehaviour, {
31
31
  valid: [
@@ -88,7 +88,7 @@ export const BASE_UI_PARTS: Record<string, OwnerPart | null> = {
88
88
 
89
89
  const LIST_PART: OwnerPart = {
90
90
  use: 'Select',
91
- more: ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`.',
91
+ more: ' To pick several, `ChipInput`; to search and act, `CommandPalette`; to tick several in a list, `Checkbox` with `row`; for the list a text editor opens as you type, `SuggestionMenu`.',
92
92
  }
93
93
 
94
94
  const MESSAGE_PART: OwnerPart = { use: 'FieldLine', more: ' For a notice, `Banner`; for a message that comes and goes, `Toast`.' }
@@ -232,7 +232,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
232
232
  id: 'walking',
233
233
  behaviour: 'Moves through its items with the arrow keys',
234
234
  baseUi: ['Menu', 'Select', 'Combobox', 'Autocomplete', 'Tabs', 'Toolbar'],
235
- owners: ['Menu', 'Select', 'ChipInput', 'CommandPalette', 'Tabs', 'Toolbar'],
235
+ owners: ['Menu', 'Select', 'ChipInput', 'CommandPalette', 'SuggestionMenu', 'Tabs', 'Toolbar'],
236
236
  reads: `a key compared by hand with ${WALKING_KEYS.join(', ')}`,
237
237
  },
238
238
  {
package/src/how.test.ts CHANGED
@@ -177,7 +177,7 @@ function compilerOptions(): ts.CompilerOptions {
177
177
  const parsed = ts.convertCompilerOptionsFromJson(raw.compilerOptions, REPO)
178
178
  // A placeholder is `any`: `people.map((p) => …)` would fail noImplicitAny on `p`,
179
179
  // the placeholder's fault, not the page's. A callback prop's parameter keeps the part's type.
180
- return { ...parsed.options, noEmit: true, noImplicitAny: false, baseUrl: REPO, paths: { '@estiva-app/ui': ['./src/index.ts'] }, types: [] }
180
+ return { ...parsed.options, noEmit: true, noImplicitAny: false, baseUrl: REPO, paths: { '@estiva-app/ui': ['./src/index.ts'], '@estiva-app/ui/editor': ['./src/editor.ts'] }, types: [] }
181
181
  }
182
182
 
183
183
  function makeHost(options: ts.CompilerOptions, virtual: Map<string, string>): ts.CompilerHost {
package/src/index.ts CHANGED
@@ -71,6 +71,7 @@ export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
71
71
  export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
72
72
  export { NavItem, type NavItemProps } from './NavItem'
73
73
  export { Popover, type PopoverProps } from './Popover'
74
+ export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
74
75
  export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
75
76
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
76
77
  export { Toolbar, ToolbarButton, ToolbarInput, ToolbarLink, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps, type ToolbarLinkProps } from './Toolbar'
@@ -93,3 +94,4 @@ export { SectionLabel } from './SectionLabel'
93
94
  export { Tabs, type TabDef, type TabsProps } from './Tabs'
94
95
  export { Toast, ToastProvider, useToast, type ToastOptions, type ToastProps, type ToastType } from './Toast'
95
96
  export { cn } from './cn'
97
+ export { isApplePlatform, shortcutLabel } from './shortcuts'
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
3
+ import { Editor } from '@tiptap/core'
4
+ import { DecorationSet } from '@tiptap/pm/view'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { KeptSelection, keepSelection, releaseSelection, keptSelection, keptSelectionKey } from './SelectionToolbar'
7
+
8
+ /**
9
+ * The kept selection (UIG-31; first written for the link field, PEE-19). Runs
10
+ * against a real editor with the extension registered.
11
+ */
12
+
13
+ let editor: Editor
14
+
15
+ beforeEach(() => {
16
+ editor = new Editor({ extensions: [StarterKit, KeptSelection], content: '<p>read the widgets topic first</p>' })
17
+ editor.commands.setTextSelection({ from: 10, to: 23 })
18
+ })
19
+
20
+ afterEach(() => {
21
+ editor.destroy()
22
+ })
23
+
24
+ /** The ranges the plugin is drawing right now. */
25
+ function drawn(): Array<[number, number]> {
26
+ const plugin = keptSelectionKey.get(editor.state)
27
+ if (!plugin || typeof plugin.props.decorations !== 'function') return []
28
+ const set = plugin.props.decorations.call(plugin, editor.state)
29
+ if (!(set instanceof DecorationSet)) return []
30
+ return set.find().map((d) => [d.from, d.to])
31
+ }
32
+
33
+ describe('keeping the selection', () => {
34
+ it('draws nothing until asked', () => {
35
+ expect(keptSelection(editor)).toBeNull()
36
+ expect(drawn()).toEqual([])
37
+ })
38
+
39
+ it('draws the selection as it is when kept, and stops when released', () => {
40
+ keepSelection(editor)
41
+ expect(keptSelection(editor)).toEqual({ from: 10, to: 23 })
42
+ expect(drawn()).toEqual([[10, 23]])
43
+ releaseSelection(editor)
44
+ expect(keptSelection(editor)).toBeNull()
45
+ expect(drawn()).toEqual([])
46
+ })
47
+
48
+ it('follows the words through an edit before them', () => {
49
+ keepSelection(editor)
50
+ editor.commands.insertContentAt(1, 'X')
51
+ expect(keptSelection(editor)).toEqual({ from: 11, to: 24 })
52
+ expect(editor.state.doc.textBetween(11, 24)).toBe('widgets topic')
53
+ })
54
+
55
+ it('releasing with nothing kept dispatches nothing', () => {
56
+ const before = editor.state
57
+ releaseSelection(editor)
58
+ expect(editor.state).toBe(before)
59
+ })
60
+
61
+ it('an empty selection is kept but not drawn', () => {
62
+ editor.commands.setTextSelection(15)
63
+ keepSelection(editor)
64
+ expect(keptSelection(editor)).toEqual({ from: 15, to: 15 })
65
+ expect(drawn()).toEqual([])
66
+ })
67
+ })