@estiva-app/ui 0.50.3 → 0.51.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/BlockNodes.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +7 -1
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/NavItem.d.ts +23 -3
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +49 -0
- package/dist/NavTree.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +7 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/editor.js +1 -1
- package/dist/editor.js.map +2 -2
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +332 -212
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +7 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +115 -5
- package/src/BlockNodes.test.tsx +43 -0
- package/src/BlockNodes.tsx +5 -2
- package/src/CollapsibleSection.mdx +6 -0
- package/src/CollapsibleSection.stories.tsx +5 -0
- package/src/CollapsibleSection.tsx +12 -4
- package/src/NavItem.mdx +30 -0
- package/src/NavItem.stories.tsx +34 -0
- package/src/NavItem.test.tsx +82 -0
- package/src/NavItem.tsx +123 -25
- package/src/NavTree.mdx +83 -0
- package/src/NavTree.stories.tsx +84 -0
- package/src/NavTree.test.tsx +54 -0
- package/src/NavTree.tsx +112 -0
- package/src/SectionHeader.tsx +1 -1
- package/src/SectionLabel.mdx +4 -1
- package/src/SectionLabel.stories.tsx +3 -0
- package/src/SectionLabel.tsx +10 -2
- package/src/eslint/no-native-title.ts +1 -1
- package/src/index.ts +1 -0
- package/src/looks.ts +8 -0
- package/src/registry/registry.test.ts +4 -4
- package/stories/Choosing.mdx +1 -0
package/src/NavItem.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
|
+
import { Collapsible } from '@base-ui/react/collapsible'
|
|
3
|
+
import { IconChevronRight, IconDotsVertical } from '@tabler/icons-react'
|
|
3
4
|
import { cn } from './cn'
|
|
4
|
-
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
5
|
+
import { COLLAPSIBLE_PANEL_CLASSES, SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_TEXT_CLASSES } from './looks'
|
|
5
6
|
import { IconButton } from './IconButton'
|
|
6
7
|
import { Menu } from './Menu'
|
|
7
8
|
import { WithTooltip } from './Tooltip'
|
|
@@ -22,8 +23,19 @@ import { WithTooltip } from './Tooltip'
|
|
|
22
23
|
* menu is open, in the count's place — the count steps aside rather than
|
|
23
24
|
* sitting under it. The ⋮ is beside the link, not inside it (a button inside
|
|
24
25
|
* an anchor is not a button), and follows it in the Tab order.
|
|
26
|
+
*
|
|
27
|
+
* `children` are rows under this one (Katerina, 2 October: Leaf's folder
|
|
28
|
+
* tree). The row stays a link; while it is pointed at or focused its icon
|
|
29
|
+
* gives way to an arrow that folds the rows, in the icon's own place — so a
|
|
30
|
+
* row with rows under it is as wide as one without. The arrow is beside the
|
|
31
|
+
* link, like the ⋮. The rows slide as `CollapsibleSection`'s do, indented
|
|
32
|
+
* past the icon so their icons start under this row's label.
|
|
33
|
+
*
|
|
34
|
+
* `hint` is shown only while the row is pointed at or focused, left of the
|
|
35
|
+
* count — a neutral `Chip`, typically. It takes its own room while it shows,
|
|
36
|
+
* so the label ends with "…" before it instead of running under it.
|
|
25
37
|
*/
|
|
26
|
-
export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
|
|
38
|
+
export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'children'> {
|
|
27
39
|
label: string
|
|
28
40
|
href: string
|
|
29
41
|
/** `0` or absent renders no counter. */
|
|
@@ -37,10 +49,47 @@ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'
|
|
|
37
49
|
menu?: ReactNode
|
|
38
50
|
/** The ⋮'s accessible name and tooltip. Default "More options for {label}". */
|
|
39
51
|
menuLabel?: string
|
|
52
|
+
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
53
|
+
hint?: ReactNode
|
|
54
|
+
/** Rows under this one, folding under it: `NavItem`s. While the row is pointed at or focused, its icon becomes the arrow. */
|
|
55
|
+
children?: ReactNode
|
|
56
|
+
/** With `children`: open unless told otherwise. */
|
|
57
|
+
defaultOpen?: boolean
|
|
58
|
+
/** With `children`: the caller owns the state. */
|
|
59
|
+
open?: boolean
|
|
60
|
+
onOpenChange?: (open: boolean) => void
|
|
40
61
|
className?: string
|
|
41
62
|
}
|
|
42
63
|
|
|
43
|
-
export function NavItem({
|
|
64
|
+
export function NavItem({
|
|
65
|
+
label,
|
|
66
|
+
href,
|
|
67
|
+
count,
|
|
68
|
+
countLabel,
|
|
69
|
+
active = false,
|
|
70
|
+
icon,
|
|
71
|
+
menu,
|
|
72
|
+
menuLabel,
|
|
73
|
+
hint,
|
|
74
|
+
children,
|
|
75
|
+
defaultOpen = true,
|
|
76
|
+
open: openProp,
|
|
77
|
+
onOpenChange,
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
}: NavItemProps) {
|
|
81
|
+
const [openState, setOpenState] = useState(defaultOpen)
|
|
82
|
+
const panelId = useId()
|
|
83
|
+
const hasRows = children != null && children !== false
|
|
84
|
+
const open = openProp ?? openState
|
|
85
|
+
const setOpen = (next: boolean) => {
|
|
86
|
+
setOpenState(next)
|
|
87
|
+
onOpenChange?.(next)
|
|
88
|
+
}
|
|
89
|
+
// The row draws beside it — the ⋮, the arrow — so the pointer can leave the
|
|
90
|
+
// link for them while the row still reads as the one it is on.
|
|
91
|
+
const besides = menu != null || hasRows
|
|
92
|
+
|
|
44
93
|
const row = (
|
|
45
94
|
<a
|
|
46
95
|
href={href}
|
|
@@ -53,14 +102,30 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
|
|
|
53
102
|
SIDEBAR_ROW_CLASSES,
|
|
54
103
|
active ? 'bg-bg-active text-text-primary' : cn(SIDEBAR_ROW_TEXT_CLASSES, 'hover:bg-bg-hover'),
|
|
55
104
|
// With a menu the ⋮ sits beside the link, so the row keeps its hover
|
|
56
|
-
// look while the ⋮ is pointed at and while its menu is open.
|
|
57
|
-
|
|
105
|
+
// look while the ⋮ is pointed at and while its menu is open. The same
|
|
106
|
+
// for the arrow.
|
|
107
|
+
besides && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary',
|
|
108
|
+
menu != null && !active && 'group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:bg-bg-hover group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:text-text-primary',
|
|
58
109
|
className,
|
|
59
110
|
)}
|
|
60
111
|
{...props}
|
|
61
112
|
>
|
|
62
|
-
{icon
|
|
113
|
+
{(icon || hasRows) && (
|
|
114
|
+
<span
|
|
115
|
+
className={cn(
|
|
116
|
+
// With rows under it the slot is kept even with no icon: the arrow lands in it.
|
|
117
|
+
'flex min-w-4 shrink-0 items-center',
|
|
118
|
+
hasRows && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0',
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{icon}
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
63
124
|
<span className="flex-1 truncate">{label}</span>
|
|
125
|
+
{hint != null && (
|
|
126
|
+
// Takes room only while it shows, so the label gives way to it.
|
|
127
|
+
<span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-within/nav:flex">{hint}</span>
|
|
128
|
+
)}
|
|
64
129
|
{count ? (
|
|
65
130
|
<WithTooltip label={countLabel ?? `${count} open`}>
|
|
66
131
|
{/* A 16px box, centred: the slot is an icon's width, so a number
|
|
@@ -71,7 +136,7 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
|
|
|
71
136
|
className={cn(
|
|
72
137
|
'min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted',
|
|
73
138
|
// With a menu, the ⋮ takes this place while it shows.
|
|
74
|
-
menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[aria-expanded=true]]/nav:opacity-0',
|
|
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',
|
|
75
140
|
)}
|
|
76
141
|
>
|
|
77
142
|
{count}
|
|
@@ -84,25 +149,58 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
|
|
|
84
149
|
) : null}
|
|
85
150
|
</a>
|
|
86
151
|
)
|
|
87
|
-
if (
|
|
152
|
+
if (!besides && hint == null) return row
|
|
88
153
|
const name = menuLabel ?? `More options for ${label}`
|
|
89
|
-
|
|
154
|
+
const line = (
|
|
90
155
|
<div className="group/nav relative shrink-0">
|
|
91
156
|
{row}
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
157
|
+
{hasRows && (
|
|
158
|
+
// `left-1`: the 24px button's 16px arrow then sits where the icon
|
|
159
|
+
// sits (the row's px-2), so the icon turns into the arrow in place.
|
|
160
|
+
<div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100">
|
|
161
|
+
<IconButton
|
|
162
|
+
tooltip={open ? 'Collapse' : 'Expand'}
|
|
163
|
+
aria-label={open ? `Hide what is under ${label}` : `Show what is under ${label}`}
|
|
164
|
+
aria-expanded={open}
|
|
165
|
+
aria-controls={panelId}
|
|
166
|
+
onClick={() => setOpen(!open)}
|
|
167
|
+
>
|
|
168
|
+
<IconChevronRight size={16} stroke={1.5} className={cn('transition-transform duration-150', open && 'rotate-90')} />
|
|
169
|
+
</IconButton>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
{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">
|
|
176
|
+
<Menu
|
|
177
|
+
align="right"
|
|
178
|
+
trigger={
|
|
179
|
+
<IconButton tooltip="More options" aria-label={name}>
|
|
180
|
+
<IconDotsVertical size={16} stroke={1.5} />
|
|
181
|
+
</IconButton>
|
|
182
|
+
}
|
|
183
|
+
>
|
|
184
|
+
{menu}
|
|
185
|
+
</Menu>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
106
188
|
</div>
|
|
107
189
|
)
|
|
190
|
+
if (!hasRows) return line
|
|
191
|
+
return (
|
|
192
|
+
<Collapsible.Root open={open} onOpenChange={setOpen} className="flex shrink-0 flex-col">
|
|
193
|
+
{line}
|
|
194
|
+
{/* CollapsibleSection's slide, the same way: Base UI measures the panel
|
|
195
|
+
and the height transitions, none under prefers-reduced-motion. */}
|
|
196
|
+
<Collapsible.Panel
|
|
197
|
+
id={panelId}
|
|
198
|
+
hiddenUntilFound
|
|
199
|
+
className={COLLAPSIBLE_PANEL_CLASSES}
|
|
200
|
+
>
|
|
201
|
+
{/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
|
|
202
|
+
<div className="flex flex-col gap-px pl-6">{children}</div>
|
|
203
|
+
</Collapsible.Panel>
|
|
204
|
+
</Collapsible.Root>
|
|
205
|
+
)
|
|
108
206
|
}
|
package/src/NavTree.mdx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as NavTreeStories from './NavTree.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={NavTreeStories} />
|
|
5
|
+
|
|
6
|
+
# NavTree
|
|
7
|
+
|
|
8
|
+
A sidebar's tree from one nested list: a quiet label, groups that fold
|
|
9
|
+
under the standard heading with their rows lightly indented, and rows under
|
|
10
|
+
rows to any depth.
|
|
11
|
+
|
|
12
|
+
<Canvas of={NavTreeStories.Default} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- Places that nest, in a **Sidebar**: groups, rows in them, rows under
|
|
17
|
+
those rows. Hand it the whole list; it draws every level.
|
|
18
|
+
- `selected` is the current row: it is `active`, and only its group starts
|
|
19
|
+
open. Rows with rows under them start open.
|
|
20
|
+
|
|
21
|
+
<Canvas of={NavTreeStories.CurrentDeep} />
|
|
22
|
+
|
|
23
|
+
- A row's `hint` (a neutral **Chip**), `count` and `menu` are **NavItem**'s,
|
|
24
|
+
passed through.
|
|
25
|
+
- `groups={null}` while the first read is on its way draws a skeleton;
|
|
26
|
+
`[]` draws `emptyMessage`, level with the rows.
|
|
27
|
+
|
|
28
|
+
<Canvas of={NavTreeStories.Loading} />
|
|
29
|
+
|
|
30
|
+
## When not
|
|
31
|
+
|
|
32
|
+
- A flat list of places → **NavItem** rows in the **Sidebar**.
|
|
33
|
+
- One group that folds, written by hand → **CollapsibleSection**.
|
|
34
|
+
- A list beside the thing that is open → **ListColumn**.
|
|
35
|
+
|
|
36
|
+
## How
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { NavTree, Sidebar } from '@estiva-app/ui'
|
|
40
|
+
|
|
41
|
+
<Sidebar>
|
|
42
|
+
<NavTree
|
|
43
|
+
title="Groups"
|
|
44
|
+
groups={groups}
|
|
45
|
+
selected={currentId}
|
|
46
|
+
onSelect={(node, event) => {
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
navigate(node.href)
|
|
49
|
+
}}
|
|
50
|
+
emptyMessage="Nothing here yet."
|
|
51
|
+
storageKey="app.sidebar.groups"
|
|
52
|
+
/>
|
|
53
|
+
</Sidebar>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Each row is a **NavItem**, so a row is a link: a router app prevents the
|
|
57
|
+
default in `onSelect` and navigates.
|
|
58
|
+
- `storageKey` remembers each group open or closed in this browser, under
|
|
59
|
+
the key and the group's id. The rows under a row are not remembered yet.
|
|
60
|
+
- The words in `emptyMessage` are the caller's.
|
|
61
|
+
|
|
62
|
+
## What it owns
|
|
63
|
+
|
|
64
|
+
It does these by itself. Write none of them around it.
|
|
65
|
+
|
|
66
|
+
| It does | So you never write | Refused when written by hand |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| Draws every level from one list, rows under rows | a recursive render of **NavItem**s and **CollapsibleSection**s | |
|
|
69
|
+
| Opens the group that holds the current row | a search through the list for the current row | |
|
|
70
|
+
| Says it is loading, or empty, in the rows' place | a skeleton or an **EmptyState** placed by hand | |
|
|
71
|
+
|
|
72
|
+
## Keys
|
|
73
|
+
|
|
74
|
+
| Key | Does |
|
|
75
|
+
|---|---|
|
|
76
|
+
| Tab | each group's title, then its rows: each row's link, then its arrow |
|
|
77
|
+
| Enter, Space | on a group's title or a row's arrow: folds or unfolds |
|
|
78
|
+
|
|
79
|
+
It is not an ARIA tree yet: there are no arrow keys between rows.
|
|
80
|
+
|
|
81
|
+
## Props
|
|
82
|
+
|
|
83
|
+
<Controls of={NavTreeStories.Default} />
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Chip } from './Chip'
|
|
5
|
+
import { NavItem } from './NavItem'
|
|
6
|
+
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
7
|
+
import { Sidebar } from './Sidebar'
|
|
8
|
+
|
|
9
|
+
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
10
|
+
const hint = <Chip type="neutral" label="Label" />
|
|
11
|
+
|
|
12
|
+
/** Three groups; the open one holds rows under rows, hints and counts, and long labels to see what fits. */
|
|
13
|
+
const GROUPS: NavTreeGroup[] = [
|
|
14
|
+
{ id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item two', href: '#', icon: placeholder }] },
|
|
15
|
+
{
|
|
16
|
+
id: 'g2',
|
|
17
|
+
title: 'A group with a longer name',
|
|
18
|
+
nodes: [
|
|
19
|
+
{ id: 'b', label: 'Item three', href: '#', icon: placeholder, count: 1, countLabel: '1 open' },
|
|
20
|
+
{
|
|
21
|
+
id: 'c',
|
|
22
|
+
label: 'An item whose label runs longer than the column',
|
|
23
|
+
href: '#',
|
|
24
|
+
icon: placeholder,
|
|
25
|
+
children: [
|
|
26
|
+
{ id: 'c1', label: 'An item one level in, also long', href: '#', icon: placeholder, count: 2, countLabel: '2 open' },
|
|
27
|
+
{ id: 'c2', label: 'Item four', href: '#', icon: placeholder, children: [{ id: 'c21', label: 'An item two levels in, long too', href: '#', icon: placeholder }] },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{ id: 'd', label: 'An item with a hint and a long label', href: '#', icon: placeholder, hint, count: 3, countLabel: '3 open' },
|
|
31
|
+
{ id: 'e', label: 'Item five', href: '#', icon: placeholder, hint },
|
|
32
|
+
{ id: 'f', label: 'An item with a hint and a count', href: '#', icon: placeholder, hint, count: 12, countLabel: '12 open' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{ id: 'g3', title: 'Group three', nodes: [{ id: 'g', label: 'Item six', href: '#', icon: placeholder }] },
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const meta = {
|
|
39
|
+
title: 'Frame/NavTree',
|
|
40
|
+
component: NavTree,
|
|
41
|
+
parameters: { layout: 'fullscreen' },
|
|
42
|
+
args: { title: 'Groups', groups: GROUPS, selected: 'b', emptyMessage: 'Nothing here yet.' },
|
|
43
|
+
argTypes: { groups: { control: false } },
|
|
44
|
+
// In the Sidebar, under a first row, as it sits in an app; a click marks the row it was on.
|
|
45
|
+
render: function Render(args) {
|
|
46
|
+
const [selected, setSelected] = useState(args.selected)
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex h-screen bg-bg-base">
|
|
49
|
+
<Sidebar>
|
|
50
|
+
<NavItem href="#" label="Item one" icon={placeholder} />
|
|
51
|
+
<NavTree
|
|
52
|
+
{...args}
|
|
53
|
+
selected={selected}
|
|
54
|
+
onSelect={(node, event) => {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
setSelected(node.id)
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</Sidebar>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
} satisfies Meta<typeof NavTree>
|
|
64
|
+
|
|
65
|
+
export default meta
|
|
66
|
+
type Story = StoryObj<typeof meta>
|
|
67
|
+
|
|
68
|
+
/** Only the group holding the current row starts open. Point at a row with rows under it: its icon becomes the arrow. Point at a row with a hint: the chip shows. */
|
|
69
|
+
export const Default: Story = {}
|
|
70
|
+
|
|
71
|
+
/** The current row is two levels in: its group opens, and the rows above it are open too. */
|
|
72
|
+
export const CurrentDeep: Story = { args: { selected: 'c21' } }
|
|
73
|
+
|
|
74
|
+
/** No quiet label over the groups. */
|
|
75
|
+
export const NoTitle: Story = { args: { title: undefined } }
|
|
76
|
+
|
|
77
|
+
/** The first read is on its way. */
|
|
78
|
+
export const Loading: Story = { args: { groups: null } }
|
|
79
|
+
|
|
80
|
+
/** No groups: the caller's words, level with the rows. */
|
|
81
|
+
export const Empty: Story = { args: { groups: [] } }
|
|
82
|
+
|
|
83
|
+
/** Close a group and reload the page: still closed. This browser remembers each group, under the key. */
|
|
84
|
+
export const Remembered: Story = { args: { storageKey: 'estiva-ui.stories.nav-tree' } }
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the page claims: every level is drawn from the one list; only the
|
|
4
|
+
* group holding the current row starts open, and the current row is active;
|
|
5
|
+
* a click reaches onSelect with its node; loading and empty say so.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
9
|
+
import userEvent from '@testing-library/user-event'
|
|
10
|
+
import { NavTree, type NavTreeGroup } from './NavTree'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
const GROUPS: NavTreeGroup[] = [
|
|
15
|
+
{ id: 'g1', title: 'Group one', nodes: [{ id: 'a', label: 'Item a', href: '#a' }] },
|
|
16
|
+
{
|
|
17
|
+
id: 'g2',
|
|
18
|
+
title: 'Group two',
|
|
19
|
+
nodes: [{ id: 'b', label: 'Item b', href: '#b', children: [{ id: 'c', label: 'Item c', href: '#c', children: [{ id: 'd', label: 'Item d', href: '#d' }] }] }],
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const hidden = (text: string) => screen.getByText(text).closest('[hidden]') !== null
|
|
24
|
+
|
|
25
|
+
describe('NavTree', () => {
|
|
26
|
+
it('draws every level, and opens only the group holding the current row', () => {
|
|
27
|
+
render(<NavTree groups={GROUPS} selected="d" emptyMessage="Nothing here yet." />)
|
|
28
|
+
expect(hidden('Item a')).toBe(true)
|
|
29
|
+
expect(hidden('Item d')).toBe(false)
|
|
30
|
+
expect(screen.getByRole('link', { name: 'Item d' }).getAttribute('aria-current')).toBe('page')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a click reaches onSelect with the node it was on', async () => {
|
|
34
|
+
const onSelect = vi.fn((_node, event: { preventDefault: () => void }) => event.preventDefault())
|
|
35
|
+
render(<NavTree groups={GROUPS} selected="b" onSelect={onSelect} emptyMessage="Nothing here yet." />)
|
|
36
|
+
await userEvent.click(screen.getByRole('link', { name: 'Item c' }))
|
|
37
|
+
expect(onSelect.mock.calls[0][0].id).toBe('c')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('draws the quiet label when given one', () => {
|
|
41
|
+
render(<NavTree title="Groups" groups={GROUPS} emptyMessage="Nothing here yet." />)
|
|
42
|
+
expect(screen.getByText('Groups')).not.toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('says it is empty in the caller’s words', () => {
|
|
46
|
+
render(<NavTree groups={[]} emptyMessage="Nothing here yet." />)
|
|
47
|
+
expect(screen.getByText('Nothing here yet.')).not.toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('draws no rows while loading', () => {
|
|
51
|
+
render(<NavTree groups={null} emptyMessage="Nothing here yet." />)
|
|
52
|
+
expect(screen.queryAllByRole('link')).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/NavTree.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { MouseEvent, ReactNode } from 'react'
|
|
2
|
+
import { CollapsibleSection } from './CollapsibleSection'
|
|
3
|
+
import { EmptyState } from './EmptyState'
|
|
4
|
+
import { NavItem } from './NavItem'
|
|
5
|
+
import { SectionHeader } from './SectionHeader'
|
|
6
|
+
import { SkeletonList } from './Skeleton'
|
|
7
|
+
|
|
8
|
+
/** One row of the tree, and the rows under it. */
|
|
9
|
+
export interface NavTreeNode {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
href: string
|
|
13
|
+
/** 16px, stroke 1.5. */
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
/** `0` or absent renders no counter. */
|
|
16
|
+
count?: number
|
|
17
|
+
/** What the count counts, for the tooltip. */
|
|
18
|
+
countLabel?: string
|
|
19
|
+
/** Shown only while the row is pointed at or focused, left of the count: a neutral `Chip`, typically. */
|
|
20
|
+
hint?: ReactNode
|
|
21
|
+
/** The row's "More options" menu: `MenuItem`s. */
|
|
22
|
+
menu?: ReactNode
|
|
23
|
+
/** Rows under this one. Their row folds them; open unless `defaultOpen` says otherwise. */
|
|
24
|
+
children?: NavTreeNode[]
|
|
25
|
+
defaultOpen?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A group heading over its rows: the top of the tree. */
|
|
29
|
+
export interface NavTreeGroup {
|
|
30
|
+
id: string
|
|
31
|
+
title: string
|
|
32
|
+
nodes: NavTreeNode[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NavTreeProps {
|
|
36
|
+
/** The quiet label over the groups. Absent, none is drawn. */
|
|
37
|
+
title?: string
|
|
38
|
+
/** `null` while the first read is on its way: a skeleton in the rows' place. */
|
|
39
|
+
groups: NavTreeGroup[] | null
|
|
40
|
+
/** The row that is current: `active`, and its group starts open. */
|
|
41
|
+
selected?: string
|
|
42
|
+
/** A row was clicked. A router app prevents the default and navigates. */
|
|
43
|
+
onSelect?: (node: NavTreeNode, event: MouseEvent<HTMLAnchorElement>) => void
|
|
44
|
+
/** What to say when there are no groups. The words are the caller's. */
|
|
45
|
+
emptyMessage: string
|
|
46
|
+
/** Remember each group open or closed in this browser, under this key and the group's id. The app prefixes it. */
|
|
47
|
+
storageKey?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function holds(nodes: NavTreeNode[], id: string | undefined): boolean {
|
|
51
|
+
return id !== undefined && nodes.some((node) => node.id === id || holds(node.children ?? [], id))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Row({ node, selected, onSelect }: { node: NavTreeNode } & Pick<NavTreeProps, 'selected' | 'onSelect'>) {
|
|
55
|
+
return (
|
|
56
|
+
<NavItem
|
|
57
|
+
href={node.href}
|
|
58
|
+
label={node.label}
|
|
59
|
+
icon={node.icon}
|
|
60
|
+
count={node.count}
|
|
61
|
+
countLabel={node.countLabel}
|
|
62
|
+
hint={node.hint}
|
|
63
|
+
menu={node.menu}
|
|
64
|
+
active={node.id === selected}
|
|
65
|
+
defaultOpen={node.defaultOpen ?? true}
|
|
66
|
+
onClick={onSelect ? (event) => onSelect(node, event) : undefined}
|
|
67
|
+
>
|
|
68
|
+
{node.children?.length
|
|
69
|
+
? node.children.map((child) => <Row key={child.id} node={child} selected={selected} onSelect={onSelect} />)
|
|
70
|
+
: undefined}
|
|
71
|
+
</NavItem>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A sidebar's tree, from one nested list (Katerina, 2 October: Leaf's folder
|
|
77
|
+
* tree): a quiet label, then each group as the standard heading folding its
|
|
78
|
+
* rows, lightly indented under its title; rows under rows to any depth, each
|
|
79
|
+
* row a link whose icon becomes the arrow that folds what is under it. Only
|
|
80
|
+
* the group that holds the current row starts open.
|
|
81
|
+
*/
|
|
82
|
+
export function NavTree({ title, groups, selected, onSelect, emptyMessage, storageKey }: NavTreeProps) {
|
|
83
|
+
return (
|
|
84
|
+
<>
|
|
85
|
+
{title && <SectionHeader title={title} look="quiet" className="mt-4 shrink-0" />}
|
|
86
|
+
{groups === null ? (
|
|
87
|
+
<SkeletonList rows={5} />
|
|
88
|
+
) : groups.length === 0 ? (
|
|
89
|
+
// Level with the rows: a row pads itself, so the line takes a row's inset (Katerina, 14 September).
|
|
90
|
+
<div className="px-2 py-1">
|
|
91
|
+
<EmptyState scope="section" message={emptyMessage} />
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
groups.map((group) => (
|
|
95
|
+
<CollapsibleSection
|
|
96
|
+
key={group.id}
|
|
97
|
+
title={group.title}
|
|
98
|
+
indent
|
|
99
|
+
defaultOpen={holds(group.nodes, selected)}
|
|
100
|
+
storageKey={storageKey ? `${storageKey}.${group.id}` : undefined}
|
|
101
|
+
className="mt-2 shrink-0"
|
|
102
|
+
contentClassName="gap-px"
|
|
103
|
+
>
|
|
104
|
+
{group.nodes.map((node) => (
|
|
105
|
+
<Row key={node.id} node={node} selected={selected} onSelect={onSelect} />
|
|
106
|
+
))}
|
|
107
|
+
</CollapsibleSection>
|
|
108
|
+
))
|
|
109
|
+
)}
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -106,7 +106,7 @@ export function SectionHeader({ title, chevron = false, isExpanded = true, onTog
|
|
|
106
106
|
// A row's words: NavItem's size and colour, brightening under the pointer with the row.
|
|
107
107
|
<span className="min-w-0 flex-1 truncate text-body-2">{title}</span>
|
|
108
108
|
) : (
|
|
109
|
-
<SectionLabel>{title}</SectionLabel>
|
|
109
|
+
<SectionLabel folds={chevron}>{title}</SectionLabel>
|
|
110
110
|
)}
|
|
111
111
|
</>
|
|
112
112
|
),
|
package/src/SectionLabel.mdx
CHANGED
|
@@ -40,7 +40,10 @@ It is a bare span: the caller owns the row it sits in and the spacing
|
|
|
40
40
|
around it. Read it `tone="secondary"` where it labels rather than leads — menu
|
|
41
41
|
headings and the command palette's groups do. `tone="muted"` where it marks a
|
|
42
42
|
place in a list rather than heading it — a date between messages. `truncate`
|
|
43
|
-
cuts a long label short with an ellipsis where its row has no room.
|
|
43
|
+
cuts a long label short with an ellipsis where its row has no room. `folds`
|
|
44
|
+
is the title of a group that opens and closes: **SectionHeader** passes it
|
|
45
|
+
whenever it draws the chevron, so in Leaf a folding group keeps Ship's type
|
|
46
|
+
while every other label takes Signal's. A colour
|
|
44
47
|
passed in `className` is refused by the lint.
|
|
45
48
|
|
|
46
49
|
- Import it — never copy its classes. A copied class list drifts the first time
|
|
@@ -45,3 +45,6 @@ export const Truncated: Story = {
|
|
|
45
45
|
</div>
|
|
46
46
|
),
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** The title of a group that folds: in Leaf it keeps Ship's type; in Signal and Ship it looks like any label. Switch the toolbar to Leaf to see the difference. */
|
|
50
|
+
export const Folds: Story = { args: { folds: true, children: 'Group' } }
|
package/src/SectionLabel.tsx
CHANGED
|
@@ -18,6 +18,10 @@ import { cn } from './cn'
|
|
|
18
18
|
* long label short with an ellipsis where its row has no room, as a date line in
|
|
19
19
|
* a narrow panel must.
|
|
20
20
|
*
|
|
21
|
+
* `folds` is the title of a group that opens and closes: in Leaf it keeps
|
|
22
|
+
* Ship's type rather than taking Signal's micro-label, so a folder reads as
|
|
23
|
+
* a name (Katerina, 2 October). Every other Leaf label keeps Signal's.
|
|
24
|
+
*
|
|
21
25
|
* Deliberately NOT the same thing as Property's stacked field label (the
|
|
22
26
|
* 9px `menu`-token one): they were merged for a day and unmerged by
|
|
23
27
|
* Katerina's ruling (2026-09-01) — a section title and a field label are
|
|
@@ -27,20 +31,24 @@ export function SectionLabel({
|
|
|
27
31
|
children,
|
|
28
32
|
tone = 'primary',
|
|
29
33
|
truncate = false,
|
|
34
|
+
folds = false,
|
|
30
35
|
className,
|
|
31
36
|
}: {
|
|
32
37
|
children: ReactNode
|
|
33
38
|
tone?: 'primary' | 'secondary' | 'muted'
|
|
34
39
|
/** Cut a long label short with an ellipsis instead of letting it wrap or spill. */
|
|
35
40
|
truncate?: boolean
|
|
41
|
+
/** The title of a group that opens and closes. In Leaf, Ship's type instead of the micro-label. */
|
|
42
|
+
folds?: boolean
|
|
36
43
|
className?: string
|
|
37
44
|
}) {
|
|
38
45
|
return (
|
|
39
46
|
<span
|
|
40
47
|
className={cn(
|
|
41
48
|
'text-h5 leading-3 signal:font-mono signal:text-small signal:uppercase signal:tracking-widest',
|
|
42
|
-
// Leaf's section labels are Signal's type (Katerina, 28 September)
|
|
43
|
-
|
|
49
|
+
// Leaf's section labels are Signal's type (Katerina, 28 September),
|
|
50
|
+
// but a group that folds keeps Ship's (Katerina, 2 October).
|
|
51
|
+
!folds && 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
|
|
44
52
|
tone === 'secondary' ? 'text-text-secondary' : tone === 'muted' ? 'text-text-muted' : 'text-text-primary',
|
|
45
53
|
truncate && 'min-w-0 truncate',
|
|
46
54
|
className,
|
|
@@ -8,7 +8,7 @@ import { bindingOfTag, partsOfFile, type Node, type Parser, type PartBinding } f
|
|
|
8
8
|
* the browser shows it as its tooltip. `no-native-title.test.ts` holds this
|
|
9
9
|
* list to the catalogue: a part that gains a `title` of its own joins it.
|
|
10
10
|
*/
|
|
11
|
-
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'Panel', 'SectionHeader'] as const
|
|
11
|
+
export const PARTS_WITH_A_TITLE = ['CollapsibleSection', 'ConfirmDialog', 'ContainerHeader', 'DialogShell', 'ListColumn', 'NavTree', 'Panel', 'SectionHeader'] as const
|
|
12
12
|
|
|
13
13
|
const child = (node: Node, key: string) => node[key] as Node | null | undefined
|
|
14
14
|
const nameOf = (node: Node | null | undefined): string | undefined =>
|
package/src/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ export { SkeletonBar, SkeletonList, SkeletonRow } from './Skeleton'
|
|
|
70
70
|
export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
|
|
71
71
|
export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
|
|
72
72
|
export { NavItem, type NavItemProps } from './NavItem'
|
|
73
|
+
export { NavTree, type NavTreeGroup, type NavTreeNode, type NavTreeProps } from './NavTree'
|
|
73
74
|
export { Popover, type PopoverProps } from './Popover'
|
|
74
75
|
export { SuggestionMenu, type SuggestionMenuHandle, type SuggestionMenuProps, type SuggestionMenuRow, type SuggestionMenuSection } from './SuggestionMenu'
|
|
75
76
|
export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
|
package/src/looks.ts
CHANGED
|
@@ -46,3 +46,11 @@ export const BARE_INPUT_CLASSES = 'bg-transparent text-text-primary placeholder:
|
|
|
46
46
|
/** A sidebar row's shape and its words at rest, brightening under the pointer: NavItem, and SectionHeader's `row` (a folder among rows). */
|
|
47
47
|
export const SIDEBAR_ROW_CLASSES = 'rounded-md transition-colors'
|
|
48
48
|
export const SIDEBAR_ROW_TEXT_CLASSES = 'text-text-secondary hover:text-text-primary'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A panel that slides open and shut on Base UI's Collapsible: Base UI measures
|
|
52
|
+
* it and writes its height to a variable, `auto` again once the slide ends, and
|
|
53
|
+
* the panel is 0 high on its opening and closing frames. None under
|
|
54
|
+
* prefers-reduced-motion. CollapsibleSection's rows, and the rows under a NavItem.
|
|
55
|
+
*/
|
|
56
|
+
export const COLLAPSIBLE_PANEL_CLASSES = 'h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none data-[starting-style]:h-0 data-[ending-style]:h-0'
|
|
@@ -48,18 +48,18 @@ describe('the registry builds', () => {
|
|
|
48
48
|
expect(registry.entries.map((one) => one.name).sort()).toEqual(values.map((one) => one.name).sort())
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
it('counts
|
|
51
|
+
it('counts 105 names over 65 files: 91 components, 13 helpers and 1 hook (NavTree joined on 2 October)', () => {
|
|
52
52
|
// The reconciliation GATES.md §24 explains. If this changes, that changes.
|
|
53
53
|
// The five `EDITOR_EXTENSIONS` count as components by their names; they are
|
|
54
54
|
// editor extensions, documented on SelectionToolbar's and BlockNodes' pages.
|
|
55
55
|
const kinds = registry.entries.reduce<Record<string, number>>((all, one) => ({ ...all, [one.kind]: (all[one.kind] ?? 0) + 1 }), {})
|
|
56
|
-
expect(kinds).toEqual({ component:
|
|
57
|
-
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(
|
|
56
|
+
expect(kinds).toEqual({ component: 91, helper: 13, hook: 1 })
|
|
57
|
+
expect(new Set(registry.entries.map((one) => one.sourceFile)).size).toBe(65)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
it('gives every entry a purpose, from its own page or from the comment above it', () => {
|
|
61
61
|
expect(registry.entries.filter((one) => one.purpose.trim() === '')).toEqual([])
|
|
62
|
-
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(
|
|
62
|
+
expect(registry.entries.filter((one) => one.purposeFrom === 'page').length).toBe(62)
|
|
63
63
|
expect(registry.entries.filter((one) => one.purposeFrom === 'comment').length).toBe(43)
|
|
64
64
|
})
|
|
65
65
|
|