@estiva-app/ui 0.52.0 → 0.53.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.
- package/dist/ComposerTriggers.d.ts +24 -2
- package/dist/ComposerTriggers.d.ts.map +1 -1
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/editor.js +32 -5
- package/dist/editor.js.map +2 -2
- package/dist/index.js +7 -2
- package/dist/index.js.map +3 -3
- package/dist/suggestionPopup.d.ts +9 -0
- package/dist/suggestionPopup.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +14 -2
- package/src/ComposerTriggers.mdx +7 -0
- package/src/ComposerTriggers.stories.tsx +21 -1
- package/src/ComposerTriggers.test.tsx +61 -0
- package/src/ComposerTriggers.tsx +46 -6
- package/src/SuggestionMenu.tsx +13 -3
- package/src/suggestionPopup.tsx +29 -1
|
@@ -22,6 +22,15 @@ export interface SuggestionPopupOptions<T> {
|
|
|
22
22
|
sections?: (items: T[], query: string) => readonly SuggestionMenuSection<T>[];
|
|
23
23
|
itemKey: (item: T) => string;
|
|
24
24
|
row: (item: T) => SuggestionMenuRow;
|
|
25
|
+
/**
|
|
26
|
+
* With `refresh`: called with a listener while the list is open; the app
|
|
27
|
+
* calls it when its data has changed (a search landing), and the open list is
|
|
28
|
+
* drawn again for the same query. Returns the unsubscribe, run when the list
|
|
29
|
+
* closes however it closes.
|
|
30
|
+
*/
|
|
31
|
+
subscribe?: (listener: () => void) => () => void;
|
|
32
|
+
/** The items for a query, as the plugin's `items` answers — asked again when `subscribe` fires. */
|
|
33
|
+
refresh?: (query: string) => T[];
|
|
25
34
|
}
|
|
26
35
|
/**
|
|
27
36
|
* The `render` of a Tiptap suggestion plugin, drawn as a `SuggestionMenu` —
|
|
@@ -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;
|
|
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"}
|
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.
|
|
7
|
+
"packageVersion": "0.53.0",
|
|
8
8
|
"exports": 120,
|
|
9
9
|
"typeExports": 128,
|
|
10
10
|
"files": null
|
|
@@ -4587,7 +4587,7 @@
|
|
|
4587
4587
|
"kind": "helper",
|
|
4588
4588
|
"importPath": "@estiva-app/ui/editor",
|
|
4589
4589
|
"sourceFile": "src/ComposerTriggers.tsx",
|
|
4590
|
-
"purpose": "The list `[` opens:
|
|
4590
|
+
"purpose": "The list `[` opens: `label: snippet` rows, the same box as the people list.",
|
|
4591
4591
|
"purposeFrom": "comment",
|
|
4592
4592
|
"props": [
|
|
4593
4593
|
{
|
|
@@ -5568,6 +5568,18 @@
|
|
|
5568
5568
|
"takes": "a handler",
|
|
5569
5569
|
"required": true,
|
|
5570
5570
|
"note": null
|
|
5571
|
+
},
|
|
5572
|
+
{
|
|
5573
|
+
"name": "subscribe",
|
|
5574
|
+
"takes": "a handler",
|
|
5575
|
+
"required": false,
|
|
5576
|
+
"note": "With `refresh`: called with a listener while the list is open; the app calls it when its data has changed (a search landing), and the open list is drawn again for the same query."
|
|
5577
|
+
},
|
|
5578
|
+
{
|
|
5579
|
+
"name": "refresh",
|
|
5580
|
+
"takes": "a handler",
|
|
5581
|
+
"required": false,
|
|
5582
|
+
"note": "The items for a query, as the plugin's `items` answers — asked again when `subscribe` fires."
|
|
5571
5583
|
}
|
|
5572
5584
|
],
|
|
5573
5585
|
"variants": [],
|
package/src/ComposerTriggers.mdx
CHANGED
|
@@ -77,6 +77,13 @@ 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
|
+
- **`[` in sections, filled in as answers land:** pass `sections(query)` in
|
|
81
|
+
place of `items` — labelled groups the app has already narrowed and ordered;
|
|
82
|
+
an empty group is not drawn — and `subscribe(listener)`, which the app calls
|
|
83
|
+
when a slower answer (a search) has landed. The open list is drawn again for
|
|
84
|
+
the same query; nothing waits for it. A row's `description` is its second
|
|
85
|
+
line, which the chip does not wear. A row the person has arrowed to stays
|
|
86
|
+
highlighted when the list is drawn again.
|
|
80
87
|
- **An app's own node name:** `CaptionedReference.extend({ name: 'yourName' })`
|
|
81
88
|
and `ReferenceTrigger.configure({ nodeName: 'yourName' })`. The chip's HTML
|
|
82
89
|
attribute follows the name (`data-your-name`).
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
typeTrigger,
|
|
14
14
|
type CaptionedItem,
|
|
15
15
|
type MentionPerson,
|
|
16
|
+
type ReferenceSection,
|
|
16
17
|
type SlashSection,
|
|
17
18
|
} from './ComposerTriggers'
|
|
18
19
|
|
|
@@ -43,6 +44,21 @@ const ITEMS: CaptionedItem[] = ['one', 'two', 'three'].map((n) => ({
|
|
|
43
44
|
uri: `nostr:item-${n}`,
|
|
44
45
|
}))
|
|
45
46
|
|
|
47
|
+
const DESCRIBED: CaptionedItem[] = ['four', 'five'].map((n) => ({
|
|
48
|
+
id: `item-${n}`,
|
|
49
|
+
label: 'Label',
|
|
50
|
+
snippet: `Item ${n}`,
|
|
51
|
+
uri: `nostr:item-${n}`,
|
|
52
|
+
description: 'Label',
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
const matching = (items: CaptionedItem[], query: string) => items.filter((item) => item.snippet.toLowerCase().includes(query.toLowerCase()))
|
|
56
|
+
|
|
57
|
+
const SECTIONS = (query: string): ReferenceSection[] => [
|
|
58
|
+
{ label: 'Group one', items: matching(ITEMS, query) },
|
|
59
|
+
{ label: 'Group two', items: matching(DESCRIBED, query) },
|
|
60
|
+
]
|
|
61
|
+
|
|
46
62
|
const INSERT: SlashSection = {
|
|
47
63
|
label: 'Insert',
|
|
48
64
|
commands: [
|
|
@@ -51,7 +67,7 @@ const INSERT: SlashSection = {
|
|
|
51
67
|
],
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
function Composer({ code }: { code: 'inline' | 'block' }) {
|
|
70
|
+
function Composer({ code, sections }: { code: 'inline' | 'block'; sections?: typeof SECTIONS }) {
|
|
55
71
|
const editor = useEditor({
|
|
56
72
|
extensions: [
|
|
57
73
|
StarterKit,
|
|
@@ -60,6 +76,7 @@ function Composer({ code }: { code: 'inline' | 'block' }) {
|
|
|
60
76
|
CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
|
|
61
77
|
ReferenceTrigger.configure({
|
|
62
78
|
items: () => ITEMS,
|
|
79
|
+
sections,
|
|
63
80
|
ariaLabel: 'Items',
|
|
64
81
|
sectionLabel: 'Items',
|
|
65
82
|
icon: <IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />,
|
|
@@ -77,3 +94,6 @@ export const Default: Story = { render: () => <Composer code="inline" /> }
|
|
|
77
94
|
|
|
78
95
|
/** Code as a block of its own, for an editor of a block document. */
|
|
79
96
|
export const CodeAsABlock: Story = { render: () => <Composer code="block" /> }
|
|
97
|
+
|
|
98
|
+
/** `[` with the app's own groups, each headed, a row with a second line. */
|
|
99
|
+
export const ReferencesInSections: Story = { render: () => <Composer code="inline" sections={SECTIONS} /> }
|
|
@@ -139,6 +139,67 @@ describe('[', () => {
|
|
|
139
139
|
expect(editor!.getHTML()).toContain('data-message-mention="true"')
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
+
it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => {
|
|
143
|
+
const listeners = new Set<() => void>()
|
|
144
|
+
let late: CaptionedItem[] = []
|
|
145
|
+
const sections = (query: string) => [
|
|
146
|
+
{ label: 'Recent', items: ITEMS.slice(0, 2).filter((item) => item.snippet.includes(query)) },
|
|
147
|
+
{ label: 'Empty', items: [] },
|
|
148
|
+
{ label: 'Found', items: late },
|
|
149
|
+
]
|
|
150
|
+
function Sectioned() {
|
|
151
|
+
const made = useEditor({
|
|
152
|
+
extensions: [
|
|
153
|
+
StarterKit,
|
|
154
|
+
Chip,
|
|
155
|
+
ReferenceTrigger.configure({
|
|
156
|
+
items: () => [],
|
|
157
|
+
sections,
|
|
158
|
+
subscribe: (listener) => {
|
|
159
|
+
listeners.add(listener)
|
|
160
|
+
return () => listeners.delete(listener)
|
|
161
|
+
},
|
|
162
|
+
nodeName: 'messageMention',
|
|
163
|
+
ariaLabel: 'Things',
|
|
164
|
+
sectionLabel: 'Things',
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
editor = made
|
|
169
|
+
return <EditorContent editor={made} />
|
|
170
|
+
}
|
|
171
|
+
render(<Sectioned />)
|
|
172
|
+
await type('[')
|
|
173
|
+
expect(options()).toEqual(['Ada: line 0', 'Ada: line 1'])
|
|
174
|
+
expect(screen.getByText('Recent')).toBeTruthy()
|
|
175
|
+
expect(screen.queryByText('Empty')).toBeNull()
|
|
176
|
+
expect(listeners.size).toBe(1)
|
|
177
|
+
|
|
178
|
+
// The person arrows to a row, then a late answer lands: their row stays highlighted.
|
|
179
|
+
await press('ArrowDown')
|
|
180
|
+
late = [{ id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example', description: 'Planning' }]
|
|
181
|
+
await act(async () => listeners.forEach((listener) => listener()))
|
|
182
|
+
expect(options()).toEqual(['Ada: line 0', 'Ada: line 1', 'Plan: Q3 roadmapPlanning'])
|
|
183
|
+
expect(screen.getByText('Found')).toBeTruthy()
|
|
184
|
+
await press('Enter')
|
|
185
|
+
expect(nodes().map((node) => node.attrs.id)).toEqual([ITEMS[1].id])
|
|
186
|
+
expect(listeners.size).toBe(0)
|
|
187
|
+
|
|
188
|
+
// A row that arrived late is picked like any other.
|
|
189
|
+
await type('[')
|
|
190
|
+
await press('ArrowDown')
|
|
191
|
+
await press('ArrowDown')
|
|
192
|
+
await press('Enter')
|
|
193
|
+
expect(nodes()[1]).toEqual({ type: 'messageMention', attrs: { id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example' } })
|
|
194
|
+
|
|
195
|
+
await type('[')
|
|
196
|
+
expect(listeners.size).toBe(1)
|
|
197
|
+
await press('Escape')
|
|
198
|
+
expect(screen.queryByRole('listbox')).toBeNull()
|
|
199
|
+
// Escape closes the list and stops listening.
|
|
200
|
+
expect(listeners.size).toBe(0)
|
|
201
|
+
})
|
|
202
|
+
|
|
142
203
|
it('matches who said it, or the text it searches', () => {
|
|
143
204
|
expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id])
|
|
144
205
|
expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id])
|
package/src/ComposerTriggers.tsx
CHANGED
|
@@ -211,6 +211,14 @@ export interface CaptionedItem {
|
|
|
211
211
|
uri: string
|
|
212
212
|
/** What the query is matched against beside `label`. Default: `snippet`. */
|
|
213
213
|
search?: string
|
|
214
|
+
/** The row's second line, e.g. where the thing sits. The chip does not wear it. */
|
|
215
|
+
description?: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One labelled group of `[`'s rows, in the order the list draws them. */
|
|
219
|
+
export interface ReferenceSection {
|
|
220
|
+
label: string
|
|
221
|
+
items: readonly CaptionedItem[]
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
/** How many references the list shows. */
|
|
@@ -289,25 +297,57 @@ export interface ReferenceMenuOptions {
|
|
|
289
297
|
icon: ReactNode
|
|
290
298
|
}
|
|
291
299
|
|
|
292
|
-
/**
|
|
300
|
+
/** A row as the list holds it: which section it is drawn under, when the list has several. */
|
|
301
|
+
type ListedItem = CaptionedItem & { section?: string }
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The list `[` opens: `label: snippet` rows, the same box as the people list.
|
|
305
|
+
* One section headed `sectionLabel`, or — when the rows say which section they
|
|
306
|
+
* are in — a heading for each, in the order the rows arrive.
|
|
307
|
+
*/
|
|
293
308
|
export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
|
|
294
309
|
return {
|
|
295
310
|
ariaLabel,
|
|
296
311
|
width: 'w-[658px]',
|
|
297
312
|
maxHeight: 'max-h-[360px]',
|
|
298
|
-
sections: (items) =>
|
|
313
|
+
sections: (items: ListedItem[]) => {
|
|
314
|
+
const groups: { label: string; items: CaptionedItem[]; className: string }[] = []
|
|
315
|
+
for (const item of items) {
|
|
316
|
+
const label = item.section ?? sectionLabel
|
|
317
|
+
const last = groups[groups.length - 1]
|
|
318
|
+
if (last?.label === label) last.items.push(item)
|
|
319
|
+
else groups.push({ label, items: [item], className: 'px-2' })
|
|
320
|
+
}
|
|
321
|
+
return groups
|
|
322
|
+
},
|
|
299
323
|
itemKey: (item) => `reference-${item.id}`,
|
|
300
|
-
row: (item) => ({ leading: icon, label: captionOf(item.label, item.snippet), hint: <EnterHint /> }),
|
|
324
|
+
row: (item) => ({ leading: icon, label: captionOf(item.label, item.snippet), description: item.description, hint: <EnterHint /> }),
|
|
301
325
|
}
|
|
302
326
|
}
|
|
303
327
|
|
|
304
328
|
export interface ReferenceTriggerOptions extends ReferenceMenuOptions {
|
|
305
|
-
/** Everything `[` can offer, asked each time the query changes. */
|
|
329
|
+
/** Everything `[` can offer, asked each time the query changes. Narrowed by {@link filterReferences}. */
|
|
306
330
|
items: () => readonly CaptionedItem[]
|
|
331
|
+
/**
|
|
332
|
+
* In place of `items`: the sections to draw for what was typed, already
|
|
333
|
+
* narrowed and ordered by the app. Empty sections are not drawn.
|
|
334
|
+
*/
|
|
335
|
+
sections?: (query: string) => readonly ReferenceSection[]
|
|
336
|
+
/**
|
|
337
|
+
* Called with a listener while the list is open; the app calls it when what
|
|
338
|
+
* `sections` would answer has changed — a search landing — and the open list
|
|
339
|
+
* is drawn again for the same query. Returns the unsubscribe.
|
|
340
|
+
*/
|
|
341
|
+
subscribe?: (listener: () => void) => () => void
|
|
307
342
|
/** The node a pick inserts. Default `captionedReference`. */
|
|
308
343
|
nodeName: string
|
|
309
344
|
}
|
|
310
345
|
|
|
346
|
+
const listed = (options: ReferenceTriggerOptions, query: string): ListedItem[] =>
|
|
347
|
+
options.sections
|
|
348
|
+
? options.sections(query).flatMap((section) => section.items.map((item) => ({ ...item, section: section.label })))
|
|
349
|
+
: filterReferences(options.items(), query)
|
|
350
|
+
|
|
311
351
|
/**
|
|
312
352
|
* `[` — reference something, inserted as a {@link CaptionedReference} chip.
|
|
313
353
|
*
|
|
@@ -331,8 +371,8 @@ export const ReferenceTrigger = Extension.create<ReferenceTriggerOptions>({
|
|
|
331
371
|
const type = state.schema.nodes[options.nodeName]
|
|
332
372
|
return !!type && !!state.doc.resolve(range.from).parent.type.contentMatch.matchType(type)
|
|
333
373
|
},
|
|
334
|
-
items: ({ query }) =>
|
|
335
|
-
render: suggestionPopup(referenceMenu(options)),
|
|
374
|
+
items: ({ query }) => listed(options, query),
|
|
375
|
+
render: suggestionPopup({ ...referenceMenu(options), subscribe: options.subscribe, refresh: (query) => listed(options, query) }),
|
|
336
376
|
command: ({ editor, range, props: item }) => {
|
|
337
377
|
editor
|
|
338
378
|
.chain()
|
package/src/SuggestionMenu.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useState, type ReactNode, type Ref } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
|
|
2
2
|
import { MenuItem, MenuSection, MenuSeparator, type MenuItemProps } from './Menu'
|
|
3
3
|
import { Popover } from './Popover'
|
|
4
4
|
|
|
@@ -85,8 +85,18 @@ export function SuggestionMenu<T>({ rect, editorElement, ariaLabel, width, maxHe
|
|
|
85
85
|
const base = useId()
|
|
86
86
|
const listboxId = `${base}-list`
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
|
|
88
|
+
// The row the person arrowed to, by key — read as the list was when they
|
|
89
|
+
// moved, so not re-run when `keys` changes.
|
|
90
|
+
const chosen = useRef<string | null>(null)
|
|
91
|
+
useEffect(() => void (chosen.current = highlight > 0 ? (keys.split('\n')[highlight] ?? null) : null), [highlight])
|
|
92
|
+
|
|
93
|
+
// A new list is a new list: the old index may not exist in it. A row the
|
|
94
|
+
// person arrowed to stays highlighted while it is still listed, so an answer
|
|
95
|
+
// landing late does not move their pick under the Enter key.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const at = chosen.current ? keys.split('\n').indexOf(chosen.current) : -1
|
|
98
|
+
setHighlight(at > 0 ? at : 0)
|
|
99
|
+
}, [keys])
|
|
90
100
|
|
|
91
101
|
useImperativeHandle(handle, () => ({
|
|
92
102
|
// 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.
|
package/src/suggestionPopup.tsx
CHANGED
|
@@ -44,6 +44,15 @@ export interface SuggestionPopupOptions<T> {
|
|
|
44
44
|
sections?: (items: T[], query: string) => readonly SuggestionMenuSection<T>[]
|
|
45
45
|
itemKey: (item: T) => string
|
|
46
46
|
row: (item: T) => SuggestionMenuRow
|
|
47
|
+
/**
|
|
48
|
+
* With `refresh`: called with a listener while the list is open; the app
|
|
49
|
+
* calls it when its data has changed (a search landing), and the open list is
|
|
50
|
+
* drawn again for the same query. Returns the unsubscribe, run when the list
|
|
51
|
+
* closes however it closes.
|
|
52
|
+
*/
|
|
53
|
+
subscribe?: (listener: () => void) => () => void
|
|
54
|
+
/** The items for a query, as the plugin's `items` answers — asked again when `subscribe` fires. */
|
|
55
|
+
refresh?: (query: string) => T[]
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
type Props<T> = SuggestionProps<T> & { options: SuggestionPopupOptions<T> }
|
|
@@ -88,7 +97,12 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
|
|
|
88
97
|
return () => {
|
|
89
98
|
let component: ReactRenderer<SuggestionMenuHandle, Props<T>> | null = null
|
|
90
99
|
let container: HTMLDivElement | null = null
|
|
100
|
+
let last: SuggestionProps<T> | null = null
|
|
101
|
+
let unsubscribe: (() => void) | undefined
|
|
91
102
|
const exit = () => {
|
|
103
|
+
unsubscribe?.()
|
|
104
|
+
unsubscribe = undefined
|
|
105
|
+
last = null
|
|
92
106
|
if (!component) return
|
|
93
107
|
openCount = Math.max(0, openCount - 1)
|
|
94
108
|
lastClose = Date.now()
|
|
@@ -104,8 +118,22 @@ export function suggestionPopup<T>(options: SuggestionPopupOptions<T>): NonNulla
|
|
|
104
118
|
container = document.createElement('div')
|
|
105
119
|
document.body.appendChild(container)
|
|
106
120
|
container.appendChild(component.element)
|
|
121
|
+
last = props
|
|
122
|
+
const { subscribe, refresh } = options
|
|
123
|
+
if (subscribe && refresh) {
|
|
124
|
+
// The plugin asks for items only when the query changes; an answer
|
|
125
|
+
// landing later draws the open list again with the same props.
|
|
126
|
+
unsubscribe = subscribe(() => {
|
|
127
|
+
if (!last || !component) return
|
|
128
|
+
last = { ...last, items: refresh(last.query) }
|
|
129
|
+
component.updateProps({ ...last, options })
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
onUpdate: (props) => {
|
|
134
|
+
last = props
|
|
135
|
+
component?.updateProps({ ...props, options })
|
|
107
136
|
},
|
|
108
|
-
onUpdate: (props) => component?.updateProps({ ...props, options }),
|
|
109
137
|
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
|
110
138
|
if (event.key === 'Escape') {
|
|
111
139
|
exit()
|