@estiva-app/ui 0.50.4 → 0.52.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 (51) hide show
  1. package/dist/CollapsibleSection.d.ts +7 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/ComposerTriggers.d.ts +169 -0
  4. package/dist/ComposerTriggers.d.ts.map +1 -0
  5. package/dist/NavItem.d.ts +23 -3
  6. package/dist/NavItem.d.ts.map +1 -1
  7. package/dist/NavTree.d.ts +49 -0
  8. package/dist/NavTree.d.ts.map +1 -0
  9. package/dist/SectionLabel.d.ts +7 -1
  10. package/dist/SectionLabel.d.ts.map +1 -1
  11. package/dist/editor.d.ts +1 -0
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +313 -12
  14. package/dist/editor.js.map +4 -4
  15. package/dist/eslint/index.js +1 -1
  16. package/dist/eslint/index.js.map +2 -2
  17. package/dist/eslint/no-native-title.d.ts +1 -1
  18. package/dist/eslint/no-native-title.d.ts.map +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +332 -212
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +7 -0
  24. package/dist/looks.d.ts.map +1 -1
  25. package/package.json +6 -1
  26. package/registry.json +426 -5
  27. package/src/CollapsibleSection.mdx +6 -0
  28. package/src/CollapsibleSection.stories.tsx +5 -0
  29. package/src/CollapsibleSection.tsx +12 -4
  30. package/src/ComposerTriggers.mdx +108 -0
  31. package/src/ComposerTriggers.stories.tsx +79 -0
  32. package/src/ComposerTriggers.test.tsx +207 -0
  33. package/src/ComposerTriggers.tsx +507 -0
  34. package/src/NavItem.mdx +30 -0
  35. package/src/NavItem.stories.tsx +34 -0
  36. package/src/NavItem.test.tsx +82 -0
  37. package/src/NavItem.tsx +123 -25
  38. package/src/NavTree.mdx +83 -0
  39. package/src/NavTree.stories.tsx +84 -0
  40. package/src/NavTree.test.tsx +54 -0
  41. package/src/NavTree.tsx +112 -0
  42. package/src/SectionHeader.tsx +1 -1
  43. package/src/SectionLabel.mdx +4 -1
  44. package/src/SectionLabel.stories.tsx +3 -0
  45. package/src/SectionLabel.tsx +10 -2
  46. package/src/editor.ts +29 -0
  47. package/src/eslint/no-native-title.ts +1 -1
  48. package/src/index.ts +1 -0
  49. package/src/looks.ts +8 -0
  50. package/src/registry/registry.test.ts +9 -8
  51. package/stories/Choosing.mdx +2 -0
