@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,65 @@
|
|
|
1
|
+
import { Meta, Canvas } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as EditorBlockHandleStories from './EditorBlockHandle.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={EditorBlockHandleStories} />
|
|
5
|
+
|
|
6
|
+
# EditorBlockHandle
|
|
7
|
+
|
|
8
|
+
`BlockHandle` in a Tiptap editor: drag the handle to move the block, press it
|
|
9
|
+
for Turn into, Duplicate and Delete, then the app's rows.
|
|
10
|
+
|
|
11
|
+
<Canvas of={EditorBlockHandleStories.Default} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **An editor of a block document** (SPEC §13.3) that someone reorganises —
|
|
16
|
+
with `BlockNodes`' `BlockId` in its extensions, so every block has an id.
|
|
17
|
+
- **Not editable**: the same handle reads — no drag, the app's rows only.
|
|
18
|
+
|
|
19
|
+
## When not
|
|
20
|
+
|
|
21
|
+
- The document being read, with no editor → **BlockHandle**, from the main
|
|
22
|
+
entry, which loads no Tiptap.
|
|
23
|
+
- Formatting a few words inside a block → **SelectionToolbar**. This acts on
|
|
24
|
+
whole blocks.
|
|
25
|
+
|
|
26
|
+
## How
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
30
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
31
|
+
import { BlockId, EditorBlockHandle, type BlockTurnInto } from '@estiva-app/ui/editor'
|
|
32
|
+
import { MenuItem, type HandleBlock } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
const turnInto: BlockTurnInto[] = [
|
|
35
|
+
{ label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
|
|
36
|
+
{ label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run() },
|
|
37
|
+
]
|
|
38
|
+
const rows = (block: HandleBlock) => <MenuItem label="Copy link" onClick={() => navigator.clipboard.writeText(`#${block.id}`)} />
|
|
39
|
+
|
|
40
|
+
const editor = useEditor({ extensions: [StarterKit, BlockId] })
|
|
41
|
+
|
|
42
|
+
<EditorBlockHandle editor={editor} turnInto={turnInto} actions={rows}>
|
|
43
|
+
<EditorContent editor={editor} />
|
|
44
|
+
</EditorBlockHandle>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `turnInto`'s `run` gets the editor with the caret already in the block, and
|
|
48
|
+
is offered only for a block of text: a paragraph, a heading, code.
|
|
49
|
+
- **Every row leaves the editor focused**, as a drop does: Turn into in the
|
|
50
|
+
block, Duplicate in the copy, Delete where the block was. An editor that
|
|
51
|
+
saves when the person leaves it saves these the same way.
|
|
52
|
+
- `duplicateBlock`, `deleteBlock` and `startBlockDrag` are the rows' own
|
|
53
|
+
actions, for a caller that offers them elsewhere (a shortcut, a test).
|
|
54
|
+
|
|
55
|
+
<Canvas of={EditorBlockHandleStories.NotEditable} />
|
|
56
|
+
|
|
57
|
+
## What it owns
|
|
58
|
+
|
|
59
|
+
It does these by itself. Write none of them around it.
|
|
60
|
+
|
|
61
|
+
| It does | So you never write | Refused when written by hand |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| Moves the block through ProseMirror's own drop, so it arrives with its id and its anchored comments still find it | a drop handler, or a delete and an insert | |
|
|
64
|
+
| Duplicates a block under a fresh id, the original keeping its own (`BlockId` decides) | an id for the copy | |
|
|
65
|
+
| Puts the caret in the block before a Turn into row runs | a selection before your `run` | |
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { EditorContent, useEditor } from '@tiptap/react'
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
4
|
+
import { IconLink, IconMessage } from '@tabler/icons-react'
|
|
5
|
+
import type { HandleBlock } from './BlockHandle'
|
|
6
|
+
import { BlockId } from './BlockNodes'
|
|
7
|
+
import { EditorBlockHandle, blockDropCursor, type BlockTurnInto } from './EditorBlockHandle'
|
|
8
|
+
import { MenuItem } from './Menu'
|
|
9
|
+
import { richTextClassName } from './RichText'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `BlockHandle` in an editor. Drag the handle to move a block; press it for
|
|
13
|
+
* Turn into, Duplicate, Delete, then the app's rows.
|
|
14
|
+
*/
|
|
15
|
+
const meta = {
|
|
16
|
+
title: 'Primitives/EditorBlockHandle',
|
|
17
|
+
// The handle sits 28px left of the content, in the page's margin.
|
|
18
|
+
parameters: { layout: 'padded' },
|
|
19
|
+
} satisfies Meta
|
|
20
|
+
|
|
21
|
+
export default meta
|
|
22
|
+
type Story = StoryObj
|
|
23
|
+
|
|
24
|
+
const DOCUMENT = {
|
|
25
|
+
type: 'doc',
|
|
26
|
+
content: [
|
|
27
|
+
{ type: 'heading', attrs: { level: 1, blockId: 'b1' }, content: [{ type: 'text', text: 'A heading' }] },
|
|
28
|
+
{ type: 'paragraph', attrs: { blockId: 'b2' }, content: [{ type: 'text', text: 'The first paragraph. Drag its handle below the list.' }] },
|
|
29
|
+
{ type: 'paragraph', attrs: { blockId: 'b3' }, content: [{ type: 'text', text: 'The second paragraph.' }] },
|
|
30
|
+
{
|
|
31
|
+
type: 'bulletList',
|
|
32
|
+
attrs: { blockId: 'b4' },
|
|
33
|
+
content: [
|
|
34
|
+
{ type: 'listItem', attrs: { blockId: 'b5' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item one' }] }] },
|
|
35
|
+
{ type: 'listItem', attrs: { blockId: 'b6' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item two' }] }] },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{ type: 'paragraph', attrs: { blockId: 'b7' }, content: [{ type: 'text', text: 'The last paragraph.' }] },
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TURN_INTO: BlockTurnInto[] = [
|
|
43
|
+
{ label: 'Text', run: (editor) => editor.chain().setParagraph().run(), active: (node) => node.type.name === 'paragraph' },
|
|
44
|
+
{ label: 'Heading', run: (editor) => editor.chain().setHeading({ level: 1 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 1 },
|
|
45
|
+
{ label: 'Subheading', run: (editor) => editor.chain().setHeading({ level: 2 }).run(), active: (node) => node.type.name === 'heading' && node.attrs.level === 2 },
|
|
46
|
+
{ label: 'Code', run: (editor) => editor.chain().setCodeBlock().run(), active: (node) => node.type.name === 'codeBlock' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** The rows an app adds: here they only say which block they were pressed on. */
|
|
50
|
+
function rows(block: HandleBlock) {
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<MenuItem label="Copy link" leading={<IconLink size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('copy link', block.id)} />
|
|
54
|
+
<MenuItem label="Comment" leading={<IconMessage size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => console.info('comment', block.id)} />
|
|
55
|
+
</>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Editing({ editable }: { editable: boolean }) {
|
|
60
|
+
const editor = useEditor({
|
|
61
|
+
extensions: [StarterKit.configure({ dropcursor: blockDropCursor }), BlockId],
|
|
62
|
+
content: DOCUMENT,
|
|
63
|
+
editable,
|
|
64
|
+
editorProps: { attributes: { class: richTextClassName('default', 'outline-none'), 'aria-label': 'Text' } },
|
|
65
|
+
})
|
|
66
|
+
return (
|
|
67
|
+
<EditorBlockHandle editor={editor} turnInto={TURN_INTO} actions={rows} className="ml-8 max-w-[560px]">
|
|
68
|
+
<EditorContent editor={editor} />
|
|
69
|
+
</EditorBlockHandle>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drag to move; Turn into, Duplicate and Delete; then the app's rows. */
|
|
74
|
+
export const Default: Story = {
|
|
75
|
+
render: () => <Editing editable />,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An editor that is not editable: no drag, and only the app's rows. */
|
|
79
|
+
export const NotEditable: Story = {
|
|
80
|
+
render: () => <Editing editable={false} />,
|
|
81
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from 'react'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
4
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
5
|
+
import { IconCopy, IconTrash } from '@tabler/icons-react'
|
|
6
|
+
import { BlockHandle, blockAtY, type BlockHandleEditing, type HandleBlock } from './BlockHandle'
|
|
7
|
+
import { MenuItem, MenuSub } from './Menu'
|
|
8
|
+
|
|
9
|
+
/** A row of the block menu's Turn into: what the block becomes. */
|
|
10
|
+
export interface BlockTurnInto {
|
|
11
|
+
label: string
|
|
12
|
+
/** Turns the block the caret is in. The handle puts the caret there first. */
|
|
13
|
+
run: (editor: Editor) => void
|
|
14
|
+
/** Whether the block already is this — drawn as the selected row. */
|
|
15
|
+
active?: (node: ProseMirrorNode) => boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EditorBlockHandleProps {
|
|
19
|
+
/** The editor. Not editable, or not there yet, and the handle reads: the caller's rows only. */
|
|
20
|
+
editor: Editor | null
|
|
21
|
+
/** Turn into's rows. Offered for a block of text — a paragraph, a heading, code. */
|
|
22
|
+
turnInto?: readonly BlockTurnInto[]
|
|
23
|
+
/** The caller's own rows, after the editing ones. */
|
|
24
|
+
actions?: (block: HandleBlock) => ReactNode
|
|
25
|
+
/** The `EditorContent`. */
|
|
26
|
+
children: ReactNode
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The editor's top-level blocks, where each starts and what it is. */
|
|
31
|
+
function editorBlocks(view: EditorView): HandleBlock[] {
|
|
32
|
+
const out: HandleBlock[] = []
|
|
33
|
+
view.state.doc.forEach((node, pos) => {
|
|
34
|
+
const element = view.nodeDOM(pos)
|
|
35
|
+
if (element instanceof HTMLElement) out.push({ id: (node.attrs.blockId as string | null) || undefined, element, pos })
|
|
36
|
+
})
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
Duplicate and Delete leave the editor focused, as Turn into and a drop do: the
|
|
42
|
+
person is still in the field, so an editor that saves when they leave it saves
|
|
43
|
+
these too — and one that loads someone else's edit while unfocused does not
|
|
44
|
+
load it over them.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/** A copy of the block straight after it, the caret in the copy. `BlockId` gives the copy a fresh id; the original keeps its own. */
|
|
48
|
+
export function duplicateBlock(editor: Editor, pos: number): boolean {
|
|
49
|
+
const node = editor.state.doc.nodeAt(pos)
|
|
50
|
+
if (!node) return false
|
|
51
|
+
editor.view.dispatch(editor.state.tr.insert(pos + node.nodeSize, node).scrollIntoView())
|
|
52
|
+
editor.commands.focus(pos + node.nodeSize + 1)
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Removes the block, the caret where it was. The last block of a document becomes an empty paragraph, since a document holds at least one. */
|
|
57
|
+
export function deleteBlock(editor: Editor, pos: number): boolean {
|
|
58
|
+
const { state } = editor
|
|
59
|
+
const node = state.doc.nodeAt(pos)
|
|
60
|
+
if (!node) return false
|
|
61
|
+
const tr = state.doc.childCount === 1 ? state.tr.replaceWith(pos, pos + node.nodeSize, state.schema.nodes.paragraph.create()) : state.tr.delete(pos, pos + node.nodeSize)
|
|
62
|
+
editor.view.dispatch(tr)
|
|
63
|
+
editor.commands.focus(Math.min(pos + 1, editor.state.doc.content.size))
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Moves the block at `from` to `to` (a position between top-level blocks), the
|
|
69
|
+
* caret in it: what a drop in the gutter does, where the editor has no drop of
|
|
70
|
+
* its own. The node moves whole, attributes and all, so it keeps its id, as
|
|
71
|
+
* ProseMirror's drop keeps it.
|
|
72
|
+
*/
|
|
73
|
+
export function moveBlock(editor: Editor, from: number, to: number): boolean {
|
|
74
|
+
const node = editor.state.doc.nodeAt(from)
|
|
75
|
+
if (!node || (to >= from && to <= from + node.nodeSize)) return false
|
|
76
|
+
const tr = editor.state.tr.delete(from, from + node.nodeSize)
|
|
77
|
+
const at = tr.mapping.map(to)
|
|
78
|
+
editor.view.dispatch(tr.insert(at, node).scrollIntoView())
|
|
79
|
+
editor.commands.focus(at + 1)
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Starts the browser's own drag of the block, as ProseMirror starts one of a
|
|
85
|
+
* selected node: the block selected, its slice on `view.dragging` with `move`,
|
|
86
|
+
* and its HTML on the transfer. ProseMirror's drop then deletes the selection
|
|
87
|
+
* and inserts the slice with its attributes — so the block arrives with the
|
|
88
|
+
* id it left with, and every comment anchored to it still finds it.
|
|
89
|
+
*/
|
|
90
|
+
export function startBlockDrag(editor: Editor, pos: number, event: Pick<globalThis.DragEvent, 'dataTransfer'>, image?: Element) {
|
|
91
|
+
editor.commands.setNodeSelection(pos)
|
|
92
|
+
const { view } = editor
|
|
93
|
+
const slice = view.state.selection.content()
|
|
94
|
+
const { dom, text } = view.serializeForClipboard(slice)
|
|
95
|
+
const transfer = event.dataTransfer
|
|
96
|
+
if (transfer) {
|
|
97
|
+
transfer.clearData()
|
|
98
|
+
transfer.setData('text/html', dom.innerHTML)
|
|
99
|
+
transfer.setData('text/plain', text)
|
|
100
|
+
transfer.effectAllowed = 'copyMove'
|
|
101
|
+
if (image) transfer.setDragImage(image, 0, 0)
|
|
102
|
+
}
|
|
103
|
+
view.dragging = { slice, move: true }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The drop line a dragged block shows, in the package's accent instead of the
|
|
108
|
+
* editor's default (the text's colour). Give it to the editor's StarterKit:
|
|
109
|
+
* `StarterKit.configure({ dropcursor: blockDropCursor })`.
|
|
110
|
+
*/
|
|
111
|
+
export const blockDropCursor = { color: false, class: 'bg-accent-primary' } as const
|
|
112
|
+
|
|
113
|
+
const NOTHING_YET: BlockHandleEditing = { find: () => null, rows: () => null, startDrag: () => {}, drop: () => {}, endDrag: () => {}, onChange: () => () => {} }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `BlockHandle` in an editor: drag the handle to move the block, and the menu
|
|
117
|
+
* gains Turn into, Duplicate and Delete before the caller's rows.
|
|
118
|
+
*/
|
|
119
|
+
export function EditorBlockHandle({ editor, turnInto = [], actions, children, className }: EditorBlockHandleProps) {
|
|
120
|
+
const editing = useMemo<BlockHandleEditing>(() => {
|
|
121
|
+
// Before the editor exists there is no block to find — not the editor's whole element read as one.
|
|
122
|
+
if (!editor) return NOTHING_YET
|
|
123
|
+
// Where the block being dragged starts.
|
|
124
|
+
let dragged: number | undefined
|
|
125
|
+
return {
|
|
126
|
+
find: (y) => blockAtY(editorBlocks(editor.view), y),
|
|
127
|
+
rows: (block) => {
|
|
128
|
+
const pos = block.pos
|
|
129
|
+
// Not editable: the caller's rows only, and no drag.
|
|
130
|
+
if (!editor.isEditable) return null
|
|
131
|
+
const node = pos === undefined ? null : editor.state.doc.nodeAt(pos)
|
|
132
|
+
if (pos === undefined || !node) return null
|
|
133
|
+
return (
|
|
134
|
+
<>
|
|
135
|
+
{node.isTextblock && turnInto.length > 0 && (
|
|
136
|
+
<MenuSub label="Turn into">
|
|
137
|
+
{turnInto.map((row) => (
|
|
138
|
+
<MenuItem
|
|
139
|
+
key={row.label}
|
|
140
|
+
label={row.label}
|
|
141
|
+
selected={row.active?.(node) ?? false}
|
|
142
|
+
onClick={() => {
|
|
143
|
+
// Inside the block's text, so the row's command turns this block and no other.
|
|
144
|
+
editor.chain().focus().setTextSelection(pos + 1).run()
|
|
145
|
+
row.run(editor)
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
))}
|
|
149
|
+
</MenuSub>
|
|
150
|
+
)}
|
|
151
|
+
<MenuItem label="Duplicate" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => duplicateBlock(editor, pos)} />
|
|
152
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => deleteBlock(editor, pos)} />
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
155
|
+
},
|
|
156
|
+
startDrag: (block, event) => {
|
|
157
|
+
dragged = block.pos
|
|
158
|
+
if (block.pos !== undefined) startBlockDrag(editor, block.pos, event, block.element)
|
|
159
|
+
},
|
|
160
|
+
drop: (target, after) => {
|
|
161
|
+
const node = target.pos === undefined ? null : editor.state.doc.nodeAt(target.pos)
|
|
162
|
+
if (dragged === undefined || target.pos === undefined || !node) return
|
|
163
|
+
moveBlock(editor, dragged, after ? target.pos + node.nodeSize : target.pos)
|
|
164
|
+
},
|
|
165
|
+
// ProseMirror clears `dragging` on its own dragend, which a drag started outside its element never reaches.
|
|
166
|
+
endDrag: () => setTimeout(() => (editor.view.dragging = null), 50),
|
|
167
|
+
onChange: (changed) => {
|
|
168
|
+
editor.on('update', changed)
|
|
169
|
+
return () => {
|
|
170
|
+
editor.off('update', changed)
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}, [editor, turnInto])
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<BlockHandle editing={editing} actions={actions} className={className}>
|
|
178
|
+
{children}
|
|
179
|
+
</BlockHandle>
|
|
180
|
+
)
|
|
181
|
+
}
|
package/src/IconButton.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
|
2
2
|
import { Button as BaseButton } from '@base-ui/react/button'
|
|
3
3
|
import { cn } from './cn'
|
|
4
|
+
import { MUTED_CONTROL_CLASSES } from './looks'
|
|
4
5
|
import { useFormBusy } from './formBusy'
|
|
5
6
|
import { TooltipTrigger } from './Tooltip'
|
|
6
7
|
|
|
@@ -82,7 +83,7 @@ export function IconButton({
|
|
|
82
83
|
// stretched and carries no `self-center` — see the note there.
|
|
83
84
|
'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
|
|
84
85
|
!off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
|
|
85
|
-
!off && (variant === 'muted' || variant === 'resolve') &&
|
|
86
|
+
!off && (variant === 'muted' || variant === 'resolve') && MUTED_CONTROL_CLASSES,
|
|
86
87
|
!off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
|
|
87
88
|
!off && variant === 'current' && 'hover:bg-bg-hover',
|
|
88
89
|
off && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
|
package/src/ListColumn.tsx
CHANGED
|
@@ -32,6 +32,8 @@ export interface ListColumnProps {
|
|
|
32
32
|
title: ReactNode
|
|
33
33
|
/** A chevron after the title, as `ContainerHeader` draws it. */
|
|
34
34
|
chevron?: boolean
|
|
35
|
+
/** The menu the title opens — `ContainerHeader`'s: the column's views. */
|
|
36
|
+
titleMenu?: ReactNode
|
|
35
37
|
/** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
|
|
36
38
|
actions?: ReactNode
|
|
37
39
|
/** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
|
|
@@ -44,7 +46,7 @@ export interface ListColumnProps {
|
|
|
44
46
|
className?: string
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
function Column({ title, chevron = false, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
|
|
49
|
+
function Column({ title, chevron = false, titleMenu, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
|
|
48
50
|
return (
|
|
49
51
|
<div
|
|
50
52
|
className={cn(
|
|
@@ -55,7 +57,7 @@ function Column({ title, chevron = false, actions, above, collapsed = false, chi
|
|
|
55
57
|
)}
|
|
56
58
|
data-collapsed={collapsed || undefined}
|
|
57
59
|
>
|
|
58
|
-
<ContainerHeader title={title} chevron={chevron} actions={actions} />
|
|
60
|
+
<ContainerHeader title={title} chevron={chevron} titleMenu={titleMenu} actions={actions} />
|
|
59
61
|
{above}
|
|
60
62
|
{broken ? (
|
|
61
63
|
// The message takes the room the list had and centres itself in it (Katerina, 19 September).
|
package/src/Menu.tsx
CHANGED
|
@@ -95,7 +95,8 @@ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
|
|
|
95
95
|
return (
|
|
96
96
|
<div
|
|
97
97
|
className={cn(
|
|
98
|
-
'
|
|
98
|
+
// 2px between rows, as a list column's and a sidebar's (Katerina, 5 October).
|
|
99
|
+
'flex flex-col gap-0.5 p-2',
|
|
99
100
|
FLOATING_SURFACE_CLASSES,
|
|
100
101
|
/*
|
|
101
102
|
* A divider in a menu runs the width of the rows it separates.
|
|
@@ -248,7 +249,7 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
|
|
|
248
249
|
* sentence was written at 0.12.6 and the prop was not: a `p-1` on
|
|
249
250
|
* `className` added 4px to these 8px instead of replacing them
|
|
250
251
|
* (PLAN Finding 60). The prop exists now. */}
|
|
251
|
-
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2', contentClassName)}>
|
|
252
|
+
<ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col gap-0.5 p-2', contentClassName)}>
|
|
252
253
|
<MenuContext.Provider value={{ openOnHover }}>
|
|
253
254
|
<DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
|
|
254
255
|
</MenuContext.Provider>
|
|
@@ -578,14 +579,14 @@ export function MenuSection({ label, children, className }: { label: string; chi
|
|
|
578
579
|
)
|
|
579
580
|
if (!inMenu) {
|
|
580
581
|
return (
|
|
581
|
-
<div className="flex flex-col">
|
|
582
|
+
<div className="flex flex-col gap-0.5">
|
|
582
583
|
{heading}
|
|
583
584
|
{children}
|
|
584
585
|
</div>
|
|
585
586
|
)
|
|
586
587
|
}
|
|
587
588
|
return (
|
|
588
|
-
<BaseMenu.Group className="flex flex-col">
|
|
589
|
+
<BaseMenu.Group className="flex flex-col gap-0.5">
|
|
589
590
|
<BaseMenu.GroupLabel render={heading} />
|
|
590
591
|
{children}
|
|
591
592
|
</BaseMenu.Group>
|
package/src/NavItem.test.tsx
CHANGED
|
@@ -99,10 +99,10 @@ describe('NavItem unread', () => {
|
|
|
99
99
|
expect(container.querySelector('[data-urgent], [data-unread]')).toBeNull()
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
it('with a menu, the ⋮
|
|
102
|
+
it('with a menu over the dot, the ⋮ ends where a heading’s ⋮ ends', () => {
|
|
103
103
|
render(<NavItem href="#" label="Item one" unread menu={<span>Action</span>} />)
|
|
104
104
|
const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
|
|
105
|
-
expect(menu?.className).toContain('right-
|
|
105
|
+
expect(menu?.className).toContain('right-1')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
108
|
it('with a count and a menu, the ⋮ lands on the count', () => {
|
package/src/NavItem.tsx
CHANGED
|
@@ -108,7 +108,11 @@ export function NavItem({
|
|
|
108
108
|
// What sits at the right at rest, after the count: the dot, or the badge.
|
|
109
109
|
const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
|
|
110
110
|
// Under the ⋮ while it shows: the count and the mark step aside for it.
|
|
111
|
-
|
|
111
|
+
// "While it shows" is the pointer on the row or the keyboard in it
|
|
112
|
+
// (`:focus-visible`), never any focus: a clicked row keeps its focus, and
|
|
113
|
+
// with `focus-within` its icon and dot went while the arrow and the ⋮ stayed
|
|
114
|
+
// hidden (Katerina, 5 October).
|
|
115
|
+
const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0'
|
|
112
116
|
|
|
113
117
|
const row = (
|
|
114
118
|
<a
|
|
@@ -135,7 +139,7 @@ export function NavItem({
|
|
|
135
139
|
className={cn(
|
|
136
140
|
// With rows under it the slot is kept even with no icon: the arrow lands in it.
|
|
137
141
|
'flex min-w-4 shrink-0 items-center',
|
|
138
|
-
hasRows && 'transition-opacity group-hover/nav:opacity-0 group-focus-
|
|
142
|
+
hasRows && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0',
|
|
139
143
|
)}
|
|
140
144
|
>
|
|
141
145
|
{icon}
|
|
@@ -144,7 +148,7 @@ export function NavItem({
|
|
|
144
148
|
<span className={cn('flex-1 truncate', unread && 'font-medium')}>{label}</span>
|
|
145
149
|
{hint != null && (
|
|
146
150
|
// Takes room only while it shows, so the label gives way to it.
|
|
147
|
-
<span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-
|
|
151
|
+
<span className="hidden shrink-0 items-center group-hover/nav:flex group-has-[:focus-visible]/nav:flex">{hint}</span>
|
|
148
152
|
)}
|
|
149
153
|
{count ? (
|
|
150
154
|
<WithTooltip label={countLabel ?? `${count} open`}>
|
|
@@ -164,12 +168,15 @@ export function NavItem({
|
|
|
164
168
|
</WithTooltip>
|
|
165
169
|
) : null}
|
|
166
170
|
{mark ? (
|
|
167
|
-
|
|
171
|
+
// `-mr-1`: the 24px slot's dot then sits on the axis of a count and
|
|
172
|
+
// of the ⋮ (8px from the edge to the 16px box), one column with a
|
|
173
|
+
// folder heading's dot and ⋮ (Katerina, 5 October).
|
|
174
|
+
<span className={cn('-mr-1 flex shrink-0', stepsAside)}>{mark}</span>
|
|
168
175
|
) : menu != null && !count ? (
|
|
169
176
|
// Nothing at the right, but a menu: no room is kept at rest, so the
|
|
170
177
|
// label has the row's whole width; while the ⋮ shows, its 24px slot
|
|
171
178
|
// is kept, so a long label ends before it instead of running under it.
|
|
172
|
-
<span aria-hidden="true" className="hidden w-6 shrink-0 group-hover/nav:block group-focus-
|
|
179
|
+
<span aria-hidden="true" className="hidden w-6 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block" />
|
|
173
180
|
) : null}
|
|
174
181
|
</a>
|
|
175
182
|
)
|
|
@@ -181,7 +188,7 @@ export function NavItem({
|
|
|
181
188
|
{hasRows && (
|
|
182
189
|
// `left-1`: the 24px button's 16px arrow then sits where the icon
|
|
183
190
|
// sits (the row's px-2), so the icon turns into the arrow in place.
|
|
184
|
-
<div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity
|
|
191
|
+
<div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100">
|
|
185
192
|
<IconButton
|
|
186
193
|
tooltip={open ? 'Collapse' : 'Expand'}
|
|
187
194
|
aria-label={open ? `Hide what is under ${label}` : `Show what is under ${label}`}
|
|
@@ -194,11 +201,13 @@ export function NavItem({
|
|
|
194
201
|
</div>
|
|
195
202
|
)}
|
|
196
203
|
{menu != null && (
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
204
|
+
// `right-1`: the 24px button's 16px icon ends 8px from the row's
|
|
205
|
+
// edge, where a count ends (px-2) — and where a SectionHeader's
|
|
206
|
+
// actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column
|
|
207
|
+
// (Katerina, 5 October: the rows move to the heading, not the
|
|
208
|
+
// heading to the rows). Over a dot or the empty slot it sits 4px
|
|
209
|
+
// right of the slot's centre.
|
|
210
|
+
<div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100')}>
|
|
202
211
|
<Menu
|
|
203
212
|
align="right"
|
|
204
213
|
trigger={
|
|
@@ -225,7 +234,7 @@ export function NavItem({
|
|
|
225
234
|
className={COLLAPSIBLE_PANEL_CLASSES}
|
|
226
235
|
>
|
|
227
236
|
{/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
|
|
228
|
-
<div className="flex flex-col gap-
|
|
237
|
+
<div className="flex flex-col gap-0.5 pt-0.5 pl-6">{children}</div>
|
|
229
238
|
</Collapsible.Panel>
|
|
230
239
|
</Collapsible.Root>
|
|
231
240
|
)
|
package/src/NavTree.mdx
CHANGED
|
@@ -33,6 +33,21 @@ rows to any depth.
|
|
|
33
33
|
|
|
34
34
|
<Canvas of={NavTreeStories.WithActionsAndUnread} />
|
|
35
35
|
|
|
36
|
+
- A group's `menu` is its "More options" ⋮, after its actions and its
|
|
37
|
+
`trailing`: what acts on the whole group — a folder's Rename, Archive. A
|
|
38
|
+
row's `menu` acts on that row.
|
|
39
|
+
|
|
40
|
+
<Canvas of={NavTreeStories.GroupMenus} />
|
|
41
|
+
|
|
42
|
+
- A group folds with a row's arrow, 16px — every folding heading does — so a
|
|
43
|
+
group and the rows under it fold with the same arrow; its title starts 8px
|
|
44
|
+
after it, as a row's label.
|
|
45
|
+
- A group's `icon` sits before its title — a folder's — and the arrow takes
|
|
46
|
+
its place under the pointer, as a row's icon turns into its arrow. Its rows
|
|
47
|
+
then start under the title, past the icon.
|
|
48
|
+
|
|
49
|
+
<Canvas of={NavTreeStories.GroupIcons} />
|
|
50
|
+
|
|
36
51
|
- Rows can be read as they open. A group's `nodes={null}` is a skeleton in
|
|
37
52
|
its rows' place, and its `message` a line in their place ("Nothing in this
|
|
38
53
|
yet."). A row with `hasChildren` and no `children` has its arrow, a
|
|
@@ -47,7 +62,7 @@ rows to any depth.
|
|
|
47
62
|
itself: leave out `groups` and pass **NavTreeSection**s as children, each
|
|
48
63
|
with **NavItem** rows. A `NavTreeSection` is one group exactly as `groups`
|
|
49
64
|
draws it — the heading, the light indent, `message`, `loading`, `trailing`,
|
|
50
|
-
`actions`, `onIntent`.
|
|
65
|
+
`actions`, `menu`, `icon`, `onIntent`.
|
|
51
66
|
|
|
52
67
|
<Canvas of={NavTreeStories.AppDrawnGroups} />
|
|
53
68
|
|
package/src/NavTree.stories.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
-
import { IconArrowUpRight, IconPlus } from '@tabler/icons-react'
|
|
4
|
+
import { IconArrowUpRight, IconFolder, IconPlus } from '@tabler/icons-react'
|
|
5
5
|
import { Chip } from './Chip'
|
|
6
6
|
import { UnreadDot } from './UnreadDot'
|
|
7
|
+
import { MenuItem, MenuSeparator } from './Menu'
|
|
7
8
|
import { NavItem } from './NavItem'
|
|
8
9
|
import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
|
|
9
10
|
import { Sidebar } from './Sidebar'
|
|
@@ -101,6 +102,52 @@ export const WithActionsAndUnread: Story = {
|
|
|
101
102
|
},
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
const groupMenu = (
|
|
106
|
+
<>
|
|
107
|
+
<MenuItem label="Add" />
|
|
108
|
+
<MenuItem label="Rename" />
|
|
109
|
+
<MenuItem label="Archive…" />
|
|
110
|
+
<MenuSeparator />
|
|
111
|
+
<MenuItem label="Delete" destructive />
|
|
112
|
+
</>
|
|
113
|
+
)
|
|
114
|
+
const rowMenu = (
|
|
115
|
+
<>
|
|
116
|
+
<MenuItem label="Move…" />
|
|
117
|
+
<MenuItem label="Rename" />
|
|
118
|
+
</>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** A group's `menu`: its "More options", after its dot. Each row has its own. Hover a group's title. */
|
|
122
|
+
export const GroupMenus: Story = {
|
|
123
|
+
args: {
|
|
124
|
+
groups: [
|
|
125
|
+
{ id: 'm1', title: 'Group one', trailing: <UnreadDot />, menu: groupMenu, defaultOpen: true, nodes: [
|
|
126
|
+
{ id: 'm1a', label: 'Item two', href: '#', icon: placeholder, unread: true, menu: rowMenu },
|
|
127
|
+
{ id: 'm1b', label: 'Item three', href: '#', icon: placeholder, menu: rowMenu },
|
|
128
|
+
] },
|
|
129
|
+
{ id: 'm2', title: 'Group two', menu: groupMenu, nodes: [{ id: 'm2a', label: 'Item four', href: '#', icon: placeholder, menu: rowMenu }] },
|
|
130
|
+
],
|
|
131
|
+
selected: 'm1b',
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const folder = <IconFolder size={16} stroke={1.5} />
|
|
136
|
+
|
|
137
|
+
/** A group's `icon`: a folder before its title, the arrow in its place on hover; its rows start under the title. */
|
|
138
|
+
export const GroupIcons: Story = {
|
|
139
|
+
args: {
|
|
140
|
+
groups: [
|
|
141
|
+
{ id: 'i1', title: 'Group one', icon: folder, menu: groupMenu, defaultOpen: true, nodes: [
|
|
142
|
+
{ id: 'i1a', label: 'Item two', href: '#', icon: placeholder, unread: true, menu: rowMenu },
|
|
143
|
+
{ id: 'i1b', label: 'Item three', href: '#', icon: placeholder, menu: rowMenu },
|
|
144
|
+
] },
|
|
145
|
+
{ id: 'i2', title: 'Group two', icon: folder, menu: groupMenu, nodes: [{ id: 'i2a', label: 'Item four', href: '#', icon: placeholder }] },
|
|
146
|
+
],
|
|
147
|
+
selected: 'i1b',
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
104
151
|
/** Read as it opens: a group still being read, a group with nothing in it, a row whose rows are read when it opens, and one that could not be read. */
|
|
105
152
|
export const ReadAsItOpens: Story = {
|
|
106
153
|
args: {
|
|
@@ -124,7 +171,7 @@ export const AppDrawnGroups: Story = {
|
|
|
124
171
|
<div className="flex h-screen bg-bg-base">
|
|
125
172
|
<Sidebar>
|
|
126
173
|
<NavTree title={args.title}>
|
|
127
|
-
<NavTreeSection title="Group one" trailing={<UnreadDot />}>
|
|
174
|
+
<NavTreeSection title="Group one" trailing={<UnreadDot />} menu={groupMenu}>
|
|
128
175
|
<NavItem href="#" label="Item one" icon={placeholder} unread />
|
|
129
176
|
<NavItem href="#" label="Item two" icon={placeholder} active>
|
|
130
177
|
<NavItem href="#" label="Item three" icon={placeholder} />
|
package/src/NavTree.test.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
8
|
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
9
|
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { MenuItem } from './Menu'
|
|
10
11
|
import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
|
|
11
12
|
|
|
12
13
|
afterEach(cleanup)
|
|
@@ -93,6 +94,12 @@ describe('NavTree read as it opens', () => {
|
|
|
93
94
|
expect(screen.getByRole('button', { name: 'Open' })).not.toBeNull()
|
|
94
95
|
})
|
|
95
96
|
|
|
97
|
+
it('a group’s menu is a ⋮ named for the group, and opens its items', async () => {
|
|
98
|
+
render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], menu: <MenuItem label="Rename" /> }]} />)
|
|
99
|
+
await userEvent.click(screen.getByRole('button', { name: 'More options for Group a' }))
|
|
100
|
+
expect(await screen.findByRole('menuitem', { name: 'Rename' })).not.toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
96
103
|
it('onIntent: the keyboard reaching a group says so at once', () => {
|
|
97
104
|
const onIntent = vi.fn()
|
|
98
105
|
render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], onIntent }]} emptyMessage="Nothing here yet." />)
|