@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.
@@ -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;CACpC;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,CAgClH"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
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.52.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: one section of `label: snippet` rows, the same box as the people list.",
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": [],
@@ -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])
@@ -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
- /** The list `[` opens: one section of `label: snippet` rows, the same box as the people list. */
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) => [{ label: sectionLabel, items, className: 'px-2' }],
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 }) => filterReferences(options.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()
@@ -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
- // A new list is a new list: the old index may not exist in it.
89
- useEffect(() => setHighlight(0), [keys])
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.
@@ -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()