@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.
Files changed (69) hide show
  1. package/dist/BlockHandle.d.ts +47 -0
  2. package/dist/BlockHandle.d.ts.map +1 -0
  3. package/dist/CollapsibleSection.d.ts +7 -1
  4. package/dist/CollapsibleSection.d.ts.map +1 -1
  5. package/dist/ContainerHeader.d.ts +7 -1
  6. package/dist/ContainerHeader.d.ts.map +1 -1
  7. package/dist/EditorBlockHandle.d.ts +57 -0
  8. package/dist/EditorBlockHandle.d.ts.map +1 -0
  9. package/dist/IconButton.d.ts.map +1 -1
  10. package/dist/ListColumn.d.ts +2 -0
  11. package/dist/ListColumn.d.ts.map +1 -1
  12. package/dist/Menu.d.ts.map +1 -1
  13. package/dist/NavItem.d.ts.map +1 -1
  14. package/dist/NavTree.d.ts +9 -1
  15. package/dist/NavTree.d.ts.map +1 -1
  16. package/dist/PersonTrigger.d.ts.map +1 -1
  17. package/dist/SectionHeader.d.ts +22 -3
  18. package/dist/SectionHeader.d.ts.map +1 -1
  19. package/dist/SectionLabel.d.ts.map +1 -1
  20. package/dist/editor.d.ts +1 -0
  21. package/dist/editor.d.ts.map +1 -1
  22. package/dist/editor.js +127 -11
  23. package/dist/editor.js.map +4 -4
  24. package/dist/gates/index.js +1 -1
  25. package/dist/gates/index.js.map +2 -2
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +831 -624
  29. package/dist/index.js.map +4 -4
  30. package/dist/looks.d.ts +4 -0
  31. package/dist/looks.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/registry.json +302 -23
  34. package/src/BlockHandle.mdx +58 -0
  35. package/src/BlockHandle.stories.tsx +44 -0
  36. package/src/BlockHandle.test.tsx +216 -0
  37. package/src/BlockHandle.tsx +220 -0
  38. package/src/CollapsibleSection.stories.tsx +14 -1
  39. package/src/CollapsibleSection.tsx +10 -4
  40. package/src/ContainerHeader.mdx +4 -0
  41. package/src/ContainerHeader.stories.tsx +14 -0
  42. package/src/ContainerHeader.test.tsx +8 -0
  43. package/src/ContainerHeader.tsx +34 -6
  44. package/src/EditorBlockHandle.mdx +65 -0
  45. package/src/EditorBlockHandle.stories.tsx +81 -0
  46. package/src/EditorBlockHandle.tsx +181 -0
  47. package/src/IconButton.tsx +2 -1
  48. package/src/ListColumn.tsx +4 -2
  49. package/src/Menu.tsx +5 -4
  50. package/src/NavItem.test.tsx +2 -2
  51. package/src/NavItem.tsx +21 -12
  52. package/src/NavTree.mdx +16 -1
  53. package/src/NavTree.stories.tsx +49 -2
  54. package/src/NavTree.test.tsx +7 -0
  55. package/src/NavTree.tsx +21 -4
  56. package/src/PersonTrigger.tsx +6 -5
  57. package/src/SectionHeader.mdx +6 -0
  58. package/src/SectionHeader.stories.tsx +18 -0
  59. package/src/SectionHeader.test.tsx +26 -0
  60. package/src/SectionHeader.tsx +99 -23
  61. package/src/SectionLabel.tsx +6 -3
  62. package/src/Sidebar.tsx +1 -1
  63. package/src/editor.ts +1 -0
  64. package/src/eslint/no-copied-look.test.ts +2 -2
  65. package/src/gates/app-checks.ts +1 -1
  66. package/src/index.ts +1 -0
  67. package/src/looks.ts +6 -0
  68. package/src/registry/registry.test.ts +5 -5
  69. 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
+ }
@@ -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') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
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',
@@ -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
- 'flex flex-col p-2',
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>
@@ -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 ⋮ sits in the 24px slot over the dot', () => {
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-2')
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
- const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0'
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-within/nav:opacity-0',
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-within/nav:flex">{hint}</span>
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
- <span className={cn('flex shrink-0', stepsAside)}>{mark}</span>
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-within/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block" />
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 focus-within:opacity-100 group-hover/nav:opacity-100">
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
- // On a count, `right-1`: the 24px button's 16px icon then ends 8px
198
- // from the row's edge, where the count ends (px-2), so the ⋮ lands on
199
- // the number. On the mark or the empty slot, `right-2`: the button
200
- // sits in the 24px slot itself.
201
- <div data-nav-menu className={cn('absolute inset-y-0 flex items-center', count && !mark ? 'right-1' : 'right-2', 'opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100')}>
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-px pl-6">{children}</div>
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
 
@@ -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} />
@@ -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." />)