@@ -0,0 +1,507 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Extension, type Editor } from '@tiptap/core'
3
+ import { PluginKey } from '@tiptap/pm/state'
4
+ import Mention, { type MentionOptions } from '@tiptap/extension-mention'
5
+ import Suggestion from '@tiptap/suggestion'
6
+ import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewProps } from '@tiptap/react'
7
+ import { Avatar } from './Avatar'
8
+ import { EnterHint } from './Menu'
9
+ import { inlineChipClassName } from './InlineChip'
10
+ import type { SuggestionMenuRow } from './SuggestionMenu'
11
+ import { suggestionPopup, type SuggestionPopupOptions } from './suggestionPopup'
12
+
13
+ /*
14
+ * The four composer triggers every Estiva app shares — `@`, `!@`, `[` and `/`
15
+ * (CON-26, Miky 2026-10-02). Peek built them first and they move here unchanged
16
+ * (CON-27); an app passes in only its own data: its people, the things `[` can
17
+ * reference, and the `/` rows it can perform beside the shared Format section.
18
+ *
19
+ * **What a pick writes is not here.** The text a chip becomes in the body and
20
+ * the tags it earns are `@estiva-app/conversation`'s (`mentionText`,
21
+ * `messageReference`, `urgentTagsFor`), so a client without React writes the
22
+ * same bytes. A chip here carries what that needs: a person's `pubkey`, a
23
+ * reference's `uri`.
24
+ */
25
+
26
+ // ─── @ and !@ ───
27
+
28
+ /** A person `@` and `!@` offer. */
29
+ export interface MentionPerson {
30
+ /** The app's own key for the person — the row's key and the chip's `id`. */
31
+ id: string
32
+ name: string
33
+ /** The second line of the row: a role, a team. */
34
+ description?: string
35
+ /** Their Nostr key, when they have one — what the body is written with (SPEC §13.1). */
36
+ pubkey?: string | null
37
+ /** The face at the row's start. Default: the package `Avatar` for the name. */
38
+ avatar?: ReactNode
39
+ }
40
+
41
+ /** How many people the list shows. */
42
+ export const PEOPLE_LIMIT = 6
43
+
44
+ /** The people whose name holds the query, the first {@link PEOPLE_LIMIT} of them. */
45
+ export function filterPeople(people: readonly MentionPerson[], query: string): MentionPerson[] {
46
+ const q = query.toLowerCase()
47
+ return people.filter((person) => q === '' || person.name.toLowerCase().includes(q)).slice(0, PEOPLE_LIMIT)
48
+ }
49
+
50
+ /**
51
+ * The people list `@` and `!@` open: the face, the name, the second line, and
52
+ * the Enter hint while highlighted — `MenuItem` at its tall size.
53
+ */
54
+ export function peopleMenu(urgent: boolean): SuggestionPopupOptions<MentionPerson> {
55
+ const section = urgent ? 'Urgent mention' : 'People'
56
+ return {
57
+ ariaLabel: section,
58
+ // The people and the reference lists are the same box: 658px, capped at 360.
59
+ width: 'w-[658px]',
60
+ maxHeight: 'max-h-[360px]',
61
+ sections: (people) => [{ label: section, items: people, className: 'px-3 py-1' }],
62
+ itemKey: (person) => person.id,
63
+ row: (person) => ({
64
+ label: person.name,
65
+ description: person.description,
66
+ leading: person.avatar ?? <Avatar size={32} alt={person.name} />,
67
+ hint: <EnterHint />,
68
+ size: 'tall',
69
+ }),
70
+ }
71
+ }
72
+
73
+ export interface PersonMentionOptions {
74
+ /** Everyone the list can offer, asked each time the query changes. */
75
+ people: () => readonly MentionPerson[]
76
+ }
77
+
78
+ type Doc = { descendants: (cb: (node: { type: { name: string }; attrs: Record<string, unknown> }) => boolean | void) => void }
79
+
80
+ function optionsOf<T>(editor: Editor, name: string): T | undefined {
81
+ return editor.extensionManager.extensions.find((one) => one.name === name)?.options as T | undefined
82
+ }
83
+
84
+ /** `@`'s list and pick, or `!@`'s, for the node `name` — the extension's own, so a renamed one still finds its people. */
85
+ function personSuggestion(urgent: boolean, name: string) {
86
+ return {
87
+ ...(urgent ? { char: '!@' } : {}),
88
+ items: ({ query, editor }: { query: string; editor: Editor }) => filterPeople(optionsOf<PersonMentionOptions>(editor, name)?.people() ?? [], query),
89
+ render: suggestionPopup(peopleMenu(urgent)),
90
+ command: ({ editor, range, props: person }: { editor: Editor; range: { from: number; to: number }; props: MentionPerson }) => {
91
+ editor
92
+ .chain()
93
+ .focus()
94
+ .insertContentAt(range, [
95
+ { type: name, attrs: { id: person.id, label: person.name, pubkey: person.pubkey ?? null } },
96
+ { type: 'text', text: ' ' },
97
+ ])
98
+ .run()
99
+ },
100
+ }
101
+ }
102
+
103
+ /*
104
+ What a chip keeps through HTML — a copy and paste, inside an editor or
105
+ between two. The chip's own `renderHTML` writes each as a `data-*` attribute
106
+ and these read them back. Until CON-27 none was written, so a pasted chip
107
+ came back with every value null and was sent as `@null`. What is read is
108
+ checked: a crafted `data-pubkey` that is not a key, or a `data-uri` that is
109
+ not a `nostr:` reference, is dropped rather than written into the body.
110
+ */
111
+ const HEX_KEY = /^[0-9a-f]{64}$/
112
+ const NOSTR_URI = /^nostr:[a-z0-9]+$/i
113
+ const fromData = (name: string, valid?: RegExp) => ({
114
+ parseHTML: (element: HTMLElement) => {
115
+ const value = element.getAttribute(`data-${name}`)
116
+ return value !== null && (!valid || valid.test(value)) ? value : null
117
+ },
118
+ // The node's own `renderHTML` writes it.
119
+ renderHTML: () => ({}),
120
+ })
121
+
122
+ const personAttributes = () => ({
123
+ id: { default: null, ...fromData('id') },
124
+ label: { default: null, ...fromData('label') },
125
+ /**
126
+ * The person's Nostr key, when they have one — SPEC §13.1. `id` is the app's
127
+ * own and means nothing to another app; this is what the body is written
128
+ * with, so a mention survives a rename.
129
+ */
130
+ pubkey: { default: null, ...fromData('pubkey', HEX_KEY) },
131
+ })
132
+
133
+ /** The `data-*` a person chip is written with, so a paste reads it back. */
134
+ function personData(attrs: Record<string, unknown>): Record<string, string> {
135
+ return {
136
+ 'data-id': String(attrs.id ?? ''),
137
+ 'data-label': String(attrs.label ?? ''),
138
+ ...(typeof attrs.pubkey === 'string' && attrs.pubkey ? { 'data-pubkey': attrs.pubkey } : {}),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * `@` — a person, as a chip that writes their key (SPEC §13.1).
144
+ *
145
+ * PersonMention.configure({ people: () => directory })
146
+ *
147
+ * Its `pubkey` and `label` are what `mentionText` takes.
148
+ */
149
+ export const PersonMention = Mention.extend<MentionOptions<MentionPerson, MentionPerson> & PersonMentionOptions>({
150
+ name: 'mention',
151
+ addOptions() {
152
+ return { ...this.parent!(), HTMLAttributes: {}, people: () => [], suggestion: personSuggestion(false, this.name) }
153
+ },
154
+ addAttributes: personAttributes,
155
+ renderHTML({ node }) {
156
+ // No node view, so this spec is what the editor draws: the one chip.
157
+ return ['span', { 'data-mention': 'true', ...personData(node.attrs), class: inlineChipClassName('person', 'cursor-default') }, `@${node.attrs.label}`]
158
+ },
159
+ parseHTML() {
160
+ return [{ tag: 'span[data-mention]' }]
161
+ },
162
+ })
163
+
164
+ /**
165
+ * `!@` — a person, urgently: the same chip in the urgent tone. Its text is an
166
+ * ordinary mention; the urgency travels as a tag (`urgentTagsFor`), so hand the
167
+ * write {@link urgentPubkeys}.
168
+ */
169
+ export const UrgentPersonMention = Mention.extend<MentionOptions<MentionPerson, MentionPerson> & PersonMentionOptions>({
170
+ name: 'urgentMention',
171
+ addOptions() {
172
+ return { ...this.parent!(), HTMLAttributes: {}, people: () => [], suggestion: personSuggestion(true, this.name) }
173
+ },
174
+ addAttributes: personAttributes,
175
+ renderHTML({ node }) {
176
+ return [
177
+ 'span',
178
+ { 'data-urgent-mention': 'true', ...personData(node.attrs), class: inlineChipClassName('urgent', 'cursor-default') },
179
+ `@${node.attrs.label}`,
180
+ ]
181
+ },
182
+ parseHTML() {
183
+ return [{ tag: 'span[data-urgent-mention]' }]
184
+ },
185
+ })
186
+
187
+ /**
188
+ * The keys the document's urgent mentions name — what the write turns into
189
+ * `["urgent", <pubkey>]` tags. A chip with no key has no tag to carry; its text
190
+ * is the old `!@Name`.
191
+ */
192
+ export function urgentPubkeys(editor: { state: { doc: Doc } }): string[] | undefined {
193
+ const urgent: string[] = []
194
+ editor.state.doc.descendants((node) => {
195
+ if (node.type.name === 'urgentMention' && typeof node.attrs.pubkey === 'string' && node.attrs.pubkey) urgent.push(node.attrs.pubkey)
196
+ })
197
+ return urgent.length > 0 ? urgent : undefined
198
+ }
199
+
200
+ // ─── [ ───
201
+
202
+ /** Something `[` offers: a reference drawn as who said it and how it opens. */
203
+ export interface CaptionedItem {
204
+ /** The referenced thing's id — the row's key and the chip's `id`. */
205
+ id: string
206
+ /** Who, as the list shows them. */
207
+ label: string
208
+ /** The opening words, marks stripped: the caption the row and the chip wear. */
209
+ snippet: string
210
+ /** `nostr:…`, exactly as the body will carry it (`messageReference`). */
211
+ uri: string
212
+ /** What the query is matched against beside `label`. Default: `snippet`. */
213
+ search?: string
214
+ }
215
+
216
+ /** How many references the list shows. */
217
+ export const REFERENCE_LIMIT = 6
218
+
219
+ /**
220
+ * The references whose label or text holds the query — the last
221
+ * {@link REFERENCE_LIMIT}, in the order given: a thread is unbounded, and a
222
+ * list that grew with it would be a hundred rows of prose.
223
+ */
224
+ export function filterReferences(items: readonly CaptionedItem[], query: string): CaptionedItem[] {
225
+ const q = query.toLowerCase()
226
+ const shown = q ? items.filter((item) => (item.search ?? item.snippet).toLowerCase().includes(q) || item.label.toLowerCase().includes(q)) : [...items]
227
+ return shown.slice(-REFERENCE_LIMIT)
228
+ }
229
+
230
+ const captionOf = (label: string, snippet: string) => (snippet ? `${label}: ${snippet}` : label)
231
+
232
+ export interface CaptionedReferenceOptions {
233
+ /** Drawn before the caption: a 14px icon. */
234
+ icon: ReactNode
235
+ }
236
+
237
+ /**
238
+ * A reference mid-sentence, drawn as `label: snippet` and written as its `uri`
239
+ * verbatim (SPEC §13.1). The caption is the snapshot it was picked with — a
240
+ * caption, never parsed. Inserted by {@link ReferenceTrigger}, a paste or a
241
+ * draft; it opens no list of its own.
242
+ */
243
+ export const CaptionedReference = Mention.extend<MentionOptions & CaptionedReferenceOptions>({
244
+ name: 'captionedReference',
245
+ group: 'inline',
246
+ inline: true,
247
+ atom: true,
248
+ addOptions() {
249
+ return { ...this.parent!(), HTMLAttributes: {}, icon: null, suggestion: { char: '\0', items: () => [] } }
250
+ },
251
+ addAttributes() {
252
+ return {
253
+ id: { default: null, ...fromData('id') },
254
+ label: { default: null, ...fromData('label') },
255
+ snippet: { default: '', parseHTML: (element: HTMLElement) => element.getAttribute('data-snippet') ?? '', renderHTML: () => ({}) },
256
+ uri: { default: null, ...fromData('uri', NOSTR_URI) },
257
+ }
258
+ },
259
+ renderHTML({ node }) {
260
+ return [
261
+ 'span',
262
+ { [`data-${this.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`]: 'true', 'data-id': String(node.attrs.id ?? ''), 'data-label': String(node.attrs.label ?? ''), 'data-snippet': String(node.attrs.snippet ?? ''), ...(node.attrs.uri ? { 'data-uri': String(node.attrs.uri) } : {}), class: inlineChipClassName('neutral', 'cursor-default') },
263
+ captionOf(node.attrs.label, node.attrs.snippet),
264
+ ]
265
+ },
266
+ parseHTML() {
267
+ return [{ tag: `span[data-${this.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}]` }]
268
+ },
269
+ addNodeView() {
270
+ const icon = this.options.icon
271
+ return ReactNodeViewRenderer(
272
+ ({ node }: ReactNodeViewProps) => (
273
+ <NodeViewWrapper as="span" className={inlineChipClassName('neutral', 'max-w-[24ch] cursor-default')}>
274
+ <span className="flex items-center justify-center w-4 h-4 shrink-0 text-text-secondary">{icon}</span>
275
+ <span className="truncate">{captionOf(node.attrs.label, node.attrs.snippet)}</span>
276
+ </NodeViewWrapper>
277
+ ),
278
+ { as: 'span' },
279
+ )
280
+ },
281
+ })
282
+
283
+ export interface ReferenceMenuOptions {
284
+ /** Names the list, e.g. what can be referenced. */
285
+ ariaLabel: string
286
+ /** The section's heading. */
287
+ sectionLabel: string
288
+ /** At each row's start: a 16px icon. */
289
+ icon: ReactNode
290
+ }
291
+
292
+ /** The list `[` opens: one section of `label: snippet` rows, the same box as the people list. */
293
+ export function referenceMenu({ ariaLabel, sectionLabel, icon }: ReferenceMenuOptions): SuggestionPopupOptions<CaptionedItem> {
294
+ return {
295
+ ariaLabel,
296
+ width: 'w-[658px]',
297
+ maxHeight: 'max-h-[360px]',
298
+ sections: (items) => [{ label: sectionLabel, items, className: 'px-2' }],
299
+ itemKey: (item) => `reference-${item.id}`,
300
+ row: (item) => ({ leading: icon, label: captionOf(item.label, item.snippet), hint: <EnterHint /> }),
301
+ }
302
+ }
303
+
304
+ export interface ReferenceTriggerOptions extends ReferenceMenuOptions {
305
+ /** Everything `[` can offer, asked each time the query changes. */
306
+ items: () => readonly CaptionedItem[]
307
+ /** The node a pick inserts. Default `captionedReference`. */
308
+ nodeName: string
309
+ }
310
+
311
+ /**
312
+ * `[` — reference something, inserted as a {@link CaptionedReference} chip.
313
+ *
314
+ * Its own extension rather than the chip's suggestion, so the chip's text (its
315
+ * `renderText`) does not change with the key that summons the list.
316
+ */
317
+ export const ReferenceTrigger = Extension.create<ReferenceTriggerOptions>({
318
+ name: 'referenceTrigger',
319
+ addOptions() {
320
+ return { items: () => [], nodeName: 'captionedReference', ariaLabel: 'References', sectionLabel: 'References', icon: null }
321
+ },
322
+ addProseMirrorPlugins() {
323
+ const options = this.options
324
+ return [
325
+ Suggestion<CaptionedItem>({
326
+ editor: this.editor,
327
+ char: '[',
328
+ pluginKey: new PluginKey('referenceTrigger'),
329
+ // As a mention's: only where the chip may go.
330
+ allow: ({ state, range }) => {
331
+ const type = state.schema.nodes[options.nodeName]
332
+ return !!type && !!state.doc.resolve(range.from).parent.type.contentMatch.matchType(type)
333
+ },
334
+ items: ({ query }) => filterReferences(options.items(), query),
335
+ render: suggestionPopup(referenceMenu(options)),
336
+ command: ({ editor, range, props: item }) => {
337
+ editor
338
+ .chain()
339
+ .focus()
340
+ .insertContentAt(range, [
341
+ { type: options.nodeName, attrs: { id: item.id, label: item.label, snippet: item.snippet, uri: item.uri } },
342
+ { type: 'text', text: ' ' },
343
+ ])
344
+ .run()
345
+ },
346
+ }),
347
+ ]
348
+ },
349
+ })
350
+
351
+ // ─── / ───
352
+
353
+ /** A row of the `/` list, and what choosing it does. */
354
+ export interface SlashCommand {
355
+ key: string
356
+ row: SuggestionMenuRow
357
+ /** Matched beside the row's label: an id, a trigger, a description. */
358
+ keywords?: readonly string[]
359
+ /** Runs once the `/query` that summoned the list has been removed. */
360
+ run: (editor: Editor) => void
361
+ }
362
+
363
+ export interface SlashSection {
364
+ label: string
365
+ commands: readonly SlashCommand[]
366
+ }
367
+
368
+ export type FormatId = 'paragraph' | 'heading' | 'subheading' | 'quote' | 'bulletList' | 'orderedList' | 'code'
369
+
370
+ export interface FormatDef {
371
+ id: FormatId
372
+ label: string
373
+ /** What you type to get it — the input rule the extension registers. Blank when there is none. */
374
+ trigger?: string
375
+ }
376
+
377
+ /**
378
+ * The formats `/` offers, in order. The hint is what you would type, not a key
379
+ * chord. Bold, italic and underline are absent: they act on text already
380
+ * written, so they are the selection toolbar's.
381
+ */
382
+ export const FORMATS: readonly FormatDef[] = [
383
+ { id: 'paragraph', label: 'Text' },
384
+ { id: 'heading', label: 'Heading', trigger: '#' },
385
+ { id: 'subheading', label: 'Subheading', trigger: '##' },
386
+ { id: 'quote', label: 'Quote', trigger: '>' },
387
+ { id: 'code', label: 'Code', trigger: '`' },
388
+ { id: 'bulletList', label: 'Bulleted list', trigger: '-' },
389
+ { id: 'orderedList', label: 'Numbered list', trigger: '1.' },
390
+ ]
391
+
392
+ /** Code as a mark inside the line, or as a block of its own. */
393
+ export type CodeFormat = 'inline' | 'block'
394
+
395
+ /**
396
+ * Apply one format to the current block. `set*` wherever Tiptap has one:
397
+ * choosing Heading should make a heading, not turn one off. `false` means
398
+ * nothing changed, not that anything failed.
399
+ */
400
+ export function applyFormat(editor: Editor, id: FormatId, code: CodeFormat = 'inline'): boolean {
401
+ const chain = editor.chain().focus()
402
+ /*
403
+ The node's own command, called by name: StarterKit's extensions declare
404
+ them, and this package builds without StarterKit. Its own command, rather
405
+ than the core one it wraps, because a list's runs with the app's options
406
+ (`keepMarks`, `itemTypeName`). An editor without that node changes nothing.
407
+ */
408
+ const run = (command: string, ...args: unknown[]): boolean => {
409
+ const named = (chain as unknown as Record<string, ((...a: unknown[]) => { run: () => boolean }) | undefined>)[command]
410
+ return named ? named(...args).run() : false
411
+ }
412
+ switch (id) {
413
+ case 'paragraph':
414
+ return run('setParagraph')
415
+ case 'heading':
416
+ return run('setHeading', { level: 1 })
417
+ case 'subheading':
418
+ return run('setHeading', { level: 2 })
419
+ case 'quote':
420
+ return run('toggleBlockquote')
421
+ case 'bulletList':
422
+ return run('toggleBulletList')
423
+ case 'orderedList':
424
+ return run('toggleOrderedList')
425
+ case 'code':
426
+ return run(code === 'block' ? 'toggleCodeBlock' : 'toggleCode')
427
+ default:
428
+ return false
429
+ }
430
+ }
431
+
432
+ /** The Format section every app's `/` opens with. */
433
+ export function formatSection(code: CodeFormat = 'inline'): SlashSection {
434
+ return {
435
+ label: 'Format',
436
+ commands: FORMATS.map((format) => ({
437
+ key: `format-${format.id}`,
438
+ row: { label: format.label, shortcut: format.trigger },
439
+ keywords: [format.id],
440
+ run: (editor) => {
441
+ applyFormat(editor, format.id, code)
442
+ },
443
+ })),
444
+ }
445
+ }
446
+
447
+ /** A row that types another trigger, so its own list opens — `@` from `/`. */
448
+ export function typeTrigger(input: string): SlashCommand['run'] {
449
+ // Deferred a frame: the delete of the `/query` has to land first, or the
450
+ // trigger's plugin sees the character arrive inside a range still being removed.
451
+ return (editor) => {
452
+ requestAnimationFrame(() => editor.commands.insertContent(input))
453
+ }
454
+ }
455
+
456
+ function matches(command: SlashCommand, q: string): boolean {
457
+ return [command.row.label, ...(command.keywords ?? [])].some((text) => text.toLowerCase().includes(q))
458
+ }
459
+
460
+ /** The sections narrowed to a query — the text typed after the `/`. Empty sections stay. */
461
+ export function filterSlashSections(sections: readonly SlashSection[], query: string): SlashSection[] {
462
+ const q = query.toLowerCase()
463
+ return sections.map((section) => ({ ...section, commands: q ? section.commands.filter((command) => matches(command, q)) : section.commands }))
464
+ }
465
+
466
+ export interface SlashCommandsOptions {
467
+ /** The sections, in order: {@link formatSection} first, then the app's own. */
468
+ sections: readonly SlashSection[]
469
+ }
470
+
471
+ type SlashRow = SlashCommand & { section: number }
472
+
473
+ /**
474
+ * `/` — format the line or insert something, at the caret, mid-sentence too.
475
+ * A `/` inside a word (`and/or`, a path) opens nothing: the plugin wants a
476
+ * space or the line's start before it.
477
+ */
478
+ export const SlashCommands = Extension.create<SlashCommandsOptions>({
479
+ name: 'slashCommands',
480
+ addOptions() {
481
+ return { sections: [formatSection()] }
482
+ },
483
+ addProseMirrorPlugins() {
484
+ const sections = this.options.sections
485
+ return [
486
+ Suggestion<SlashRow>({
487
+ editor: this.editor,
488
+ char: '/',
489
+ startOfLine: false,
490
+ pluginKey: new PluginKey('slashCommands'),
491
+ items: ({ query }) => filterSlashSections(sections, query).flatMap((section, at) => section.commands.map((command) => ({ ...command, section: at }))),
492
+ command: ({ editor, range, props }) => {
493
+ editor.chain().focus().deleteRange(range).run()
494
+ props.run(editor)
495
+ },
496
+ render: suggestionPopup<SlashRow>({
497
+ ariaLabel: 'Commands',
498
+ width: 'w-[300px]',
499
+ maxHeight: 'max-h-[400px]',
500
+ sections: (rows) => sections.map((section, at) => ({ label: section.label, items: rows.filter((row) => row.section === at) })),
501
+ itemKey: (row) => row.key,
502
+ row: (row) => row.row,
503
+ }),
504
+ }),
505
+ ]
506
+ },
507
+ })
package/src/NavItem.mdx CHANGED
@@ -28,6 +28,19 @@ count — a muted mono number whose tooltip says what it counts.
28
28
 
29
29
  <Canvas of={NavItemStories.WithMenu} />
30
30
 
31
+ - A row with rows under it takes them as `children`: more `NavItem`s. The
32
+ row stays a link; while it is pointed at or focused, its icon becomes the
33
+ arrow that folds them, in the icon's own place, and they slide shut. For a
34
+ whole tree from one list, use **NavTree**.
35
+
36
+ <Canvas of={NavItemStories.WithRowsUnder} />
37
+
38
+ - A word that helps only while the row is pointed at — where the item comes
39
+ from — is `hint`: pass a neutral **Chip**. It shows left of the count, and
40
+ the label ends with "…" before it instead of running under it.
41
+
42
+ <Canvas of={NavItemStories.WithHintLongLabel} />
43
+
31
44
  ## When not
32
45
 
33
46
  - An icon rail's tile → **RailItem**.
@@ -55,6 +68,11 @@ import { NavItem } from '@estiva-app/ui'
55
68
  - The label truncates; the count never gives up its width.
56
69
  - `menu` is the items only: `menu={<MenuItem label="Move to Folder…" onClick={move} />}`.
57
70
  The ⋮'s name is `More options for {label}` unless `menuLabel` says otherwise.
71
+ - `children` start open; `defaultOpen={false}` starts them folded, and
72
+ `open` with `onOpenChange` hands the state to the app. The arrow's name is
73
+ `Hide what is under {label}` or `Show what is under {label}`.
74
+ - `hint` takes room only while it shows, so a label at rest keeps its full
75
+ width.
58
76
 
59
77
  - A row is 32px tall, 8px in on each side, 8px between the icon and the label,
60
78
  the label 14px regular, 6px corners.
@@ -68,6 +86,18 @@ It does these by itself. Write none of them around it.
68
86
  | Marks the current place for assistive technology when `active` | `aria-current` | |
69
87
  | Keeps its 32px in a scrolling column | `shrink-0` | |
70
88
  | Shows a ⋮ over the count while pointed at, focused or open, and keeps the row's hover look under it | a positioned `IconButton` + `Menu` over the row, `group-hover` reveals | |
89
+ | Turns its icon into the arrow that folds `children` while pointed at or focused, and slides them | a positioned `IconButton` over the icon, open state, `group-hover` reveals, an indented box | |
90
+ | Tells assistive technology whether its rows are open | `aria-expanded`, `aria-controls` | |
91
+ | Shows `hint` only while pointed at or focused, and makes the label give way | a positioned chip over the row, `group-hover` reveals | |
92
+
93
+ ## Keys
94
+
95
+ | Key | Does |
96
+ |---|---|
97
+ | Tab | the link, then its arrow (with `children`), then its ⋮ (with `menu`) |
98
+ | Enter, Space | on the arrow: folds or unfolds the rows under it |
99
+
100
+ The rows are not an ARIA tree: there are no arrow keys between rows yet.
71
101
 
72
102
  ## Props
73
103
 
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { Chip } from './Chip'
3
4
  import { MenuItem } from './Menu'
4
5
  import { NavItem } from './NavItem'
5
6
 
@@ -60,3 +61,36 @@ export const WithMenuActive: Story = {
60
61
  export const WithMenuNoCount: Story = {
61
62
  args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, menu: MENU },
62
63
  }
64
+
65
+ /** A hint shows only while the row is pointed at or focused, left of the count: here a neutral Chip. */
66
+ export const WithHint: Story = {
67
+ args: { icon: placeholder, count: 3, countLabel: '3 open', hint: <Chip type="neutral" label="Label" /> },
68
+ }
69
+
70
+ /** A long label gives way to the hint while it shows: it ends with "…" before the chip instead of running under it. */
71
+ export const WithHintLongLabel: Story = {
72
+ args: { label: 'An item whose label runs much longer than the column has room for', icon: placeholder, count: 12, countLabel: '12 open', hint: <Chip type="neutral" label="Label" /> },
73
+ }
74
+
75
+ /** Rows under a row: point at it and its icon becomes the arrow that folds them. The row itself stays a link. */
76
+ export const WithRowsUnder: Story = {
77
+ args: { icon: placeholder },
78
+ render: (args) => (
79
+ <NavItem {...args}>
80
+ <NavItem href="#" label="Item two" icon={placeholder} />
81
+ <NavItem href="#" label="Item three" icon={placeholder}>
82
+ <NavItem href="#" label="Item four" icon={placeholder} />
83
+ </NavItem>
84
+ </NavItem>
85
+ ),
86
+ }
87
+
88
+ /** Starts folded: point at it, or Tab to its arrow, to open it. */
89
+ export const WithRowsUnderClosed: Story = {
90
+ args: { icon: placeholder, defaultOpen: false },
91
+ render: (args) => (
92
+ <NavItem {...args}>
93
+ <NavItem href="#" label="Item two" icon={placeholder} />
94
+ </NavItem>
95
+ ),
96
+ }
@@ -0,0 +1,82 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the page claims for the rows under a row and the hint: the arrow folds
4
+ * the rows and says so, the rows are hidden rather than gone, the row itself
5
+ * stays a link a click does not fold, and the hint is there to be revealed.
6
+ * The reveal on hover and the slide are Chrome's to show.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { NavItem } from './NavItem'
12
+
13
+ afterEach(cleanup)
14
+
15
+ const arrow = (name: RegExp) => screen.getByRole('button', { name })
16
+
17
+ describe('NavItem with rows under it', () => {
18
+ const tree = (props: Partial<Parameters<typeof NavItem>[0]> = {}) => (
19
+ <NavItem href="#one" label="Item one" {...props}>
20
+ <NavItem href="#two" label="Item two" />
21
+ </NavItem>
22
+ )
23
+
24
+ it('starts open: the arrow says so and names what it folds, and the rows are there', () => {
25
+ render(tree())
26
+ const button = arrow(/Hide what is under Item one/)
27
+ expect(button.getAttribute('aria-expanded')).toBe('true')
28
+ expect(document.getElementById(button.getAttribute('aria-controls') ?? '')).not.toBeNull()
29
+ expect(screen.getByText('Item two').closest('[hidden]')).toBeNull()
30
+ })
31
+
32
+ it('the arrow folds the rows, hidden rather than gone, and unfolds them', async () => {
33
+ render(tree())
34
+ await userEvent.click(arrow(/Hide what is under Item one/))
35
+ expect(arrow(/Show what is under Item one/).getAttribute('aria-expanded')).toBe('false')
36
+ expect(screen.getByText('Item two').closest('[hidden]')).not.toBeNull()
37
+ await userEvent.click(arrow(/Show what is under Item one/))
38
+ expect(screen.getByText('Item two').closest('[hidden]')).toBeNull()
39
+ })
40
+
41
+ it('a click on the row is the link’s, and does not fold it', async () => {
42
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
43
+ render(tree({ onClick }))
44
+ await userEvent.click(screen.getByRole('link', { name: 'Item one' }))
45
+ expect(onClick).toHaveBeenCalledTimes(1)
46
+ expect(arrow(/Hide what is under Item one/).getAttribute('aria-expanded')).toBe('true')
47
+ })
48
+
49
+ it('starts folded with defaultOpen false, and reports a change through onOpenChange', async () => {
50
+ const onOpenChange = vi.fn()
51
+ render(tree({ defaultOpen: false, onOpenChange }))
52
+ expect(screen.getByText('Item two').closest('[hidden]')).not.toBeNull()
53
+ await userEvent.click(arrow(/Show what is under Item one/))
54
+ expect(onOpenChange).toHaveBeenCalledWith(true)
55
+ })
56
+
57
+ it('Enter on the arrow folds the rows', async () => {
58
+ render(tree())
59
+ arrow(/Hide what is under Item one/).focus()
60
+ await userEvent.keyboard('{Enter}')
61
+ expect(arrow(/Show what is under Item one/).getAttribute('aria-expanded')).toBe('false')
62
+ })
63
+
64
+ it('a row with a menu and rows under it: the open rows do not hold the ⋮ open', () => {
65
+ render(tree({ menu: <span>Action</span> }))
66
+ const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
67
+ expect(menu).not.toBeNull()
68
+ expect(menu?.querySelector('[aria-expanded=true]')).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('NavItem with a hint', () => {
73
+ it('draws the hint inside the row, beside the label', () => {
74
+ render(<NavItem href="#" label="Item one" hint={<span>Label</span>} />)
75
+ expect(screen.getByRole('link', { name: /Item one/ }).textContent).toContain('Label')
76
+ })
77
+
78
+ it('without children, a menu or a hint, the row is the bare link', () => {
79
+ const { container } = render(<NavItem href="#" label="Item one" />)
80
+ expect(container.firstElementChild?.tagName).toBe('A')
81
+ })
82
+ })