@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.
Files changed (54) hide show
  1. package/dist/BlockHandle.d.ts +47 -0
  2. package/dist/BlockHandle.d.ts.map +1 -0
  3. package/dist/EditorBlockHandle.d.ts +57 -0
  4. package/dist/EditorBlockHandle.d.ts.map +1 -0
  5. package/dist/NavItem.d.ts +14 -1
  6. package/dist/NavItem.d.ts.map +1 -1
  7. package/dist/NavTree.d.ts +93 -13
  8. package/dist/NavTree.d.ts.map +1 -1
  9. package/dist/UnreadDot.d.ts +12 -0
  10. package/dist/UnreadDot.d.ts.map +1 -0
  11. package/dist/editor.d.ts +1 -0
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +127 -11
  14. package/dist/editor.js.map +4 -4
  15. package/dist/eslint/index.js +1 -1
  16. package/dist/eslint/index.js.map +2 -2
  17. package/dist/eslint/no-native-title.d.ts +1 -1
  18. package/dist/eslint/no-native-title.d.ts.map +1 -1
  19. package/dist/index.d.ts +3 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +801 -584
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +1 -1
  24. package/dist/looks.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/registry.json +362 -11
  27. package/src/BlockHandle.mdx +58 -0
  28. package/src/BlockHandle.stories.tsx +44 -0
  29. package/src/BlockHandle.test.tsx +216 -0
  30. package/src/BlockHandle.tsx +220 -0
  31. package/src/EditorBlockHandle.mdx +65 -0
  32. package/src/EditorBlockHandle.stories.tsx +81 -0
  33. package/src/EditorBlockHandle.tsx +181 -0
  34. package/src/NavItem.mdx +14 -1
  35. package/src/NavItem.stories.tsx +15 -0
  36. package/src/NavItem.test.tsx +31 -0
  37. package/src/NavItem.tsx +49 -12
  38. package/src/NavTree.mdx +25 -0
  39. package/src/NavTree.stories.tsx +57 -1
  40. package/src/NavTree.test.tsx +78 -1
  41. package/src/NavTree.tsx +175 -40
  42. package/src/SectionLabel.stories.tsx +2 -3
  43. package/src/UnreadDot.mdx +57 -0
  44. package/src/UnreadDot.stories.tsx +22 -0
  45. package/src/UnreadDot.test.tsx +17 -0
  46. package/src/UnreadDot.tsx +17 -0
  47. package/src/editor.ts +1 -0
  48. package/src/eslint/no-native-title.ts +1 -1
  49. package/src/index.ts +3 -1
  50. package/src/looks.ts +2 -1
  51. package/src/registry/registry.test.ts +5 -5
  52. package/stories/Choosing.mdx +3 -0
  53. package/tailwind-preset.js +1 -0
  54. 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, 6px corners.
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
 
@@ -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
+ }
@@ -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="flex-1 truncate">{label}</span>
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
- 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',
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
- ) : menu != null ? (
146
- // No count, but a menu: the count's place is kept free, so a long
147
- // label truncates before the ⋮ instead of running under it.
148
- <span aria-hidden="true" className="min-w-4 shrink-0" />
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 from the row's
174
- // edge, where the count ends (px-2), so the ⋮ lands on the number.
175
- <div data-nav-menu className="absolute inset-y-0 right-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100">
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
 
@@ -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
+ }
@@ -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
+ })