@estiva-app/ui 0.44.1 → 0.46.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/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/InlineChip.d.ts +1 -1
- package/dist/NavItem.d.ts +11 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +9 -1
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/SelectionToolbar.d.ts +62 -0
- package/dist/SelectionToolbar.d.ts.map +1 -0
- package/dist/SuggestionMenu.d.ts +75 -0
- package/dist/SuggestionMenu.d.ts.map +1 -0
- package/dist/editor.d.ts +12 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +357 -0
- package/dist/editor.js.map +7 -0
- package/dist/eslint/index.js +4 -3
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +274 -133
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +3 -0
- package/dist/looks.d.ts.map +1 -1
- package/dist/registry/{app-YMABQII6.js → app-LDGOH6UW.js} +3 -3
- package/dist/registry/{build-3EANARLQ.js → build-P6X3PTZP.js} +6 -2
- package/dist/registry/build.d.ts +4 -0
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-SIB44CES.js → chunk-P55P2QFD.js} +9 -4
- package/dist/registry/chunk-P55P2QFD.js.map +7 -0
- package/dist/registry/{chunk-EVJW26UR.js → chunk-TQ6UQER3.js} +10 -10
- package/dist/registry/chunk-TQ6UQER3.js.map +7 -0
- package/dist/registry/cli.js +2 -2
- package/dist/registry/index.js +2 -2
- package/dist/shortcuts.d.ts +27 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/suggestionPopup.d.ts +41 -0
- package/dist/suggestionPopup.d.ts.map +1 -0
- package/package.json +27 -2
- package/registry.json +391 -11
- package/src/Chip.tsx +1 -1
- package/src/CollapsibleSection.mdx +5 -0
- package/src/CollapsibleSection.stories.tsx +5 -0
- package/src/CollapsibleSection.tsx +10 -3
- package/src/CommandPalette.tsx +1 -1
- package/src/InlineChip.test.tsx +1 -1
- package/src/InlineChip.tsx +1 -1
- package/src/NavItem.mdx +9 -0
- package/src/NavItem.stories.tsx +23 -0
- package/src/NavItem.tsx +56 -6
- package/src/Reaction.tsx +1 -1
- package/src/SectionHeader.mdx +9 -0
- package/src/SectionHeader.stories.tsx +15 -0
- package/src/SectionHeader.tsx +28 -7
- package/src/SelectionToolbar.mdx +78 -0
- package/src/SelectionToolbar.stories.tsx +63 -0
- package/src/SelectionToolbar.test.ts +105 -0
- package/src/SelectionToolbar.tsx +487 -0
- package/src/SuggestionMenu.mdx +107 -0
- package/src/SuggestionMenu.stories.tsx +202 -0
- package/src/SuggestionMenu.test.tsx +128 -0
- package/src/SuggestionMenu.tsx +189 -0
- package/src/editor.ts +11 -0
- package/src/eslint/no-rebuilt-behaviour.test.ts +1 -1
- package/src/eslint/no-rebuilt-behaviour.ts +2 -2
- package/src/eslint/no-restyled-part.ts +2 -1
- package/src/how.test.ts +1 -1
- package/src/index.ts +2 -0
- package/src/keptSelection.test.ts +67 -0
- package/src/looks.ts +4 -0
- package/src/registry/app.ts +8 -8
- package/src/registry/build.ts +10 -2
- package/src/registry/registry.test.ts +14 -9
- package/src/shortcuts.ts +40 -0
- package/src/suggestionPopup.tsx +119 -0
- package/stories/Choosing.mdx +2 -0
- package/tailwind-preset.js +1 -0
- package/tokens.css +19 -4
- package/dist/registry/chunk-EVJW26UR.js.map +0 -7
- package/dist/registry/chunk-SIB44CES.js.map +0 -7
- /package/dist/registry/{app-YMABQII6.js.map → app-LDGOH6UW.js.map} +0 -0
- /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
|
{
|
|
@@ -125,6 +125,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
125
125
|
Card: ['fill', 'hover', 'attention', 'selected', 'active', 'hovered', 'quietUntilHover', 'unreadable', 'clip'],
|
|
126
126
|
Checkbox: ['row'],
|
|
127
127
|
Chip: ['type'],
|
|
128
|
+
CollapsibleSection: ['look'],
|
|
128
129
|
Divider: ['tone', 'orientation'],
|
|
129
130
|
EmptyState: ['scope'],
|
|
130
131
|
FieldLine: ['tone'],
|
|
@@ -142,7 +143,7 @@ export const PART_LOOK_PROPS: Record<string, string[]> = {
|
|
|
142
143
|
Reaction: ['pressed'],
|
|
143
144
|
ReactionPicker: ['surface'],
|
|
144
145
|
RichText: ['size'],
|
|
145
|
-
SectionHeader: ['hover'],
|
|
146
|
+
SectionHeader: ['hover', 'look'],
|
|
146
147
|
SectionLabel: ['tone', 'truncate'],
|
|
147
148
|
Select: ['size'],
|
|
148
149
|
Tabs: ['size'],
|
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
|
+
})
|