@estiva-app/ui 0.53.1 → 0.55.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/EditorBlockHandle.d.ts +57 -0
- package/dist/EditorBlockHandle.d.ts.map +1 -0
- package/dist/NavItem.d.ts +14 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +93 -13
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/UnreadDot.d.ts +12 -0
- package/dist/UnreadDot.d.ts.map +1 -0
- 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/eslint/index.js +1 -1
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-native-title.d.ts +1 -1
- package/dist/eslint/no-native-title.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +801 -584
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +1 -1
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +362 -11
- 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/EditorBlockHandle.mdx +65 -0
- package/src/EditorBlockHandle.stories.tsx +81 -0
- package/src/EditorBlockHandle.tsx +181 -0
- package/src/NavItem.mdx +14 -1
- package/src/NavItem.stories.tsx +15 -0
- package/src/NavItem.test.tsx +31 -0
- package/src/NavItem.tsx +49 -12
- package/src/NavTree.mdx +25 -0
- package/src/NavTree.stories.tsx +57 -1
- package/src/NavTree.test.tsx +78 -1
- package/src/NavTree.tsx +175 -40
- package/src/SectionLabel.stories.tsx +2 -3
- package/src/UnreadDot.mdx +57 -0
- package/src/UnreadDot.stories.tsx +22 -0
- package/src/UnreadDot.test.tsx +17 -0
- package/src/UnreadDot.tsx +17 -0
- package/src/editor.ts +1 -0
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +3 -1
- package/src/looks.ts +2 -1
- package/src/registry/registry.test.ts +5 -5
- package/stories/Choosing.mdx +3 -0
- package/tailwind-preset.js +1 -0
- package/tokens.css +5 -0
|
@@ -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/NavItem.mdx
CHANGED
|
@@ -41,6 +41,13 @@ count — a muted mono number whose tooltip says what it counts.
|
|
|
41
41
|
|
|
42
42
|
<Canvas of={NavItemStories.WithHintLongLabel} />
|
|
43
43
|
|
|
44
|
+
- Something new on the row is `unread`: its words and icon brighten, the
|
|
45
|
+
label is set in medium, and the **UnreadDot** sits at the right.
|
|
46
|
+
`urgent` with it draws the warning badge in the dot's place. Say "unread"
|
|
47
|
+
in the row's own accessible name too: a dot reads as nothing.
|
|
48
|
+
|
|
49
|
+
<Canvas of={NavItemStories.Unread} />
|
|
50
|
+
|
|
44
51
|
## When not
|
|
45
52
|
|
|
46
53
|
- An icon rail's tile → **RailItem**.
|
|
@@ -73,9 +80,14 @@ import { NavItem } from '@estiva-app/ui'
|
|
|
73
80
|
`Hide what is under {label}` or `Show what is under {label}`.
|
|
74
81
|
- `hint` takes room only while it shows, so a label at rest keeps its full
|
|
75
82
|
width.
|
|
83
|
+
- The ⋮ lands on what is at the right: the count, or the dot. With nothing
|
|
84
|
+
there it keeps no room at rest, so the label has the row's whole width,
|
|
85
|
+
and takes its 24px slot only while it shows.
|
|
86
|
+
- `active` is `bg-nav-active`, the current row in a sidebar, which each theme
|
|
87
|
+
sets for itself.
|
|
76
88
|
|
|
77
89
|
- A row is 32px tall, 8px in on each side, 8px between the icon and the label,
|
|
78
|
-
the label 14px regular,
|
|
90
|
+
the label 14px regular (medium when unread), 8px corners as a **MenuItem**'s.
|
|
79
91
|
|
|
80
92
|
## What it owns
|
|
81
93
|
|
|
@@ -89,6 +101,7 @@ It does these by itself. Write none of them around it.
|
|
|
89
101
|
| Turns its icon into the arrow that folds `children` while pointed at or focused, and slides them | a positioned `IconButton` over the icon, open state, `group-hover` reveals, an indented box | |
|
|
90
102
|
| Tells assistive technology whether its rows are open | `aria-expanded`, `aria-controls` | |
|
|
91
103
|
| Shows `hint` only while pointed at or focused, and makes the label give way | a positioned chip over the row, `group-hover` reveals | |
|
|
104
|
+
| Draws the dot or the badge for `unread` and `urgent`, and brightens the row | an **UnreadDot** placed by hand, a weight or colour passed in | |
|
|
92
105
|
|
|
93
106
|
## Keys
|
|
94
107
|
|
package/src/NavItem.stories.tsx
CHANGED
|
@@ -94,3 +94,18 @@ export const WithRowsUnderClosed: Story = {
|
|
|
94
94
|
</NavItem>
|
|
95
95
|
),
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/** Something new: the words brighten, the label is medium, and the dot sits at the right. */
|
|
99
|
+
export const Unread: Story = {
|
|
100
|
+
args: { icon: placeholder, unread: true },
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Urgent and unread: the warning badge in the dot's place. */
|
|
104
|
+
export const UnreadUrgent: Story = {
|
|
105
|
+
args: { icon: placeholder, unread: true, urgent: true },
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Unread, with a menu: point at it and the ⋮ lands on the dot. */
|
|
109
|
+
export const UnreadWithMenu: Story = {
|
|
110
|
+
args: { icon: placeholder, unread: true, menu: MENU },
|
|
111
|
+
}
|
package/src/NavItem.test.tsx
CHANGED
|
@@ -80,3 +80,34 @@ describe('NavItem with a hint', () => {
|
|
|
80
80
|
expect(container.firstElementChild?.tagName).toBe('A')
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
|
+
|
|
84
|
+
describe('NavItem unread', () => {
|
|
85
|
+
it('draws the dot, and sets the label in medium', () => {
|
|
86
|
+
const { container } = render(<NavItem href="#" label="Item one" unread />)
|
|
87
|
+
expect(container.querySelector('[data-unread]')).not.toBeNull()
|
|
88
|
+
expect(screen.getByText('Item one').className).toContain('font-medium')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('urgent draws the badge in the dot’s place', () => {
|
|
92
|
+
const { container } = render(<NavItem href="#" label="Item one" unread urgent />)
|
|
93
|
+
expect(container.querySelector('[data-urgent]')).not.toBeNull()
|
|
94
|
+
expect(container.querySelector('[data-unread]')).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('urgent without unread draws nothing', () => {
|
|
98
|
+
const { container } = render(<NavItem href="#" label="Item one" urgent />)
|
|
99
|
+
expect(container.querySelector('[data-urgent], [data-unread]')).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('with a menu, the ⋮ sits in the 24px slot over the dot', () => {
|
|
103
|
+
render(<NavItem href="#" label="Item one" unread menu={<span>Action</span>} />)
|
|
104
|
+
const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
|
|
105
|
+
expect(menu?.className).toContain('right-2')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('with a count and a menu, the ⋮ lands on the count', () => {
|
|
109
|
+
render(<NavItem href="#" label="Item one" count={3} menu={<span>Action</span>} />)
|
|
110
|
+
const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
|
|
111
|
+
expect(menu?.className).toContain('right-1')
|
|
112
|
+
})
|
|
113
|
+
})
|
package/src/NavItem.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
2
|
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
|
-
import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
3
|
+
import { IconAlertSquareRounded, IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
4
4
|
import { cn } from './cn'
|
|
5
5
|
import { COLLAPSIBLE_PANEL_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
6
6
|
import { IconButton } from './IconButton'
|
|
7
7
|
import { Menu } from './Menu'
|
|
8
|
+
import { UnreadDot } from './UnreadDot'
|
|
8
9
|
import { WithTooltip } from './Tooltip'
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -34,6 +35,15 @@ import { WithTooltip } from './Tooltip'
|
|
|
34
35
|
* `hint` is shown only while the row is pointed at or focused, left of the
|
|
35
36
|
* count — a neutral `Chip`, typically. It takes its own room while it shows,
|
|
36
37
|
* so the label ends with "…" before it instead of running under it.
|
|
38
|
+
*
|
|
39
|
+
* `unread` brightens the row's words and icon, sets the label in medium, and
|
|
40
|
+
* draws the `UnreadDot` at the right; `urgent` with it draws the warning badge
|
|
41
|
+
* in the dot's place (Katerina, 2 October: Peek's row, moved in). The ⋮ lands
|
|
42
|
+
* on whatever is in that place, and with nothing there it takes no room until
|
|
43
|
+
* the row is pointed at, so a label at rest keeps the row's whole width.
|
|
44
|
+
*
|
|
45
|
+
* Active is `bg-nav-active`, the current row in a sidebar, which each theme
|
|
46
|
+
* sets: the selected tab's neutral fill in most, Signal's blue in Signal.
|
|
37
47
|
*/
|
|
38
48
|
export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'children'> {
|
|
39
49
|
label: string
|
|
@@ -51,6 +61,10 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
|
|
|
51
61
|
menuLabel?: string
|
|
52
62
|
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
53
63
|
hint?: ReactNode
|
|
64
|
+
/** Something new here: brighter words, the label in medium, and the `UnreadDot` at the right. Say so in the row's own words for assistive tech too. */
|
|
65
|
+
unread?: boolean
|
|
66
|
+
/** With `unread`: the warning badge in the dot's place. */
|
|
67
|
+
urgent?: boolean
|
|
54
68
|
/** Rows under this one, folding under it: `NavItem`s. While the row is pointed at or focused, its icon becomes the arrow. */
|
|
55
69
|
children?: ReactNode
|
|
56
70
|
/** With `children`: open unless told otherwise. */
|
|
@@ -71,6 +85,8 @@ export function NavItem({
|
|
|
71
85
|
menu,
|
|
72
86
|
menuLabel,
|
|
73
87
|
hint,
|
|
88
|
+
unread = false,
|
|
89
|
+
urgent = false,
|
|
74
90
|
children,
|
|
75
91
|
defaultOpen = true,
|
|
76
92
|
open: openProp,
|
|
@@ -89,6 +105,10 @@ export function NavItem({
|
|
|
89
105
|
// The row draws beside it — the ⋮, the arrow — so the pointer can leave the
|
|
90
106
|
// link for them while the row still reads as the one it is on.
|
|
91
107
|
const besides = menu != null || hasRows
|
|
108
|
+
// What sits at the right at rest, after the count: the dot, or the badge.
|
|
109
|
+
const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
|
|
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'
|
|
92
112
|
|
|
93
113
|
const row = (
|
|
94
114
|
<a
|
|
@@ -100,7 +120,7 @@ export function NavItem({
|
|
|
100
120
|
// shrink-0 family; Katerina, 2026-09-02).
|
|
101
121
|
'flex h-8 min-w-0 shrink-0 items-center gap-2 px-2 text-body-2',
|
|
102
122
|
SIDEBAR_ROW_CLASSES,
|
|
103
|
-
active ? 'bg-bg-active text-text-primary' : cn(SIDEBAR_ROW_TEXT_CLASSES, 'hover:bg-bg-hover'),
|
|
123
|
+
active ? 'bg-bg-nav-active text-text-primary' : cn(unread ? 'text-text-primary' : SIDEBAR_ROW_TEXT_CLASSES, 'hover:bg-bg-hover'),
|
|
104
124
|
// With a menu the ⋮ sits beside the link, so the row keeps its hover
|
|
105
125
|
// look while the ⋮ is pointed at and while its menu is open. The same
|
|
106
126
|
// for the arrow.
|
|
@@ -121,7 +141,7 @@ export function NavItem({
|
|
|
121
141
|
{icon}
|
|
122
142
|
</span>
|
|
123
143
|
)}
|
|
124
|
-
<span className=
|
|
144
|
+
<span className={cn('flex-1 truncate', unread && 'font-medium')}>{label}</span>
|
|
125
145
|
{hint != null && (
|
|
126
146
|
// Takes room only while it shows, so the label gives way to it.
|
|
127
147
|
<span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-within/nav:flex">{hint}</span>
|
|
@@ -135,17 +155,21 @@ export function NavItem({
|
|
|
135
155
|
<span
|
|
136
156
|
className={cn(
|
|
137
157
|
'min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted',
|
|
138
|
-
// With a menu, the ⋮ takes this place while it shows.
|
|
139
|
-
|
|
158
|
+
// With a menu and no mark after it, the ⋮ takes this place while it shows.
|
|
159
|
+
!mark && stepsAside,
|
|
140
160
|
)}
|
|
141
161
|
>
|
|
142
162
|
{count}
|
|
143
163
|
</span>
|
|
144
164
|
</WithTooltip>
|
|
145
|
-
) :
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
) : null}
|
|
166
|
+
{mark ? (
|
|
167
|
+
<span className={cn('flex shrink-0', stepsAside)}>{mark}</span>
|
|
168
|
+
) : menu != null && !count ? (
|
|
169
|
+
// Nothing at the right, but a menu: no room is kept at rest, so the
|
|
170
|
+
// label has the row's whole width; while the ⋮ shows, its 24px slot
|
|
171
|
+
// 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" />
|
|
149
173
|
) : null}
|
|
150
174
|
</a>
|
|
151
175
|
)
|
|
@@ -170,9 +194,11 @@ export function NavItem({
|
|
|
170
194
|
</div>
|
|
171
195
|
)}
|
|
172
196
|
{menu != null && (
|
|
173
|
-
// `right-1`: the 24px button's 16px icon then ends 8px
|
|
174
|
-
// edge, where the count ends (px-2), so the ⋮ lands on
|
|
175
|
-
|
|
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')}>
|
|
176
202
|
<Menu
|
|
177
203
|
align="right"
|
|
178
204
|
trigger={
|
|
@@ -204,3 +230,14 @@ export function NavItem({
|
|
|
204
230
|
</Collapsible.Root>
|
|
205
231
|
)
|
|
206
232
|
}
|
|
233
|
+
|
|
234
|
+
/** Urgent and unread: a 12px warning mark on its tint, in the dot's 24px slot. */
|
|
235
|
+
function UrgentMark() {
|
|
236
|
+
return (
|
|
237
|
+
<div className="flex h-6 w-6 shrink-0 items-center justify-center" data-urgent>
|
|
238
|
+
<div className="flex items-center rounded-full bg-warning-muted p-0.5 signal:shadow-glow-warning">
|
|
239
|
+
<IconAlertSquareRounded size={12} stroke={2.5} className="text-warning-default" />
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
package/src/NavTree.mdx
CHANGED
|
@@ -27,6 +27,30 @@ rows to any depth.
|
|
|
27
27
|
|
|
28
28
|
<Canvas of={NavTreeStories.Loading} />
|
|
29
29
|
|
|
30
|
+
- `titleActions` sit beside the label, always shown ("New"); a group's
|
|
31
|
+
`actions` beside its title on hover ("Open"), its `trailing` always (an
|
|
32
|
+
**UnreadDot**). A row's `unread` and `urgent` are **NavItem**'s.
|
|
33
|
+
|
|
34
|
+
<Canvas of={NavTreeStories.WithActionsAndUnread} />
|
|
35
|
+
|
|
36
|
+
- Rows can be read as they open. A group's `nodes={null}` is a skeleton in
|
|
37
|
+
its rows' place, and its `message` a line in their place ("Nothing in this
|
|
38
|
+
yet."). A row with `hasChildren` and no `children` has its arrow, a
|
|
39
|
+
skeleton under it once open, and `onOpenChange` says when to read them;
|
|
40
|
+
`childrenMessage` is the line under it. `onIntent` on a group says the
|
|
41
|
+
pointer rests on it or the keyboard reached it: read it then, so opening
|
|
42
|
+
it draws rows.
|
|
43
|
+
|
|
44
|
+
<Canvas of={NavTreeStories.ReadAsItOpens} />
|
|
45
|
+
|
|
46
|
+
- An app whose groups and rows each read their own data draws the groups
|
|
47
|
+
itself: leave out `groups` and pass **NavTreeSection**s as children, each
|
|
48
|
+
with **NavItem** rows. A `NavTreeSection` is one group exactly as `groups`
|
|
49
|
+
draws it — the heading, the light indent, `message`, `loading`, `trailing`,
|
|
50
|
+
`actions`, `onIntent`.
|
|
51
|
+
|
|
52
|
+
<Canvas of={NavTreeStories.AppDrawnGroups} />
|
|
53
|
+
|
|
30
54
|
## When not
|
|
31
55
|
|
|
32
56
|
- A flat list of places → **NavItem** rows in the **Sidebar**.
|
|
@@ -68,6 +92,7 @@ It does these by itself. Write none of them around it.
|
|
|
68
92
|
| Draws every level from one list, rows under rows | a recursive render of **NavItem**s and **CollapsibleSection**s | |
|
|
69
93
|
| Opens the group that holds the current row | a search through the list for the current row | |
|
|
70
94
|
| Says it is loading, or empty, in the rows' place | a skeleton or an **EmptyState** placed by hand | |
|
|
95
|
+
| Says when the pointer rests on a group, 150ms, and not while it sweeps past | a timer on pointer enter and leave | |
|
|
71
96
|
|
|
72
97
|
## Keys
|
|
73
98
|
|
package/src/NavTree.stories.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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
5
|
import { Chip } from './Chip'
|
|
6
|
+
import { UnreadDot } from './UnreadDot'
|
|
5
7
|
import { NavItem } from './NavItem'
|
|
6
|
-
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
8
|
+
import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
|
|
7
9
|
import { Sidebar } from './Sidebar'
|
|
8
10
|
|
|
9
11
|
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
@@ -82,3 +84,57 @@ export const Empty: Story = { args: { groups: [] } }
|
|
|
82
84
|
|
|
83
85
|
/** Close a group and reload the page: still closed. This browser remembers each group, under the key. */
|
|
84
86
|
export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.nav-tree' } }
|
|
87
|
+
|
|
88
|
+
/** Actions beside the label and on each group, a group's dot, and unread rows — one urgent. */
|
|
89
|
+
export const WithActionsAndUnread: Story = {
|
|
90
|
+
args: {
|
|
91
|
+
titleActions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'New', onClick: () => {} }],
|
|
92
|
+
groups: [
|
|
93
|
+
{ id: 'u1', title: 'Group one', trailing: <UnreadDot />, actions: [{ icon: <IconArrowUpRight size={16} stroke={1.5} />, tooltip: 'Open', onClick: () => {} }], defaultOpen: true, nodes: [
|
|
94
|
+
{ id: 'u1a', label: 'Item two', href: '#', icon: placeholder, unread: true },
|
|
95
|
+
{ id: 'u1b', label: 'Item three', href: '#', icon: placeholder, unread: true, urgent: true },
|
|
96
|
+
{ id: 'u1c', label: 'Item four', href: '#', icon: placeholder },
|
|
97
|
+
] },
|
|
98
|
+
{ id: 'u2', title: 'Group two', actions: [{ icon: <IconArrowUpRight size={16} stroke={1.5} />, tooltip: 'Open', onClick: () => {} }], nodes: [{ id: 'u2a', label: 'Item five', href: '#', icon: placeholder }] },
|
|
99
|
+
],
|
|
100
|
+
selected: 'u1c',
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 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
|
+
export const ReadAsItOpens: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
groups: [
|
|
108
|
+
{ id: 'l1', title: 'Being read', defaultOpen: true, nodes: null },
|
|
109
|
+
{ id: 'l2', title: 'Empty', defaultOpen: true, nodes: [], message: 'Nothing in this yet.' },
|
|
110
|
+
{ id: 'l3', title: 'Rows read on open', defaultOpen: true, nodes: [
|
|
111
|
+
{ id: 'l3a', label: 'Item with rows not read yet', href: '#', icon: placeholder, hasChildren: true, defaultOpen: false },
|
|
112
|
+
{ id: 'l3b', label: 'Item whose rows failed', href: '#', icon: placeholder, hasChildren: true, childrenMessage: 'This could not be read.' },
|
|
113
|
+
] },
|
|
114
|
+
],
|
|
115
|
+
selected: undefined,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Groups the app draws itself, as `NavTree`'s children: each a `NavTreeSection` with `NavItem` rows — for groups and rows that each read their own data. */
|
|
120
|
+
export const AppDrawnGroups: Story = {
|
|
121
|
+
args: { groups: undefined },
|
|
122
|
+
render: function Render(args) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex h-screen bg-bg-base">
|
|
125
|
+
<Sidebar>
|
|
126
|
+
<NavTree title={args.title}>
|
|
127
|
+
<NavTreeSection title="Group one" trailing={<UnreadDot />}>
|
|
128
|
+
<NavItem href="#" label="Item one" icon={placeholder} unread />
|
|
129
|
+
<NavItem href="#" label="Item two" icon={placeholder} active>
|
|
130
|
+
<NavItem href="#" label="Item three" icon={placeholder} />
|
|
131
|
+
</NavItem>
|
|
132
|
+
</NavTreeSection>
|
|
133
|
+
<NavTreeSection title="Group two" loading />
|
|
134
|
+
<NavTreeSection title="Group three" message="Nothing in this yet." />
|
|
135
|
+
</NavTree>
|
|
136
|
+
</Sidebar>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
},
|
|
140
|
+
}
|
package/src/NavTree.test.tsx
CHANGED
|
@@ -7,7 +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 { NavTree, type NavTreeGroup } from './NavTree'
|
|
10
|
+
import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
|
|
11
11
|
|
|
12
12
|
afterEach(cleanup)
|
|
13
13
|
|
|
@@ -52,3 +52,80 @@ describe('NavTree', () => {
|
|
|
52
52
|
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
53
53
|
})
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
describe('NavTree read as it opens', () => {
|
|
57
|
+
it('a group being read draws no rows; a group’s message is said in their place', () => {
|
|
58
|
+
render(
|
|
59
|
+
<NavTree
|
|
60
|
+
groups={[
|
|
61
|
+
{ id: 'a', title: 'Group a', defaultOpen: true, nodes: null },
|
|
62
|
+
{ id: 'b', title: 'Group b', defaultOpen: true, nodes: [], message: 'Nothing in this yet.' },
|
|
63
|
+
]}
|
|
64
|
+
emptyMessage="Nothing here yet."
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
68
|
+
expect(screen.getByText('Nothing in this yet.')).not.toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('a row with hasChildren has its arrow, and says when it opens', async () => {
|
|
72
|
+
const onOpenChange = vi.fn()
|
|
73
|
+
render(
|
|
74
|
+
<NavTree
|
|
75
|
+
groups={[{ id: 'a', title: 'Group a', defaultOpen: true, nodes: [{ id: 'x', label: 'Item x', href: '#x', hasChildren: true, defaultOpen: false, onOpenChange }] }]}
|
|
76
|
+
emptyMessage="Nothing here yet."
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
await userEvent.click(screen.getByRole('button', { name: /Show what is under Item x/ }))
|
|
80
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('the label’s and a group’s actions are drawn', () => {
|
|
84
|
+
render(
|
|
85
|
+
<NavTree
|
|
86
|
+
title="Groups"
|
|
87
|
+
titleActions={[{ icon: <span />, tooltip: 'New', onClick: () => {} }]}
|
|
88
|
+
groups={[{ id: 'a', title: 'Group a', nodes: [], actions: [{ icon: <span />, tooltip: 'Open', onClick: () => {} }] }]}
|
|
89
|
+
emptyMessage="Nothing here yet."
|
|
90
|
+
/>,
|
|
91
|
+
)
|
|
92
|
+
expect(screen.getByRole('button', { name: 'New' })).not.toBeNull()
|
|
93
|
+
expect(screen.getByRole('button', { name: 'Open' })).not.toBeNull()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('onIntent: the keyboard reaching a group says so at once', () => {
|
|
97
|
+
const onIntent = vi.fn()
|
|
98
|
+
render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], onIntent }]} emptyMessage="Nothing here yet." />)
|
|
99
|
+
screen.getByRole('button', { name: 'Group a' }).focus()
|
|
100
|
+
expect(onIntent).toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('NavTree drawn by the app', () => {
|
|
105
|
+
it('draws NavTreeSections given as children, each with its rows, line or skeleton', () => {
|
|
106
|
+
render(
|
|
107
|
+
<NavTree title="Groups">
|
|
108
|
+
<NavTreeSection title="Group a"><a href="#a">Item a</a></NavTreeSection>
|
|
109
|
+
<NavTreeSection title="Group b" message="Nothing in this yet." />
|
|
110
|
+
<NavTreeSection title="Group c" loading />
|
|
111
|
+
</NavTree>,
|
|
112
|
+
)
|
|
113
|
+
expect(screen.getByRole('button', { name: 'Group a' })).not.toBeNull()
|
|
114
|
+
expect(screen.getByText('Item a')).not.toBeNull()
|
|
115
|
+
expect(screen.getByText('Nothing in this yet.')).not.toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('NavTree marks its groups', () => {
|
|
120
|
+
it('each group box carries data-nav-group with its id', () => {
|
|
121
|
+
const { container } = render(<NavTree groups={[{ id: 'g1', title: 'Group a', nodes: [] }]} emptyMessage="Nothing here yet." />)
|
|
122
|
+
expect(container.querySelector('[data-nav-group="g1"]')).not.toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('NavTree with no groups and no words', () => {
|
|
127
|
+
it('draws no line at all', () => {
|
|
128
|
+
const { container } = render(<NavTree groups={[]} />)
|
|
129
|
+
expect(container.textContent).toBe('')
|
|
130
|
+
})
|
|
131
|
+
})
|