@estiva-app/ui 0.54.0 → 0.56.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/BlockHandle.d.ts +47 -0
- package/dist/BlockHandle.d.ts.map +1 -0
- package/dist/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +7 -1
- package/dist/ContainerHeader.d.ts.map +1 -1
- package/dist/EditorBlockHandle.d.ts +57 -0
- package/dist/EditorBlockHandle.d.ts.map +1 -0
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/ListColumn.d.ts +2 -0
- package/dist/ListColumn.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +9 -1
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +22 -3
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/editor.d.ts +1 -0
- package/dist/editor.d.ts.map +1 -1
- package/dist/editor.js +127 -11
- package/dist/editor.js.map +4 -4
- package/dist/gates/index.js +1 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +831 -624
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +4 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +302 -23
- package/src/BlockHandle.mdx +58 -0
- package/src/BlockHandle.stories.tsx +44 -0
- package/src/BlockHandle.test.tsx +216 -0
- package/src/BlockHandle.tsx +220 -0
- package/src/CollapsibleSection.stories.tsx +14 -1
- package/src/CollapsibleSection.tsx +10 -4
- package/src/ContainerHeader.mdx +4 -0
- package/src/ContainerHeader.stories.tsx +14 -0
- package/src/ContainerHeader.test.tsx +8 -0
- package/src/ContainerHeader.tsx +34 -6
- package/src/EditorBlockHandle.mdx +65 -0
- package/src/EditorBlockHandle.stories.tsx +81 -0
- package/src/EditorBlockHandle.tsx +181 -0
- package/src/IconButton.tsx +2 -1
- package/src/ListColumn.tsx +4 -2
- package/src/Menu.tsx +5 -4
- package/src/NavItem.test.tsx +2 -2
- package/src/NavItem.tsx +21 -12
- package/src/NavTree.mdx +16 -1
- package/src/NavTree.stories.tsx +49 -2
- package/src/NavTree.test.tsx +7 -0
- package/src/NavTree.tsx +21 -4
- package/src/PersonTrigger.tsx +6 -5
- package/src/SectionHeader.mdx +6 -0
- package/src/SectionHeader.stories.tsx +18 -0
- package/src/SectionHeader.test.tsx +26 -0
- package/src/SectionHeader.tsx +99 -23
- package/src/SectionLabel.tsx +6 -3
- package/src/Sidebar.tsx +1 -1
- package/src/editor.ts +1 -0
- package/src/eslint/no-copied-look.test.ts +2 -2
- package/src/gates/app-checks.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +6 -0
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +2 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The block handle (RIC-18): what its rows do to the document, and which rows
|
|
4
|
+
* it offers. The drag itself is the browser's and is driven in real Chromium
|
|
5
|
+
* (the PR says how); here, the slice it starts with and what a drop of it keeps.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
|
|
8
|
+
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { Editor } from '@tiptap/core'
|
|
11
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
12
|
+
import { BlockHandle } from './BlockHandle'
|
|
13
|
+
import { BlockId } from './BlockNodes'
|
|
14
|
+
import { EditorBlockHandle, deleteBlock, duplicateBlock, moveBlock, startBlockDrag } from './EditorBlockHandle'
|
|
15
|
+
import { MenuItem } from './Menu'
|
|
16
|
+
|
|
17
|
+
const DOCUMENT = {
|
|
18
|
+
type: 'doc',
|
|
19
|
+
content: [
|
|
20
|
+
{ type: 'paragraph', attrs: { blockId: 'p1' }, content: [{ type: 'text', text: 'one' }] },
|
|
21
|
+
{ type: 'paragraph', attrs: { blockId: 'p2' }, content: [{ type: 'text', text: 'two' }] },
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Json = { type: string; attrs?: Record<string, unknown>; content?: Json[] }
|
|
26
|
+
const editors: Editor[] = []
|
|
27
|
+
function editor(content: object = DOCUMENT) {
|
|
28
|
+
const e = new Editor({ extensions: [StarterKit, BlockId], content })
|
|
29
|
+
editors.push(e)
|
|
30
|
+
return e
|
|
31
|
+
}
|
|
32
|
+
const ids = (e: Editor) => (e.getJSON().content as Json[]).map((b) => b.attrs?.blockId)
|
|
33
|
+
const posOf = (e: Editor, id: string) => {
|
|
34
|
+
let at = -1
|
|
35
|
+
e.state.doc.forEach((node, pos) => {
|
|
36
|
+
if (node.attrs.blockId === id) at = pos
|
|
37
|
+
})
|
|
38
|
+
return at
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Press and release on the handle. Base UI opens a menu on the press; under
|
|
43
|
+
* jsdom `user.click`'s own move-press-release-click left it shut, while the
|
|
44
|
+
* same handle opens on a click in Chromium (the story, driven in the PR).
|
|
45
|
+
*/
|
|
46
|
+
async function press(user: ReturnType<typeof userEvent.setup>, target: HTMLElement) {
|
|
47
|
+
await user.pointer({ keys: '[MouseLeft>]', target })
|
|
48
|
+
await user.pointer({ keys: '[/MouseLeft]', target })
|
|
49
|
+
await screen.findByRole('menu')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// jsdom lays nothing out, so a Range has no rects. The editor measures the caret
|
|
53
|
+
// a frame after Duplicate, Delete or a move gives it the focus back; under load
|
|
54
|
+
// that frame came while a test still ran (CI, 5 October), and threw.
|
|
55
|
+
beforeAll(() => {
|
|
56
|
+
const none = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }
|
|
57
|
+
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
|
|
58
|
+
Range.prototype.getBoundingClientRect = () => none as DOMRect
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
cleanup()
|
|
63
|
+
for (const e of editors.splice(0)) e.destroy()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('duplicateBlock', () => {
|
|
67
|
+
it('puts a copy after the block, and the copy gets an id of its own', () => {
|
|
68
|
+
const e = editor()
|
|
69
|
+
duplicateBlock(e, posOf(e, 'p1'))
|
|
70
|
+
const [first, copy, last] = ids(e)
|
|
71
|
+
expect(first).toBe('p1')
|
|
72
|
+
expect(copy).toMatch(/^[0-9a-f]{12}$/)
|
|
73
|
+
expect(last).toBe('p2')
|
|
74
|
+
expect(e.getText()).toBe('one\n\none\n\ntwo')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('then deleting the copy gives back the document it started from', () => {
|
|
78
|
+
const e = editor()
|
|
79
|
+
const before = e.getJSON()
|
|
80
|
+
duplicateBlock(e, posOf(e, 'p2'))
|
|
81
|
+
deleteBlock(e, posOf(e, ids(e)[2] as string))
|
|
82
|
+
expect(e.getJSON()).toEqual(before)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('deleteBlock', () => {
|
|
87
|
+
it('removes the block and leaves the others their ids', () => {
|
|
88
|
+
const e = editor()
|
|
89
|
+
deleteBlock(e, posOf(e, 'p1'))
|
|
90
|
+
expect(ids(e)).toEqual(['p2'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('leaves an empty paragraph when it was the only block', () => {
|
|
94
|
+
const e = editor({ type: 'doc', content: [DOCUMENT.content[0]] })
|
|
95
|
+
deleteBlock(e, 0)
|
|
96
|
+
expect(e.getText()).toBe('')
|
|
97
|
+
expect(e.state.doc.childCount).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('moveBlock', () => {
|
|
102
|
+
it('moves the block whole, so it keeps its id, as a drop in the gutter does', () => {
|
|
103
|
+
const e = editor()
|
|
104
|
+
moveBlock(e, posOf(e, 'p1'), e.state.doc.content.size)
|
|
105
|
+
expect(ids(e)).toEqual(['p2', 'p1'])
|
|
106
|
+
expect(e.getText()).toBe('two\n\none')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does nothing when the block would land where it is', () => {
|
|
110
|
+
const e = editor()
|
|
111
|
+
const before = e.getJSON()
|
|
112
|
+
expect(moveBlock(e, posOf(e, 'p2'), posOf(e, 'p2'))).toBe(false)
|
|
113
|
+
expect(e.getJSON()).toEqual(before)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('startBlockDrag', () => {
|
|
118
|
+
it('selects the block and drags its slice, with its id, as a move', () => {
|
|
119
|
+
const e = editor()
|
|
120
|
+
const transfer = { data: {} as Record<string, string>, clearData() {}, setData(type: string, value: string) { this.data[type] = value }, effectAllowed: '' }
|
|
121
|
+
startBlockDrag(e, posOf(e, 'p2'), { dataTransfer: transfer as unknown as DataTransfer })
|
|
122
|
+
expect(e.view.dragging?.move).toBe(true)
|
|
123
|
+
expect(e.view.dragging?.slice.content.firstChild?.attrs.blockId).toBe('p2')
|
|
124
|
+
expect(e.state.selection.from).toBe(posOf(e, 'p2'))
|
|
125
|
+
expect(transfer.data['text/plain']).toBe('two')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('a drop of that slice, as ProseMirror makes it, keeps the id', () => {
|
|
129
|
+
const e = editor()
|
|
130
|
+
startBlockDrag(e, posOf(e, 'p1'), { dataTransfer: null })
|
|
131
|
+
// ProseMirror's drop: delete the selection, then insert the slice's node at the drop point.
|
|
132
|
+
const node = e.view.dragging!.slice.content.firstChild!
|
|
133
|
+
const tr = e.state.tr.deleteSelection()
|
|
134
|
+
tr.insert(tr.doc.content.size, node)
|
|
135
|
+
e.view.dispatch(tr)
|
|
136
|
+
expect(ids(e)).toEqual(['p2', 'p1'])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('BlockHandle', () => {
|
|
141
|
+
it('reading, offers only the caller’s rows and does not drag', async () => {
|
|
142
|
+
render(
|
|
143
|
+
<BlockHandle actions={(b) => <MenuItem label={`Copy link ${b.id}`} onClick={() => {}} />}>
|
|
144
|
+
<div>
|
|
145
|
+
<p data-block-id="r1">read me</p>
|
|
146
|
+
</div>
|
|
147
|
+
</BlockHandle>,
|
|
148
|
+
)
|
|
149
|
+
const user = userEvent.setup()
|
|
150
|
+
await user.hover(screen.getByText('read me'))
|
|
151
|
+
const handle = screen.getByRole('button', { name: 'Block menu' })
|
|
152
|
+
expect(handle.getAttribute('draggable')).toBe('false')
|
|
153
|
+
await press(user, handle)
|
|
154
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Copy link r1'])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('reading with no rows of the caller’s, draws no handle', async () => {
|
|
158
|
+
render(
|
|
159
|
+
<BlockHandle>
|
|
160
|
+
<div>
|
|
161
|
+
<p data-block-id="r1">read me</p>
|
|
162
|
+
</div>
|
|
163
|
+
</BlockHandle>,
|
|
164
|
+
)
|
|
165
|
+
await userEvent.setup().hover(screen.getByText('read me'))
|
|
166
|
+
expect(screen.queryByRole('button', { name: 'Block menu' })).toBeNull()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('editing, Delete from the menu removes the block', async () => {
|
|
170
|
+
const e = editor()
|
|
171
|
+
const { EditorContent } = await import('@tiptap/react')
|
|
172
|
+
render(
|
|
173
|
+
<EditorBlockHandle editor={e}>
|
|
174
|
+
<EditorContent editor={e} />
|
|
175
|
+
</EditorBlockHandle>,
|
|
176
|
+
)
|
|
177
|
+
const user = userEvent.setup()
|
|
178
|
+
// jsdom lays nothing out: every block's top is 0, so the pointer is on the last.
|
|
179
|
+
await act(async () => user.hover(screen.getByText('two')))
|
|
180
|
+
await press(user, screen.getByRole('button', { name: 'Block menu' }))
|
|
181
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Duplicate', 'Delete'])
|
|
182
|
+
await user.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
|
183
|
+
// The editor takes the focus back a frame later and measures the caret then: wait for it, so it is part of the test.
|
|
184
|
+
await act(() => new Promise<void>((done) => requestAnimationFrame(() => done())))
|
|
185
|
+
expect(ids(e)).toEqual(['p1'])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('an editor that cannot be edited gets the caller’s rows only, and no drag', async () => {
|
|
189
|
+
const e = editor()
|
|
190
|
+
e.setEditable(false)
|
|
191
|
+
const { EditorContent } = await import('@tiptap/react')
|
|
192
|
+
render(
|
|
193
|
+
<EditorBlockHandle editor={e} actions={(b) => <MenuItem label={`Copy link ${b.id}`} onClick={() => {}} />}>
|
|
194
|
+
<EditorContent editor={e} />
|
|
195
|
+
</EditorBlockHandle>,
|
|
196
|
+
)
|
|
197
|
+
const user = userEvent.setup()
|
|
198
|
+
await act(async () => user.hover(screen.getByText('two')))
|
|
199
|
+
const handle = screen.getByRole('button', { name: 'Block menu' })
|
|
200
|
+
expect(handle.getAttribute('draggable')).toBe('false')
|
|
201
|
+
await press(user, handle)
|
|
202
|
+
expect(screen.getAllByRole('menuitem').map((r) => r.textContent)).toEqual(['Copy link p2'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('draws no handle before its editor exists', async () => {
|
|
206
|
+
render(
|
|
207
|
+
<EditorBlockHandle editor={null} actions={() => <MenuItem label="Copy link" onClick={() => {}} />}>
|
|
208
|
+
<div>
|
|
209
|
+
<p data-block-id="r1">not yet</p>
|
|
210
|
+
</div>
|
|
211
|
+
</EditorBlockHandle>,
|
|
212
|
+
)
|
|
213
|
+
await userEvent.setup().hover(screen.getByText('not yet'))
|
|
214
|
+
expect(screen.queryByRole('button', { name: 'Block menu' })).toBeNull()
|
|
215
|
+
})
|
|
216
|
+
})
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
|
|
2
|
+
import type { BaseUIEvent } from '@base-ui/react/types'
|
|
3
|
+
import { IconGripVertical } from '@tabler/icons-react'
|
|
4
|
+
import { cn } from './cn'
|
|
5
|
+
import { IconButton } from './IconButton'
|
|
6
|
+
import { Menu, MenuSeparator } from './Menu'
|
|
7
|
+
|
|
8
|
+
/** The block the pointer is on: its §13.3 id, its element, and — in an editor — where it starts. */
|
|
9
|
+
export interface HandleBlock {
|
|
10
|
+
/** `undefined` only for a block that has none yet, which nothing can address. */
|
|
11
|
+
id: string | undefined
|
|
12
|
+
element: HTMLElement
|
|
13
|
+
/** Position before the block in the editor's document; absent when reading. */
|
|
14
|
+
pos?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What `EditorBlockHandle` hands this part to make it edit. A reading surface leaves it off. */
|
|
18
|
+
export interface BlockHandleEditing {
|
|
19
|
+
/** The block level with `y`, in the editor's own document. */
|
|
20
|
+
find: (y: number) => HandleBlock | null
|
|
21
|
+
/** The editing rows, before the caller's. */
|
|
22
|
+
rows: (block: HandleBlock) => ReactNode
|
|
23
|
+
/** Starts the browser's drag of the block; the handle is draggable only with this. */
|
|
24
|
+
startDrag: (block: HandleBlock, event: globalThis.DragEvent) => void
|
|
25
|
+
/** Drops the dragged block before `target`, or after it: a drop in the gutter, where the editor has none. */
|
|
26
|
+
drop: (target: HandleBlock, after: boolean) => void
|
|
27
|
+
endDrag: () => void
|
|
28
|
+
/** Calls back when the document changes, so a block found before is found again. Returns the unsubscribe. */
|
|
29
|
+
onChange: (changed: () => void) => () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BlockHandleProps {
|
|
33
|
+
/** The caller's own rows — a link to the block, a comment on it. Reading, they are the whole menu; with none, no handle is drawn. */
|
|
34
|
+
actions?: (block: HandleBlock) => ReactNode
|
|
35
|
+
/** Given by `EditorBlockHandle` (from `@estiva-app/ui/editor`). Leave it off to read. */
|
|
36
|
+
editing?: BlockHandleEditing
|
|
37
|
+
/** What the blocks are drawn in. Reading: a surface whose top-level elements carry `data-block-id`, as `RichText` draws. */
|
|
38
|
+
children: ReactNode
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A block's id as a reading surface writes it: on the element, or on the first inside it (a table's wrapper). */
|
|
43
|
+
function readId(element: HTMLElement): string | undefined {
|
|
44
|
+
const own = element.getAttribute('data-block-id') ?? element.querySelector('[data-block-id]')?.getAttribute('data-block-id')
|
|
45
|
+
return own || undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The last of `blocks` whose top is at or above `y` — the gap between two blocks belongs to the one before. */
|
|
49
|
+
export function blockAtY(blocks: readonly HandleBlock[], y: number): HandleBlock | null {
|
|
50
|
+
let found: HandleBlock | null = null
|
|
51
|
+
for (const block of blocks) {
|
|
52
|
+
if (block.element.getBoundingClientRect().top > y) break
|
|
53
|
+
found = block
|
|
54
|
+
}
|
|
55
|
+
return found ?? blocks[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The top-level blocks of a reading surface: its root's children that carry an id. */
|
|
59
|
+
function readingBlocks(root: Element | null): HandleBlock[] {
|
|
60
|
+
if (!root) return []
|
|
61
|
+
return Array.from(root.children)
|
|
62
|
+
.filter((el): el is HTMLElement => el instanceof HTMLElement)
|
|
63
|
+
.map((element) => ({ id: readId(element), element }))
|
|
64
|
+
.filter((b) => b.id !== undefined)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The handle's 24px, against the block's first line. */
|
|
68
|
+
const HANDLE = 24
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How far down the block the handle starts, so it is centred on the first
|
|
72
|
+
* line: a heading's line is taller than a paragraph's. A table's or a list's
|
|
73
|
+
* first line is its first descendant's.
|
|
74
|
+
*/
|
|
75
|
+
function firstLineOffset(element: HTMLElement): number {
|
|
76
|
+
const text = element.querySelector<HTMLElement>('p, h1, h2, h3, pre, li') ?? element
|
|
77
|
+
const line = parseFloat(getComputedStyle(text).lineHeight)
|
|
78
|
+
if (!Number.isFinite(line)) return 0
|
|
79
|
+
const inset = text === element ? 0 : text.getBoundingClientRect().top - element.getBoundingClientRect().top
|
|
80
|
+
return inset + (line - HANDLE) / 2
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The handle at the left of the block under the pointer, and the block's menu
|
|
85
|
+
* behind it. Reading, the menu is the caller's rows only, so someone who
|
|
86
|
+
* cannot edit can still point at one block — copy a link to it, comment on
|
|
87
|
+
* it. In an editor, `EditorBlockHandle` adds the drag, Turn into, Duplicate
|
|
88
|
+
* and Delete.
|
|
89
|
+
*
|
|
90
|
+
* It sits in the gutter left of the content, 28px out, so wrapping the content
|
|
91
|
+
* moves nothing. The gutter counts as the block's, as in Notion: the handle
|
|
92
|
+
* stays while the pointer crosses to it.
|
|
93
|
+
*/
|
|
94
|
+
export function BlockHandle({ actions, editing, children, className }: BlockHandleProps) {
|
|
95
|
+
const frame = useRef<HTMLDivElement>(null)
|
|
96
|
+
const content = useRef<HTMLDivElement>(null)
|
|
97
|
+
const [block, setBlock] = useState<HandleBlock | null>(null)
|
|
98
|
+
const [top, setTop] = useState(0)
|
|
99
|
+
const [open, setOpen] = useState(false)
|
|
100
|
+
|
|
101
|
+
const follow = useCallback(
|
|
102
|
+
(y: number) => {
|
|
103
|
+
if (!content.current || !frame.current) return
|
|
104
|
+
const found = editing ? editing.find(y) : blockAtY(readingBlocks(content.current.firstElementChild), y)
|
|
105
|
+
setBlock(found)
|
|
106
|
+
if (found) setTop(found.element.getBoundingClientRect().top - frame.current.getBoundingClientRect().top + firstLineOffset(found.element))
|
|
107
|
+
},
|
|
108
|
+
[editing],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// A block moved or deleted under the handle would leave its rows acting on a stale position.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!editing) return
|
|
114
|
+
return editing.onChange(() => {
|
|
115
|
+
if (!open) setBlock(null)
|
|
116
|
+
})
|
|
117
|
+
}, [editing, open])
|
|
118
|
+
|
|
119
|
+
const rows = editing && block ? editing.rows(block) : null
|
|
120
|
+
const own = actions && block ? actions(block) : null
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
A block dragged down the gutter lands by height, as in Notion: the top half
|
|
124
|
+
of a block puts it before, the bottom half after, and the line shows where.
|
|
125
|
+
Over the text, the editor draws its own line and makes its own drop.
|
|
126
|
+
*/
|
|
127
|
+
const dragging = useRef(false)
|
|
128
|
+
const [line, setLine] = useState<number | null>(null)
|
|
129
|
+
const inGutter = (e: DragEvent<HTMLDivElement>) => dragging.current && !!editing && !!content.current && !content.current.contains(e.target as Node)
|
|
130
|
+
const landing = (y: number) => {
|
|
131
|
+
const target = editing?.find(y)
|
|
132
|
+
if (!target || !frame.current) return null
|
|
133
|
+
const box = target.element.getBoundingClientRect()
|
|
134
|
+
const after = y > box.top + box.height / 2
|
|
135
|
+
// Midway across the gap to the neighbour, where the editor draws its own line.
|
|
136
|
+
const neighbour = (after ? target.element.nextElementSibling : target.element.previousElementSibling)?.getBoundingClientRect()
|
|
137
|
+
const edge = after ? (neighbour ? (box.bottom + neighbour.top) / 2 : box.bottom) : neighbour ? (neighbour.bottom + box.top) / 2 : box.top
|
|
138
|
+
return { target, after, top: edge - frame.current.getBoundingClientRect().top }
|
|
139
|
+
}
|
|
140
|
+
const endDrag = () => {
|
|
141
|
+
dragging.current = false
|
|
142
|
+
setLine(null)
|
|
143
|
+
editing?.endDrag()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
ref={frame}
|
|
149
|
+
className={cn('relative', className)}
|
|
150
|
+
onMouseMove={(e) => {
|
|
151
|
+
if (!open) follow(e.clientY)
|
|
152
|
+
}}
|
|
153
|
+
onMouseLeave={() => {
|
|
154
|
+
if (!open) setBlock(null)
|
|
155
|
+
}}
|
|
156
|
+
onDragOver={(e) => {
|
|
157
|
+
if (!inGutter(e)) return setLine(null)
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
setLine(landing(e.clientY)?.top ?? null)
|
|
160
|
+
}}
|
|
161
|
+
onDrop={(e) => {
|
|
162
|
+
if (inGutter(e)) {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
const at = landing(e.clientY)
|
|
165
|
+
if (at) editing?.drop(at.target, at.after)
|
|
166
|
+
}
|
|
167
|
+
// The drop can redraw the document and take the handle with it, before its dragend.
|
|
168
|
+
endDrag()
|
|
169
|
+
}}
|
|
170
|
+
onDragLeave={(e) => {
|
|
171
|
+
if (!frame.current?.contains(e.relatedTarget as Node)) setLine(null)
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<div ref={content}>{children}</div>
|
|
175
|
+
{/* The gutter is the frame's own, so the pointer on its way from the text to the handle never leaves it. */}
|
|
176
|
+
<div aria-hidden className="absolute inset-y-0 -left-7 w-7" />
|
|
177
|
+
{line !== null && <div aria-hidden className="pointer-events-none absolute inset-x-0 h-px bg-accent-primary" style={{ top: line }} />}
|
|
178
|
+
{block && (rows || own) && (
|
|
179
|
+
// The top is the block's, measured: it depends on the document, not on a size of ours.
|
|
180
|
+
<div className={cn('absolute -left-7', rows && 'cursor-grab active:cursor-grabbing')} style={{ top }}>
|
|
181
|
+
<Menu
|
|
182
|
+
open={open}
|
|
183
|
+
onOpenChange={setOpen}
|
|
184
|
+
trigger={
|
|
185
|
+
<IconButton
|
|
186
|
+
aria-label="Block menu"
|
|
187
|
+
tooltip={rows ? 'Drag to move, click for the menu' : 'Menu'}
|
|
188
|
+
draggable={!!rows}
|
|
189
|
+
// Base UI opens a menu on the press, so a drag flashed it open.
|
|
190
|
+
// The handle's menu opens on the click instead, as Notion's does:
|
|
191
|
+
// a press that becomes a drag never clicks.
|
|
192
|
+
onMouseDown={(e: BaseUIEvent<MouseEvent<HTMLButtonElement>>) => e.preventBaseUIHandler()}
|
|
193
|
+
onClick={(e: BaseUIEvent<MouseEvent<HTMLButtonElement>>) => {
|
|
194
|
+
e.preventBaseUIHandler()
|
|
195
|
+
setOpen((was) => !was)
|
|
196
|
+
}}
|
|
197
|
+
onDragStart={(e: DragEvent<HTMLButtonElement>) => {
|
|
198
|
+
if (!editing || !rows) return
|
|
199
|
+
setOpen(false)
|
|
200
|
+
dragging.current = true
|
|
201
|
+
editing.startDrag(block, e.nativeEvent)
|
|
202
|
+
}}
|
|
203
|
+
onDragEnd={() => {
|
|
204
|
+
endDrag()
|
|
205
|
+
setBlock(null)
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<IconGripVertical size={16} stroke={1.5} />
|
|
209
|
+
</IconButton>
|
|
210
|
+
}
|
|
211
|
+
>
|
|
212
|
+
{rows}
|
|
213
|
+
{rows && own && <MenuSeparator />}
|
|
214
|
+
{own}
|
|
215
|
+
</Menu>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
|
2
2
|
import { IconPlus } from '@tabler/icons-react'
|
|
3
3
|
import { Chip } from './Chip'
|
|
4
4
|
import { CollapsibleSection } from './CollapsibleSection'
|
|
5
|
+
import { MenuItem } from './Menu'
|
|
5
6
|
import { NavItem } from './NavItem'
|
|
6
7
|
|
|
7
8
|
/** A section that opens and closes: the header's title is the toggle, and the rows slide. */
|
|
@@ -9,7 +10,7 @@ const meta = {
|
|
|
9
10
|
title: 'Navigation/CollapsibleSection',
|
|
10
11
|
component: CollapsibleSection,
|
|
11
12
|
decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
|
|
12
|
-
args: { title: 'Section', contentClassName: 'gap-
|
|
13
|
+
args: { title: 'Section', contentClassName: 'gap-0.5', children: null },
|
|
13
14
|
argTypes: { children: { control: false }, actions: { control: false } },
|
|
14
15
|
render: (args) => (
|
|
15
16
|
<CollapsibleSection {...args}>
|
|
@@ -48,6 +49,18 @@ export const AsARow: Story = {
|
|
|
48
49
|
args: { title: 'Folder', look: 'row' },
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/** `menu`: the section's "More options" ⋮, after its actions — a folder's Rename, Archive. Hover the title. */
|
|
53
|
+
export const WithMenu: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
menu: (
|
|
56
|
+
<>
|
|
57
|
+
<MenuItem label="Rename" />
|
|
58
|
+
<MenuItem label="Delete" destructive />
|
|
59
|
+
</>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/** `indent`: the heading look, its rows starting under its title, past the chevron — a folder in a sidebar. */
|
|
52
65
|
export const Indented: Story = {
|
|
53
66
|
args: { title: 'Group', indent: true },
|
|
@@ -32,10 +32,16 @@ export interface CollapsibleSectionProps {
|
|
|
32
32
|
onOpenChange?: (open: boolean) => void
|
|
33
33
|
/** Remember open or closed in this browser, under this key. The app prefixes it. */
|
|
34
34
|
storageKey?: string
|
|
35
|
+
/** 16px, before the title; the arrow takes its place on hover — `SectionHeader`'s. */
|
|
36
|
+
icon?: ReactNode
|
|
35
37
|
/** Beside the title and always visible, before the actions — a count. `SectionHeader`'s. */
|
|
36
38
|
trailing?: ReactNode
|
|
37
39
|
/** Beside the title, revealed on hover or focus — `SectionHeader`'s. */
|
|
38
40
|
actions?: SectionAction[]
|
|
41
|
+
/** Beside the actions, revealed with them: the section's "More options" menu, `MenuItem`s — `SectionHeader`'s. */
|
|
42
|
+
menu?: ReactNode
|
|
43
|
+
/** The ⋮'s accessible name — `SectionHeader`'s. */
|
|
44
|
+
menuLabel?: string
|
|
39
45
|
showActions?: 'hover' | 'always'
|
|
40
46
|
/**
|
|
41
47
|
* `heading` (default): the title is a section heading. `row`: the fold is a
|
|
@@ -79,7 +85,7 @@ function writeStored(key: string | undefined, open: boolean) {
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, trailing, actions, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
|
|
88
|
+
export function CollapsibleSection({ title, defaultOpen = true, open: openProp, onOpenChange, storageKey, icon, trailing, actions, menu, menuLabel, showActions, look = 'heading', indent = false, children, className, contentClassName }: CollapsibleSectionProps) {
|
|
83
89
|
const [openState, setOpenState] = useState(() => readStored(storageKey) ?? defaultOpen)
|
|
84
90
|
const open = openProp ?? openState
|
|
85
91
|
const setOpen = (next: boolean) => {
|
|
@@ -89,7 +95,7 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
|
|
|
89
95
|
}
|
|
90
96
|
return (
|
|
91
97
|
<Collapsible.Root open={open} onOpenChange={setOpen} className={cn('flex flex-col', className)}>
|
|
92
|
-
<SectionHeader title={title} chevron isExpanded={open} trailing={trailing} actions={actions} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger />} />
|
|
98
|
+
<SectionHeader title={title} chevron icon={icon} isExpanded={open} trailing={trailing} actions={actions} menu={menu} menuLabel={menuLabel} showActions={showActions} look={look} className="shrink-0" render={<Collapsible.Trigger />} />
|
|
93
99
|
{/* The slide: Base UI measures the panel and writes its height to a
|
|
94
100
|
variable — `auto` again once the slide ends, so rows that arrive
|
|
95
101
|
later are not clipped — and the panel is 0 high on its opening frame
|
|
@@ -101,8 +107,8 @@ export function CollapsibleSection({ title, defaultOpen = true, open: openProp,
|
|
|
101
107
|
className={COLLAPSIBLE_PANEL_CLASSES}
|
|
102
108
|
>
|
|
103
109
|
{/* A row's rows start under its title: the 16px chevron and its 8px gap
|
|
104
|
-
(pl-6). An indented heading's: its
|
|
105
|
-
<div className={cn('flex flex-col', look === 'row'
|
|
110
|
+
(pl-6). An indented heading's: the same — its arrow is a row's now (5 October). */}
|
|
111
|
+
<div className={cn('flex flex-col pt-0.5', (look === 'row' || indent) && 'pl-6', contentClassName)}>{children}</div>
|
|
106
112
|
</Collapsible.Panel>
|
|
107
113
|
</Collapsible.Root>
|
|
108
114
|
)
|
package/src/ContainerHeader.mdx
CHANGED
|
@@ -17,6 +17,10 @@ with a hairline under it.
|
|
|
17
17
|
thread, a side panel. One per column.
|
|
18
18
|
- The column's own actions — new, sort, close — as IconButtons with tooltips
|
|
19
19
|
in `actions`.
|
|
20
|
+
- `titleMenu`: the title opens a menu of the column's views, its chevron
|
|
21
|
+
drawn; the title and the chevron are one button.
|
|
22
|
+
|
|
23
|
+
<Canvas of={ContainerHeaderStories.WithTitleMenu} />
|
|
20
24
|
|
|
21
25
|
## When not
|
|
22
26
|
|
|
@@ -2,6 +2,7 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
|
2
2
|
import { IconEdit, IconSortDescending, IconX } from '@tabler/icons-react'
|
|
3
3
|
import { ContainerHeader } from './ContainerHeader'
|
|
4
4
|
import { IconButton } from './IconButton'
|
|
5
|
+
import { MenuItem } from './Menu'
|
|
5
6
|
import { Card } from './Card'
|
|
6
7
|
|
|
7
8
|
// The top of a column: a surface with a hairline, so the bar's own hairline and height can be seen.
|
|
@@ -18,6 +19,19 @@ const meta = {
|
|
|
18
19
|
export default meta
|
|
19
20
|
type Story = StoryObj<typeof meta>
|
|
20
21
|
|
|
22
|
+
/** A title that opens a menu: the title and its chevron are one button. Click it. */
|
|
23
|
+
export const WithTitleMenu: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
title: 'View one',
|
|
26
|
+
titleMenu: (
|
|
27
|
+
<>
|
|
28
|
+
<MenuItem label="View one" selected />
|
|
29
|
+
<MenuItem label="View two" />
|
|
30
|
+
</>
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
/** A string title, one line, alone. */
|
|
22
36
|
export const Default: Story = {}
|
|
23
37
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
9
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import { MenuItem } from './Menu'
|
|
10
12
|
import { ContainerHeader } from './ContainerHeader'
|
|
11
13
|
|
|
12
14
|
afterEach(cleanup)
|
|
@@ -44,4 +46,10 @@ describe('ContainerHeader', () => {
|
|
|
44
46
|
expect(container.firstElementChild!.children).toHaveLength(2)
|
|
45
47
|
expect(screen.getByRole('button', { name: 'New' }).closest('div')!.className).toContain('gap-1')
|
|
46
48
|
})
|
|
49
|
+
|
|
50
|
+
it('a title with a menu is one button, and opens it', async () => {
|
|
51
|
+
render(<ContainerHeader title="View one" titleMenu={<MenuItem label="View two" />} />)
|
|
52
|
+
await userEvent.click(screen.getByRole('button', { name: 'View one' }))
|
|
53
|
+
expect(await screen.findByRole('menuitem', { name: 'View two' })).not.toBeNull()
|
|
54
|
+
})
|
|
47
55
|
})
|
package/src/ContainerHeader.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { IconChevronDown } from '@tabler/icons-react'
|
|
3
|
+
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
4
|
import { cn } from './cn'
|
|
5
|
+
import { MENU_TRIGGER_CLASSES } from './looks'
|
|
6
|
+
import { Menu } from './Menu'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The bar across the top of a column — a list, a thread, a side panel: its
|
|
@@ -20,20 +23,45 @@ export interface ContainerHeaderProps {
|
|
|
20
23
|
title: ReactNode
|
|
21
24
|
/** A chevron after the title, for a title that opens something. */
|
|
22
25
|
chevron?: boolean
|
|
26
|
+
/**
|
|
27
|
+
* The menu the title opens: `MenuItem`s — the column's views (Katerina,
|
|
28
|
+
* 5 October: Peek's Topics column, Topics or Folders). The title and its
|
|
29
|
+
* chevron are then one button, lit under the pointer; the chevron is drawn.
|
|
30
|
+
*/
|
|
31
|
+
titleMenu?: ReactNode
|
|
23
32
|
/** The column's own buttons, at the right edge: IconButtons with tooltips, 4px apart. */
|
|
24
33
|
actions?: ReactNode
|
|
25
34
|
className?: string
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
export function ContainerHeader({ title, chevron = false, actions, className }: ContainerHeaderProps) {
|
|
37
|
+
export function ContainerHeader({ title, chevron = false, titleMenu, actions, className }: ContainerHeaderProps) {
|
|
29
38
|
const text = typeof title === 'string'
|
|
39
|
+
const name = (
|
|
40
|
+
<div className={cn('flex items-center gap-1', text ? 'shrink-0' : 'min-w-0 flex-1')}>
|
|
41
|
+
{text ? <span className="whitespace-nowrap text-body-2-strong text-text-primary">{title}</span> : <div className="min-w-0 flex-1">{title}</div>}
|
|
42
|
+
{(chevron || titleMenu != null) && <IconChevronDown size={12} stroke={1.5} className="shrink-0 text-text-secondary" />}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
30
45
|
return (
|
|
31
|
-
|
|
46
|
+
// With a title menu the bar gives the button 6px of its left padding
|
|
47
|
+
// (pl-3.5 + the button's px-1.5 = pl-5): the words stay where a title's
|
|
48
|
+
// are, and the fill is inside the bar — a negative margin had it clipped
|
|
49
|
+
// (Katerina, 5 October).
|
|
50
|
+
<div className={cn('flex h-12 shrink-0 items-center justify-between overflow-hidden border-b border-border-subtle py-2 pr-4', titleMenu != null ? 'pl-3.5' : 'pl-5', className)}>
|
|
32
51
|
<div className={cn('flex items-center gap-2 overflow-hidden', text ? 'shrink-0' : 'min-w-0 flex-1')}>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
52
|
+
{titleMenu != null ? (
|
|
53
|
+
<Menu
|
|
54
|
+
trigger={
|
|
55
|
+
<BaseButton type="button" className={cn('flex min-w-0 items-center px-1.5 py-1', MENU_TRIGGER_CLASSES)}>
|
|
56
|
+
{name}
|
|
57
|
+
</BaseButton>
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{titleMenu}
|
|
61
|
+
</Menu>
|
|
62
|
+
) : (
|
|
63
|
+
name
|
|
64
|
+
)}
|
|
37
65
|
</div>
|
|
38
66
|
{actions && (
|
|
39
67
|
<div className="flex shrink-0 items-center justify-end gap-3">
|