@estiva-app/ui 0.50.4 → 0.52.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 (51) hide show
  1. package/dist/CollapsibleSection.d.ts +7 -1
  2. package/dist/CollapsibleSection.d.ts.map +1 -1
  3. package/dist/ComposerTriggers.d.ts +169 -0
  4. package/dist/ComposerTriggers.d.ts.map +1 -0
  5. package/dist/NavItem.d.ts +23 -3
  6. package/dist/NavItem.d.ts.map +1 -1
  7. package/dist/NavTree.d.ts +49 -0
  8. package/dist/NavTree.d.ts.map +1 -0
  9. package/dist/SectionLabel.d.ts +7 -1
  10. package/dist/SectionLabel.d.ts.map +1 -1
  11. package/dist/editor.d.ts +1 -0
  12. package/dist/editor.d.ts.map +1 -1
  13. package/dist/editor.js +313 -12
  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 +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +332 -212
  22. package/dist/index.js.map +4 -4
  23. package/dist/looks.d.ts +7 -0
  24. package/dist/looks.d.ts.map +1 -1
  25. package/package.json +6 -1
  26. package/registry.json +426 -5
  27. package/src/CollapsibleSection.mdx +6 -0
  28. package/src/CollapsibleSection.stories.tsx +5 -0
  29. package/src/CollapsibleSection.tsx +12 -4
  30. package/src/ComposerTriggers.mdx +108 -0
  31. package/src/ComposerTriggers.stories.tsx +79 -0
  32. package/src/ComposerTriggers.test.tsx +207 -0
  33. package/src/ComposerTriggers.tsx +507 -0
  34. package/src/NavItem.mdx +30 -0
  35. package/src/NavItem.stories.tsx +34 -0
  36. package/src/NavItem.test.tsx +82 -0
  37. package/src/NavItem.tsx +123 -25
  38. package/src/NavTree.mdx +83 -0
  39. package/src/NavTree.stories.tsx +84 -0
  40. package/src/NavTree.test.tsx +54 -0
  41. package/src/NavTree.tsx +112 -0
  42. package/src/SectionHeader.tsx +1 -1
  43. package/src/SectionLabel.mdx +4 -1
  44. package/src/SectionLabel.stories.tsx +3 -0
  45. package/src/SectionLabel.tsx +10 -2
  46. package/src/editor.ts +29 -0
  47. package/src/eslint/no-native-title.ts +1 -1
  48. package/src/index.ts +1 -0
  49. package/src/looks.ts +8 -0
  50. package/src/registry/registry.test.ts +9 -8
  51. package/stories/Choosing.mdx +2 -0
package/src/NavItem.tsx CHANGED
@@ -1,7 +1,8 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2
- import { IconDotsVertical } from '@tabler/icons-react'
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({ label, href, count, countLabel, active = false, icon, menu, menuLabel, className, ...props }: NavItemProps) {
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
- menu != null && !active && 'group-hover/nav:bg-bg-hover group-hover/nav:text-text-primary group-has-[[aria-expanded=true]]/nav:bg-bg-hover group-has-[[aria-expanded=true]]/nav:text-text-primary',
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 && <span className="flex shrink-0 items-center">{icon}</span>}
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 (menu == null) return row
152
+ if (!besides && hint == null) return row
88
153
  const name = menuLabel ?? `More options for ${label}`
89
- return (
154
+ const line = (
90
155
  <div className="group/nav relative shrink-0">
91
156
  {row}
92
- {/* `right-1`: the 24px button's 16px icon then ends 8px from the row's
93
- edge, where the count ends (px-2), so the ⋮ lands on the number. */}
94
- <div 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">
95
- <Menu
96
- align="right"
97
- trigger={
98
- <IconButton tooltip="More options" aria-label={name}>
99
- <IconDotsVertical size={16} stroke={1.5} />
100
- </IconButton>
101
- }
102
- >
103
- {menu}
104
- </Menu>
105
- </div>
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
  }
@@ -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
+ })
@@ -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
+ }
@@ -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
  ),
@@ -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. A colour
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' } }
@@ -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
- 'leaf:font-mono leaf:text-small leaf:uppercase leaf:tracking-widest',
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,
package/src/editor.ts CHANGED
@@ -9,6 +9,35 @@
9
9
  */
10
10
  export { KeptSelection, SelectionToolbar, normalizeHref, type MarkId, type SelectionToolbarProps } from './SelectionToolbar'
11
11
  export { suggestionPopup, isSuggestionActive, isSuggestionOpen, type SuggestionPopupOptions } from './suggestionPopup'
12
+ export {
13
+ PersonMention,
14
+ UrgentPersonMention,
15
+ CaptionedReference,
16
+ ReferenceTrigger,
17
+ SlashCommands,
18
+ peopleMenu,
19
+ referenceMenu,
20
+ filterPeople,
21
+ urgentPubkeys,
22
+ filterReferences,
23
+ formatSection,
24
+ applyFormat,
25
+ typeTrigger,
26
+ filterSlashSections,
27
+ FORMATS,
28
+ type MentionPerson,
29
+ type PersonMentionOptions,
30
+ type CaptionedItem,
31
+ type CaptionedReferenceOptions,
32
+ type ReferenceTriggerOptions,
33
+ type ReferenceMenuOptions,
34
+ type SlashCommand,
35
+ type SlashSection,
36
+ type SlashCommandsOptions,
37
+ type FormatId,
38
+ type FormatDef,
39
+ type CodeFormat,
40
+ } from './ComposerTriggers'
12
41
  export {
13
42
  BlockId,
14
43
  UnknownBlock,
@@ -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'