@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,108 @@
1
+ import { Meta, Canvas } from '@storybook/addon-docs/blocks'
2
+ import * as ComposerTriggersStories from './ComposerTriggers.stories'
3
+
4
+ <Meta of={ComposerTriggersStories} />
5
+
6
+ # ComposerTriggers
7
+
8
+ The four keys a composer shares across apps: `@` and `!@` to mention a person,
9
+ `[` to reference something, `/` to format the line or insert something. Each
10
+ opens a `SuggestionMenu` at the caret; a pick inserts a chip.
11
+
12
+ <Canvas of={ComposerTriggersStories.Default} />
13
+
14
+ ## When
15
+
16
+ - **`PersonMention` and `UrgentPersonMention`** — any editor whose text names
17
+ people. The urgent one is the same chip in the urgent tone.
18
+ - **`CaptionedReference` and `ReferenceTrigger`** — referencing something the
19
+ app can list, drawn as who wrote it and its opening words.
20
+ - **`SlashCommands`** — `formatSection()` first, then only the rows the app can
21
+ perform. `formatSection('block')` makes Code a block, for an editor of a
22
+ block document.
23
+
24
+ ## When not
25
+
26
+ - Formatting text already written → **SelectionToolbar**. Bold, italic and
27
+ underline are not in `/` for that reason.
28
+ - A list that is not opened by a key typed in an editor → **CommandPalette** or
29
+ **Menu**.
30
+ - Drawing a sent body → **RichText**. These are for editing only.
31
+
32
+ ## How
33
+
34
+ ```tsx
35
+ import { EditorContent, useEditor } from '@tiptap/react'
36
+ import StarterKit from '@tiptap/starter-kit'
37
+ import { IconSquareRounded } from '@tabler/icons-react'
38
+ import {
39
+ CaptionedReference,
40
+ PersonMention,
41
+ ReferenceTrigger,
42
+ SlashCommands,
43
+ UrgentPersonMention,
44
+ formatSection,
45
+ typeTrigger,
46
+ urgentPubkeys,
47
+ type CaptionedItem,
48
+ type MentionPerson,
49
+ } from '@estiva-app/ui/editor'
50
+
51
+ const people = (): MentionPerson[] => [{ id: 'one', name: 'Person one', pubkey: null }]
52
+ const items = (): CaptionedItem[] => [{ id: 'one', label: 'Person one', snippet: 'Item one', uri: 'nostr:item' }]
53
+
54
+ const editor = useEditor({
55
+ extensions: [
56
+ StarterKit,
57
+ PersonMention.configure({ people }),
58
+ UrgentPersonMention.configure({ people }),
59
+ CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
60
+ ReferenceTrigger.configure({ items, ariaLabel: 'Items', sectionLabel: 'Items', icon: <IconSquareRounded size={16} stroke={1.5} /> }),
61
+ SlashCommands.configure({
62
+ sections: [formatSection(), { label: 'Insert', commands: [{ key: 'insert-@', row: { label: 'Mention', shortcut: '@' }, run: typeTrigger('@') }] }],
63
+ }),
64
+ ],
65
+ })
66
+
67
+ const urgent = editor ? urgentPubkeys(editor) : undefined
68
+
69
+ <EditorContent editor={editor} />
70
+ ```
71
+
72
+ - **The body is written by `@estiva-app/conversation`, not here.** A mention
73
+ chip carries `pubkey` and `label` for its `mentionText`; a reference chip
74
+ carries the `uri` the app built with that package, written back verbatim. The
75
+ urgency is not in the text: hand `urgentPubkeys(editor)` to `urgentTagsFor`.
76
+ - **The lists ask each time the query changes.** `people` and `items` are
77
+ functions, so they read the app's current data. `@` shows the first six
78
+ names holding the query; `[` the last six whose label or `search` (default:
79
+ `snippet`) holds it.
80
+ - **An app's own node name:** `CaptionedReference.extend({ name: 'yourName' })`
81
+ and `ReferenceTrigger.configure({ nodeName: 'yourName' })`. The chip's HTML
82
+ attribute follows the name (`data-your-name`).
83
+ - **A row that opens another list** runs `typeTrigger('@')`: it types the key
84
+ once the `/query` is gone.
85
+ - An open list is reported by `isSuggestionActive()`, so an editor that sends
86
+ on Enter does not send on the Enter that picked a row.
87
+
88
+ <Canvas of={ComposerTriggersStories.CodeAsABlock} />
89
+
90
+ ## What it owns
91
+
92
+ Nothing. It only draws. Clicks and keys are yours.
93
+
94
+ ## Keys
95
+
96
+ The lists are `SuggestionMenu`s, and their keys are its own. What the body
97
+ says, the tags it earns and sending stay the app's.
98
+
99
+
100
+ | Key | Does |
101
+ | --- | --- |
102
+ | `@` after a space or at the line's start | Opens the people list |
103
+ | `!@` | Opens the urgent list |
104
+ | `[` | Opens the reference list |
105
+ | `/` after a space or at the line's start | Opens Format and the app's sections |
106
+ | ↑ ↓ | Move the highlight, wrapping at the ends |
107
+ | Enter | Inserts the highlighted row, or runs it |
108
+ | Escape | Closes the list |
@@ -0,0 +1,79 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { EditorContent, useEditor } from '@tiptap/react'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import { richTextClassName } from './RichText'
6
+ import {
7
+ CaptionedReference,
8
+ PersonMention,
9
+ ReferenceTrigger,
10
+ SlashCommands,
11
+ UrgentPersonMention,
12
+ formatSection,
13
+ typeTrigger,
14
+ type CaptionedItem,
15
+ type MentionPerson,
16
+ type SlashSection,
17
+ } from './ComposerTriggers'
18
+
19
+ /**
20
+ * The four composer triggers: `@` and `!@` offer a person, `[` offers
21
+ * something to reference, and `/` formats the line or inserts something.
22
+ * Type one of them in the box.
23
+ */
24
+ const meta = {
25
+ title: 'Primitives/ComposerTriggers',
26
+ parameters: { layout: 'padded' },
27
+ } satisfies Meta
28
+
29
+ export default meta
30
+ type Story = StoryObj
31
+
32
+ const PEOPLE: MentionPerson[] = ['one', 'two', 'three', 'four', 'five', 'six', 'seven'].map((n, i) => ({
33
+ id: `person-${n}`,
34
+ name: `Person ${n}`,
35
+ description: i % 2 === 0 ? 'Label' : undefined,
36
+ pubkey: null,
37
+ }))
38
+
39
+ const ITEMS: CaptionedItem[] = ['one', 'two', 'three'].map((n) => ({
40
+ id: `item-${n}`,
41
+ label: 'Person one',
42
+ snippet: `Item ${n}`,
43
+ uri: `nostr:item-${n}`,
44
+ }))
45
+
46
+ const INSERT: SlashSection = {
47
+ label: 'Insert',
48
+ commands: [
49
+ { key: 'insert-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@'], run: typeTrigger('@') },
50
+ { key: 'insert-[', row: { label: 'Reference', shortcut: '[' }, keywords: ['['], run: typeTrigger('[') },
51
+ ],
52
+ }
53
+
54
+ function Composer({ code }: { code: 'inline' | 'block' }) {
55
+ const editor = useEditor({
56
+ extensions: [
57
+ StarterKit,
58
+ PersonMention.configure({ people: () => PEOPLE }),
59
+ UrgentPersonMention.configure({ people: () => PEOPLE }),
60
+ CaptionedReference.configure({ icon: <IconSquareRounded size={14} stroke={1.5} /> }),
61
+ ReferenceTrigger.configure({
62
+ items: () => ITEMS,
63
+ ariaLabel: 'Items',
64
+ sectionLabel: 'Items',
65
+ icon: <IconSquareRounded size={16} stroke={1.5} className="text-text-secondary" />,
66
+ }),
67
+ SlashCommands.configure({ sections: [formatSection(code), INSERT] }),
68
+ ],
69
+ content: '<p>Type @, !@, [ or / after a space: </p>',
70
+ editorProps: { attributes: { class: richTextClassName('default', 'min-h-[80px] w-[480px] rounded-md border border-border-default p-3 outline-none') } },
71
+ })
72
+ return <EditorContent editor={editor} />
73
+ }
74
+
75
+ /** Code as a mark inside the line. */
76
+ export const Default: Story = { render: () => <Composer code="inline" /> }
77
+
78
+ /** Code as a block of its own, for an editor of a block document. */
79
+ export const CodeAsABlock: Story = { render: () => <Composer code="block" /> }
@@ -0,0 +1,207 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The composer triggers (CON-27, moved from Peek's `mention.tsx` and
4
+ * `slashCommands.tsx`): each opens its list at the caret, and a pick inserts
5
+ * the chip the body is written from.
6
+ */
7
+ import { act } from 'react'
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
9
+ import { render, screen, cleanup, within } from '@testing-library/react'
10
+ import type { Editor } from '@tiptap/core'
11
+ import { EditorContent, useEditor } from '@tiptap/react'
12
+ import StarterKit from '@tiptap/starter-kit'
13
+ import {
14
+ CaptionedReference,
15
+ PersonMention,
16
+ ReferenceTrigger,
17
+ SlashCommands,
18
+ UrgentPersonMention,
19
+ filterPeople,
20
+ filterReferences,
21
+ filterSlashSections,
22
+ formatSection,
23
+ typeTrigger,
24
+ urgentPubkeys,
25
+ type CaptionedItem,
26
+ type MentionPerson,
27
+ type SlashSection,
28
+ } from './ComposerTriggers'
29
+
30
+ const KEY = 'b'.repeat(64)
31
+ const PEOPLE: MentionPerson[] = [
32
+ { id: 'ada', name: 'Ada Lovelace', description: 'Engineering', pubkey: KEY },
33
+ { id: 'bea', name: 'Bea', pubkey: null },
34
+ ...['Cy', 'Di', 'Ed', 'Flo', 'Gus'].map((name) => ({ id: name.toLowerCase(), name })),
35
+ ]
36
+ const ITEMS: CaptionedItem[] = Array.from({ length: 8 }, (_, i) => ({
37
+ id: `${i}`.repeat(64),
38
+ label: i === 7 ? 'Bea' : 'Ada',
39
+ snippet: `line ${i}`,
40
+ uri: `nostr:nevent1example${i}`,
41
+ search: `**line** ${i}`,
42
+ }))
43
+ const APP_SECTION: SlashSection = {
44
+ label: 'Shortcuts',
45
+ commands: [{ key: 'shortcut-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@', 'Mention a person'], run: typeTrigger('@') }],
46
+ }
47
+
48
+ // An app's own name for the chip, as Peek's `messageMention`.
49
+ const Chip = CaptionedReference.extend({ name: 'messageMention' })
50
+
51
+ let editor: Editor | null = null
52
+
53
+ function Composer() {
54
+ const made = useEditor({
55
+ extensions: [
56
+ StarterKit,
57
+ PersonMention.configure({ people: () => PEOPLE }),
58
+ UrgentPersonMention.configure({ people: () => PEOPLE }),
59
+ Chip,
60
+ ReferenceTrigger.configure({ items: () => ITEMS, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' }),
61
+ SlashCommands.configure({ sections: [formatSection(), APP_SECTION] }),
62
+ ],
63
+ })
64
+ editor = made
65
+ return <EditorContent editor={made} />
66
+ }
67
+
68
+ async function type(text: string) {
69
+ await act(async () => {
70
+ editor!.chain().focus().insertContent(text).run()
71
+ })
72
+ }
73
+
74
+ async function press(key: string) {
75
+ await act(async () => {
76
+ editor!.view.someProp('handleKeyDown', (handle) => handle(editor!.view, new KeyboardEvent('keydown', { key })))
77
+ })
78
+ }
79
+
80
+ // The highlighted row also draws its Enter hint; the label is what is compared.
81
+ const options = () => within(screen.getByRole('listbox')).getAllByRole('option').map((one) => one.textContent?.replace('↩ Enter', ''))
82
+ const nodes = () => {
83
+ const found: { type: string; attrs: Record<string, unknown> }[] = []
84
+ editor!.state.doc.descendants((node) => {
85
+ if (node.isInline && !node.isText) found.push({ type: node.type.name, attrs: node.attrs })
86
+ })
87
+ return found
88
+ }
89
+
90
+ // jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it.
91
+ beforeAll(() => {
92
+ Element.prototype.scrollIntoView = vi.fn()
93
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
94
+ })
95
+
96
+ afterEach(() => {
97
+ editor?.destroy()
98
+ editor = null
99
+ cleanup()
100
+ })
101
+
102
+ describe('@ and !@', () => {
103
+ it('lists the first six people, narrowed by name, and a pick inserts a chip with their key', async () => {
104
+ render(<Composer />)
105
+ await type('@')
106
+ expect(screen.getByRole('listbox', { name: 'People' })).toBeTruthy()
107
+ expect(options()).toHaveLength(6)
108
+ await type('ada')
109
+ expect(options()).toHaveLength(1)
110
+ await press('Enter')
111
+ expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }])
112
+ expect(editor!.getHTML()).toContain('data-mention="true"')
113
+ expect(screen.queryByRole('listbox')).toBeNull()
114
+ })
115
+
116
+ it('opens the urgent list on !@, and the document reports whose key it names', async () => {
117
+ render(<Composer />)
118
+ await type('!@')
119
+ expect(screen.getByRole('listbox', { name: 'Urgent mention' })).toBeTruthy()
120
+ await press('Enter')
121
+ expect(nodes()[0]).toEqual({ type: 'urgentMention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } })
122
+ expect(urgentPubkeys(editor!)).toEqual([KEY])
123
+ })
124
+
125
+ it('keeps a person with no key, who has nothing to carry urgently', () => {
126
+ expect(filterPeople(PEOPLE, 'BE')).toEqual([PEOPLE[1]])
127
+ expect(filterPeople(PEOPLE, '')).toHaveLength(6)
128
+ })
129
+ })
130
+
131
+ describe('[', () => {
132
+ it('lists the last six references and inserts the app-named chip with its uri', async () => {
133
+ render(<Composer />)
134
+ await type('[')
135
+ expect(screen.getByRole('listbox', { name: 'Things' })).toBeTruthy()
136
+ expect(options()).toEqual(['Ada: line 2', 'Ada: line 3', 'Ada: line 4', 'Ada: line 5', 'Ada: line 6', 'Bea: line 7'])
137
+ await press('Enter')
138
+ expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[2].id, label: 'Ada', snippet: 'line 2', uri: ITEMS[2].uri } }])
139
+ expect(editor!.getHTML()).toContain('data-message-mention="true"')
140
+ })
141
+
142
+ it('matches who said it, or the text it searches', () => {
143
+ expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id])
144
+ expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id])
145
+ })
146
+ })
147
+
148
+ describe('a chip through HTML (copy and paste)', () => {
149
+ it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => {
150
+ render(<Composer />)
151
+ await act(async () => {
152
+ editor!.commands.setContent([
153
+ {
154
+ type: 'paragraph',
155
+ content: [
156
+ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
157
+ { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
158
+ ],
159
+ },
160
+ ])
161
+ })
162
+ const html = editor!.getHTML()
163
+ await act(async () => {
164
+ editor!.commands.setContent(html)
165
+ })
166
+ expect(nodes()).toEqual([
167
+ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } },
168
+ { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } },
169
+ ])
170
+ await act(async () => {
171
+ editor!.commands.setContent(
172
+ '<p><span data-urgent-mention="true" data-id="x" data-label="Ada" data-pubkey="not-a-key">@Ada</span>' +
173
+ '<span data-message-mention="true" data-id="y" data-label="Ada" data-uri="javascript:alert(1)">Ada</span></p>',
174
+ )
175
+ })
176
+ const [person, reference] = nodes()
177
+ expect([person.type, person.attrs.label, person.attrs.pubkey]).toEqual(['urgentMention', 'Ada', null])
178
+ expect([reference.type, reference.attrs.uri]).toEqual(['messageMention', null])
179
+ })
180
+ })
181
+
182
+ describe('/', () => {
183
+ it('opens Format first, then the app’s sections, and runs the chosen row', async () => {
184
+ render(<Composer />)
185
+ await type('/')
186
+ expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy()
187
+ expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code`', 'Bulleted list-', 'Numbered list1.', 'Mention@'])
188
+ await type('head')
189
+ await press('Enter')
190
+ expect(editor!.getJSON().content?.[0]).toEqual({ type: 'heading', attrs: { level: 1 } })
191
+ })
192
+
193
+ it('narrows on a label, an id or a keyword, and keeps an emptied section', () => {
194
+ const sections = [formatSection(), APP_SECTION]
195
+ const labels = (q: string) => filterSlashSections(sections, q).map((s) => s.commands.map((c) => c.row.label))
196
+ expect(labels('list')).toEqual([['Bulleted list', 'Numbered list'], []])
197
+ expect(labels('mention a person')).toEqual([[], ['Mention']])
198
+ expect(labels('HEAD')).toEqual([['Heading', 'Subheading'], []])
199
+ })
200
+
201
+ it('makes code a block when the app asks for one', async () => {
202
+ render(<Composer />)
203
+ const code = formatSection('block').commands.find((c) => c.key === 'format-code')!
204
+ await act(async () => code.run(editor!))
205
+ expect(editor!.getJSON().content?.[0].type).toBe('codeBlock')
206
+ })
207
+ })