@estiva-app/ui 0.55.0 → 0.57.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 (65) hide show
  1. package/dist/ChipInput.d.ts.map +1 -1
  2. package/dist/CollapsibleSection.d.ts +8 -2
  3. package/dist/CollapsibleSection.d.ts.map +1 -1
  4. package/dist/CommandPalette.d.ts.map +1 -1
  5. package/dist/ContainerHeader.d.ts +7 -1
  6. package/dist/ContainerHeader.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/ListColumn.d.ts +2 -0
  9. package/dist/ListColumn.d.ts.map +1 -1
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/NavItem.d.ts.map +1 -1
  12. package/dist/NavTree.d.ts +11 -3
  13. package/dist/NavTree.d.ts.map +1 -1
  14. package/dist/PersonTrigger.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +24 -5
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/SectionLabel.d.ts.map +1 -1
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/SuggestionMenu.d.ts.map +1 -1
  20. package/dist/gates/index.js +1 -1
  21. package/dist/gates/index.js.map +2 -2
  22. package/dist/index.js +138 -77
  23. package/dist/index.js.map +4 -4
  24. package/dist/looks.d.ts +4 -0
  25. package/dist/looks.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/registry.json +98 -26
  28. package/src/ChipInput.test.tsx +8 -0
  29. package/src/ChipInput.tsx +4 -2
  30. package/src/CollapsibleSection.mdx +1 -1
  31. package/src/CollapsibleSection.stories.tsx +14 -1
  32. package/src/CollapsibleSection.test.tsx +24 -0
  33. package/src/CollapsibleSection.tsx +11 -5
  34. package/src/CommandPalette.test.tsx +6 -0
  35. package/src/CommandPalette.tsx +3 -2
  36. package/src/ContainerHeader.mdx +4 -0
  37. package/src/ContainerHeader.stories.tsx +14 -0
  38. package/src/ContainerHeader.test.tsx +8 -0
  39. package/src/ContainerHeader.tsx +34 -6
  40. package/src/IconButton.tsx +2 -1
  41. package/src/ListColumn.test.tsx +8 -0
  42. package/src/ListColumn.tsx +4 -2
  43. package/src/Menu.tsx +5 -4
  44. package/src/NavItem.mdx +3 -1
  45. package/src/NavItem.test.tsx +32 -2
  46. package/src/NavItem.tsx +28 -12
  47. package/src/NavTree.mdx +16 -1
  48. package/src/NavTree.stories.tsx +49 -2
  49. package/src/NavTree.test.tsx +28 -0
  50. package/src/NavTree.tsx +27 -6
  51. package/src/PersonTrigger.tsx +6 -5
  52. package/src/SectionHeader.mdx +10 -3
  53. package/src/SectionHeader.stories.tsx +18 -0
  54. package/src/SectionHeader.test.tsx +89 -0
  55. package/src/SectionHeader.tsx +114 -26
  56. package/src/SectionLabel.tsx +6 -3
  57. package/src/Select.test.tsx +8 -0
  58. package/src/Select.tsx +5 -2
  59. package/src/Sidebar.tsx +1 -1
  60. package/src/SuggestionMenu.stories.tsx +15 -0
  61. package/src/SuggestionMenu.test.tsx +8 -0
  62. package/src/SuggestionMenu.tsx +4 -2
  63. package/src/eslint/no-copied-look.test.ts +2 -2
  64. package/src/gates/app-checks.ts +1 -1
  65. package/src/looks.ts +6 -0
package/src/Menu.tsx CHANGED
@@ -95,7 +95,8 @@ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
95
95
  return (
96
96
  <div
97
97
  className={cn(
98
- 'flex flex-col p-2',
98
+ // 2px between rows, as a list column's and a sidebar's (Katerina, 5 October).
99
+ 'flex flex-col gap-0.5 p-2',
99
100
  FLOATING_SURFACE_CLASSES,
100
101
  /*
101
102
  * A divider in a menu runs the width of the rows it separates.
@@ -248,7 +249,7 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
248
249
  * sentence was written at 0.12.6 and the prop was not: a `p-1` on
249
250
  * `className` added 4px to these 8px instead of replacing them
250
251
  * (PLAN Finding 60). The prop exists now. */}
251
- <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2', contentClassName)}>
252
+ <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col gap-0.5 p-2', contentClassName)}>
252
253
  <MenuContext.Provider value={{ openOnHover }}>
253
254
  <DividerInPanel.Provider value={true}>{children}</DividerInPanel.Provider>
254
255
  </MenuContext.Provider>
@@ -578,14 +579,14 @@ export function MenuSection({ label, children, className }: { label: string; chi
578
579
  )
579
580
  if (!inMenu) {
580
581
  return (
581
- <div className="flex flex-col">
582
+ <div className="flex flex-col gap-0.5">
582
583
  {heading}
583
584
  {children}
584
585
  </div>
585
586
  )
586
587
  }
587
588
  return (
588
- <BaseMenu.Group className="flex flex-col">
589
+ <BaseMenu.Group className="flex flex-col gap-0.5">
589
590
  <BaseMenu.GroupLabel render={heading} />
590
591
  {children}
591
592
  </BaseMenu.Group>
package/src/NavItem.mdx CHANGED
@@ -24,7 +24,9 @@ count — a muted mono number whose tooltip says what it counts.
24
24
 
25
25
  - A row with things to do to it — move it, rename it — takes them as
26
26
  `menu`: pass `MenuItem`s, and the row draws the ⋮, shows it while the row
27
- is pointed at or focused, and moves the count aside for it.
27
+ is pointed at or keyboard-focused, and moves the count aside for it. On a
28
+ screen with no hover the ⋮ always shows, and the count or dot under it
29
+ stays aside: the row reads as it does under a pointer.
28
30
 
29
31
  <Canvas of={NavItemStories.WithMenu} />
30
32
 
@@ -94,15 +94,45 @@ describe('NavItem unread', () => {
94
94
  expect(container.querySelector('[data-unread]')).toBeNull()
95
95
  })
96
96
 
97
+ it('the dot and the badge are pulled 4px right (-mr-1), onto the axis of a count and of the ⋮', () => {
98
+ const { container } = render(
99
+ <>
100
+ <NavItem href="#" label="Item one" unread />
101
+ <NavItem href="#" label="Item two" unread urgent />
102
+ </>,
103
+ )
104
+ expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('-mr-1')
105
+ expect(container.querySelector('[data-urgent]')!.parentElement!.className.split(' ')).toContain('-mr-1')
106
+ })
107
+
97
108
  it('urgent without unread draws nothing', () => {
98
109
  const { container } = render(<NavItem href="#" label="Item one" urgent />)
99
110
  expect(container.querySelector('[data-urgent], [data-unread]')).toBeNull()
100
111
  })
101
112
 
102
- it('with a menu, the ⋮ sits in the 24px slot over the dot', () => {
113
+ it('with a menu over the dot, the ⋮ ends where a heading’s ⋮ ends', () => {
103
114
  render(<NavItem href="#" label="Item one" unread menu={<span>Action</span>} />)
104
115
  const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')
105
- expect(menu?.className).toContain('right-2')
116
+ expect(menu?.className).toContain('right-1')
117
+ })
118
+
119
+ it('on a screen with no hover the ⋮ always shows, and the count steps aside for it', () => {
120
+ render(<NavItem href="#" label="Item one" count={3} menu={<span>Action</span>} />)
121
+ const menu = screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')!
122
+ expect(menu.className.split(' ')).toContain('[@media(hover:none)]:opacity-100')
123
+ expect(screen.getByText('3').className.split(' ')).toContain('[@media(hover:none)]:opacity-0')
124
+ })
125
+
126
+ it('on a screen with no hover, with nothing at the right, the ⋮’s place is kept so the label stops before it', () => {
127
+ const { container } = render(<NavItem href="#" label="Item one" menu={<span>Action</span>} />)
128
+ expect(container.querySelector('a > [aria-hidden="true"]')!.className.split(' ')).toContain('[@media(hover:none)]:block')
129
+ })
130
+
131
+ it('with nothing at the right, the ⋮’s kept place is the ⋮’s own, at right-1, so the label loses no more than it covers', () => {
132
+ const { container } = render(<NavItem href="#" label="Item one" menu={<span>Action</span>} />)
133
+ // The slot is pulled 4px into the padding, as the ⋮ is (`right-1`): measured, the label then stops 8px before the button, not 12.
134
+ expect(container.querySelector('a > [aria-hidden="true"]')!.className.split(' ')).toEqual(expect.arrayContaining(['-mr-1', 'w-6']))
135
+ expect(screen.getByRole('button', { name: 'More options for Item one' }).closest('[data-nav-menu]')!.className).toContain('right-1')
106
136
  })
107
137
 
108
138
  it('with a count and a menu, the ⋮ lands on the count', () => {
package/src/NavItem.tsx CHANGED
@@ -108,7 +108,13 @@ export function NavItem({
108
108
  // What sits at the right at rest, after the count: the dot, or the badge.
109
109
  const mark = unread ? (urgent ? <UrgentMark /> : <UnreadDot />) : null
110
110
  // Under the ⋮ while it shows: the count and the mark step aside for it.
111
- const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0'
111
+ // "While it shows" is the pointer on the row or the keyboard in it
112
+ // (`:focus-visible`), never any focus: a clicked row keeps its focus, and
113
+ // with `focus-within` its icon and dot went while the arrow and the ⋮ stayed
114
+ // hidden (Katerina, 5 October).
115
+ // A screen with no hover shows the ⋮ always (below), so what it lands on
116
+ // steps aside always: the row reads as it does under a pointer.
117
+ const stepsAside = menu != null && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0 group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:opacity-0 [@media(hover:none)]:opacity-0'
112
118
 
113
119
  const row = (
114
120
  <a
@@ -135,7 +141,7 @@ export function NavItem({
135
141
  className={cn(
136
142
  // With rows under it the slot is kept even with no icon: the arrow lands in it.
137
143
  'flex min-w-4 shrink-0 items-center',
138
- hasRows && 'transition-opacity group-hover/nav:opacity-0 group-focus-within/nav:opacity-0',
144
+ hasRows && 'transition-opacity group-hover/nav:opacity-0 group-has-[:focus-visible]/nav:opacity-0',
139
145
  )}
140
146
  >
141
147
  {icon}
@@ -144,7 +150,7 @@ export function NavItem({
144
150
  <span className={cn('flex-1 truncate', unread && 'font-medium')}>{label}</span>
145
151
  {hint != null && (
146
152
  // Takes room only while it shows, so the label gives way to it.
147
- <span className="hidden shrink-0 items-center group-hover/nav:flex group-focus-within/nav:flex">{hint}</span>
153
+ <span className="hidden shrink-0 items-center group-hover/nav:flex group-has-[:focus-visible]/nav:flex">{hint}</span>
148
154
  )}
149
155
  {count ? (
150
156
  <WithTooltip label={countLabel ?? `${count} open`}>
@@ -164,12 +170,18 @@ export function NavItem({
164
170
  </WithTooltip>
165
171
  ) : null}
166
172
  {mark ? (
167
- <span className={cn('flex shrink-0', stepsAside)}>{mark}</span>
173
+ // `-mr-1`: the 24px slot's dot then sits on the axis of a count and
174
+ // of the ⋮ (8px from the edge to the 16px box), one column with a
175
+ // folder heading's dot and ⋮ (Katerina, 5 October).
176
+ <span className={cn('-mr-1 flex shrink-0', stepsAside)}>{mark}</span>
168
177
  ) : menu != null && !count ? (
169
178
  // Nothing at the right, but a menu: no room is kept at rest, so the
170
179
  // label has the row's whole width; while the ⋮ shows, its 24px slot
171
180
  // 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" />
181
+ // `-mr-1`: the ⋮ sits at `right-1`, 4px into the row's padding, so the
182
+ // slot does too — at the padding's edge it kept 4px the ⋮ never
183
+ // covers, and the label stopped 12px short of the button, not 8.
184
+ <span aria-hidden="true" className="-mr-1 hidden w-6 shrink-0 group-hover/nav:block group-has-[:focus-visible]/nav:block group-has-[[data-nav-menu]_[aria-expanded=true]]/nav:block [@media(hover:none)]:block" />
173
185
  ) : null}
174
186
  </a>
175
187
  )
@@ -181,7 +193,7 @@ export function NavItem({
181
193
  {hasRows && (
182
194
  // `left-1`: the 24px button's 16px arrow then sits where the icon
183
195
  // sits (the row's px-2), so the icon turns into the arrow in place.
184
- <div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100">
196
+ <div className="absolute inset-y-0 left-1 flex items-center opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100">
185
197
  <IconButton
186
198
  tooltip={open ? 'Collapse' : 'Expand'}
187
199
  aria-label={open ? `Hide what is under ${label}` : `Show what is under ${label}`}
@@ -194,11 +206,15 @@ export function NavItem({
194
206
  </div>
195
207
  )}
196
208
  {menu != null && (
197
- // On a count, `right-1`: the 24px button's 16px icon then ends 8px
198
- // from the row's edge, where the count ends (px-2), so the ⋮ lands on
199
- // the number. On the mark or the empty slot, `right-2`: the button
200
- // sits in the 24px slot itself.
201
- <div data-nav-menu className={cn('absolute inset-y-0 flex items-center', count && !mark ? 'right-1' : 'right-2', 'opacity-0 transition-opacity focus-within:opacity-100 group-hover/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100')}>
209
+ // `right-1`: the 24px button's 16px icon ends 8px from the row's
210
+ // edge, where a count ends (px-2) — and where a SectionHeader's
211
+ // actions and ⋮ end, so a folder's ⋮ and its rows' ⋮ are one column
212
+ // (Katerina, 5 October: the rows move to the heading, not the
213
+ // heading to the rows). Over a dot or the empty slot it sits 4px
214
+ // right of the slot's centre.
215
+ // On a screen with no hover it always shows: nothing there can point
216
+ // at the row, and the menu has no other way in.
217
+ <div data-nav-menu className={cn('absolute inset-y-0 right-1 flex items-center', 'opacity-0 transition-opacity group-hover/nav:opacity-100 group-has-[:focus-visible]/nav:opacity-100 has-[[aria-expanded=true]]:opacity-100 [@media(hover:none)]:opacity-100')}>
202
218
  <Menu
203
219
  align="right"
204
220
  trigger={
@@ -225,7 +241,7 @@ export function NavItem({
225
241
  className={COLLAPSIBLE_PANEL_CLASSES}
226
242
  >
227
243
  {/* `pl-6`: past the 16px icon and its 8px gap, so the rows' icons start under this row's label. */}
228
- <div className="flex flex-col gap-px pl-6">{children}</div>
244
+ <div className="flex flex-col gap-0.5 pt-0.5 pl-6">{children}</div>
229
245
  </Collapsible.Panel>
230
246
  </Collapsible.Root>
231
247
  )
package/src/NavTree.mdx CHANGED
@@ -33,6 +33,21 @@ rows to any depth.
33
33
 
34
34
  <Canvas of={NavTreeStories.WithActionsAndUnread} />
35
35
 
36
+ - A group's `menu` is its "More options" ⋮, after its actions and its
37
+ `trailing`: what acts on the whole group — a folder's Rename, Archive. A
38
+ row's `menu` acts on that row.
39
+
40
+ <Canvas of={NavTreeStories.GroupMenus} />
41
+
42
+ - A group folds with a row's arrow, 16px — every folding heading does — so a
43
+ group and the rows under it fold with the same arrow; its title starts 8px
44
+ after it, as a row's label.
45
+ - A group's `icon` sits before its title — a folder's — and the arrow takes
46
+ its place under the pointer, as a row's icon turns into its arrow. Its rows
47
+ then start under the title, past the icon.
48
+
49
+ <Canvas of={NavTreeStories.GroupIcons} />
50
+
36
51
  - Rows can be read as they open. A group's `nodes={null}` is a skeleton in
37
52
  its rows' place, and its `message` a line in their place ("Nothing in this
38
53
  yet."). A row with `hasChildren` and no `children` has its arrow, a
@@ -47,7 +62,7 @@ rows to any depth.
47
62
  itself: leave out `groups` and pass **NavTreeSection**s as children, each
48
63
  with **NavItem** rows. A `NavTreeSection` is one group exactly as `groups`
49
64
  draws it — the heading, the light indent, `message`, `loading`, `trailing`,
50
- `actions`, `onIntent`.
65
+ `actions`, `menu`, `icon`, `onIntent`.
51
66
 
52
67
  <Canvas of={NavTreeStories.AppDrawnGroups} />
53
68
 
@@ -1,9 +1,10 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconSquareRounded } from '@tabler/icons-react'
3
3
  import { useState } from 'react'
4
- import { IconArrowUpRight, IconPlus } from '@tabler/icons-react'
4
+ import { IconArrowUpRight, IconFolder, IconPlus } from '@tabler/icons-react'
5
5
  import { Chip } from './Chip'
6
6
  import { UnreadDot } from './UnreadDot'
7
+ import { MenuItem, MenuSeparator } from './Menu'
7
8
  import { NavItem } from './NavItem'
8
9
  import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
9
10
  import { Sidebar } from './Sidebar'
@@ -101,6 +102,52 @@ export const WithActionsAndUnread: Story = {
101
102
  },
102
103
  }
103
104
 
105
+ const groupMenu = (
106
+ <>
107
+ <MenuItem label="Add" />
108
+ <MenuItem label="Rename" />
109
+ <MenuItem label="Archive…" />
110
+ <MenuSeparator />
111
+ <MenuItem label="Delete" destructive />
112
+ </>
113
+ )
114
+ const rowMenu = (
115
+ <>
116
+ <MenuItem label="Move…" />
117
+ <MenuItem label="Rename" />
118
+ </>
119
+ )
120
+
121
+ /** A group's `menu`: its "More options", after its dot. Each row has its own. Hover a group's title. */
122
+ export const GroupMenus: Story = {
123
+ args: {
124
+ groups: [
125
+ { id: 'm1', title: 'Group one', trailing: <UnreadDot />, menu: groupMenu, defaultOpen: true, nodes: [
126
+ { id: 'm1a', label: 'Item two', href: '#', icon: placeholder, unread: true, menu: rowMenu },
127
+ { id: 'm1b', label: 'Item three', href: '#', icon: placeholder, menu: rowMenu },
128
+ ] },
129
+ { id: 'm2', title: 'Group two', menu: groupMenu, nodes: [{ id: 'm2a', label: 'Item four', href: '#', icon: placeholder, menu: rowMenu }] },
130
+ ],
131
+ selected: 'm1b',
132
+ },
133
+ }
134
+
135
+ const folder = <IconFolder size={16} stroke={1.5} />
136
+
137
+ /** A group's `icon`: a folder before its title, the arrow in its place on hover; its rows start under the title. */
138
+ export const GroupIcons: Story = {
139
+ args: {
140
+ groups: [
141
+ { id: 'i1', title: 'Group one', icon: folder, menu: groupMenu, defaultOpen: true, nodes: [
142
+ { id: 'i1a', label: 'Item two', href: '#', icon: placeholder, unread: true, menu: rowMenu },
143
+ { id: 'i1b', label: 'Item three', href: '#', icon: placeholder, menu: rowMenu },
144
+ ] },
145
+ { id: 'i2', title: 'Group two', icon: folder, menu: groupMenu, nodes: [{ id: 'i2a', label: 'Item four', href: '#', icon: placeholder }] },
146
+ ],
147
+ selected: 'i1b',
148
+ },
149
+ }
150
+
104
151
  /** Read as it opens: a group still being read, a group with nothing in it, a row whose rows are read when it opens, and one that could not be read. */
105
152
  export const ReadAsItOpens: Story = {
106
153
  args: {
@@ -124,7 +171,7 @@ export const AppDrawnGroups: Story = {
124
171
  <div className="flex h-screen bg-bg-base">
125
172
  <Sidebar>
126
173
  <NavTree title={args.title}>
127
- <NavTreeSection title="Group one" trailing={<UnreadDot />}>
174
+ <NavTreeSection title="Group one" trailing={<UnreadDot />} menu={groupMenu}>
128
175
  <NavItem href="#" label="Item one" icon={placeholder} unread />
129
176
  <NavItem href="#" label="Item two" icon={placeholder} active>
130
177
  <NavItem href="#" label="Item three" icon={placeholder} />
@@ -7,6 +7,7 @@
7
7
  import { afterEach, describe, expect, it, vi } from 'vitest'
8
8
  import { cleanup, render, screen } from '@testing-library/react'
9
9
  import userEvent from '@testing-library/user-event'
10
+ import { MenuItem } from './Menu'
10
11
  import { NavTree, NavTreeSection, type NavTreeGroup } from './NavTree'
11
12
 
12
13
  afterEach(cleanup)
@@ -93,6 +94,12 @@ describe('NavTree read as it opens', () => {
93
94
  expect(screen.getByRole('button', { name: 'Open' })).not.toBeNull()
94
95
  })
95
96
 
97
+ it('a group’s menu is a ⋮ named for the group, and opens its items', async () => {
98
+ render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], menu: <MenuItem label="Rename" /> }]} />)
99
+ await userEvent.click(screen.getByRole('button', { name: 'More options for Group a' }))
100
+ expect(await screen.findByRole('menuitem', { name: 'Rename' })).not.toBeNull()
101
+ })
102
+
96
103
  it('onIntent: the keyboard reaching a group says so at once', () => {
97
104
  const onIntent = vi.fn()
98
105
  render(<NavTree groups={[{ id: 'a', title: 'Group a', nodes: [], onIntent }]} emptyMessage="Nothing here yet." />)
@@ -123,6 +130,27 @@ describe('NavTree marks its groups', () => {
123
130
  })
124
131
  })
125
132
 
133
+ /**
134
+ * 4px between groups and none above the first, title or no title. The margin
135
+ * was `mt-1 first:mt-0`, and the quiet title is drawn before the groups, so
136
+ * the first group under a title was never the first child and took the 4px
137
+ * (measured in Chrome: 6px under the title, 2px after).
138
+ */
139
+ describe('NavTree spaces its groups', () => {
140
+ it('only a group after a group takes the 4px, so the first sits at the column’s 2px under the title', () => {
141
+ const { container } = render(<NavTree title="Groups" groups={GROUPS} emptyMessage="Nothing here yet." />)
142
+ const boxes = [...container.querySelectorAll<HTMLElement>('[data-nav-group]')]
143
+ for (const box of boxes) {
144
+ const classes = box.className.split(' ')
145
+ expect(classes).toContain('[&+&]:mt-1')
146
+ expect(classes).not.toContain('mt-1')
147
+ }
148
+ // The selector's own terms: the first group follows the title, which is no group.
149
+ expect(boxes[0].previousElementSibling!.className.split(' ')).not.toContain('[&+&]:mt-1')
150
+ expect(boxes[1].previousElementSibling).toBe(boxes[0])
151
+ })
152
+ })
153
+
126
154
  describe('NavTree with no groups and no words', () => {
127
155
  it('draws no line at all', () => {
128
156
  const { container } = render(<NavTree groups={[]} />)
package/src/NavTree.tsx CHANGED
@@ -50,8 +50,12 @@ export interface NavTreeGroup {
50
50
  message?: string
51
51
  /** Beside the title, always shown: an `UnreadDot`, typically. */
52
52
  trailing?: ReactNode
53
- /** Beside the title, shown on hover or focus: "Open", typically. */
53
+ /** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */
54
54
  actions?: SectionAction[]
55
+ /** The group's "More options" menu, after its actions: `MenuItem`s — a folder's Rename, Archive. */
56
+ menu?: ReactNode
57
+ /** 16px, before the title — a folder's; the arrow takes its place on hover. */
58
+ icon?: ReactNode
55
59
  defaultOpen?: boolean
56
60
  /** The caller owns whether the group is open. */
57
61
  open?: boolean
@@ -146,6 +150,8 @@ function Group({ group, selected, onSelect, storageKey }: { group: NavTreeGroup
146
150
  loading={group.nodes === null}
147
151
  trailing={group.trailing}
148
152
  actions={group.actions}
153
+ menu={group.menu}
154
+ icon={group.icon}
149
155
  defaultOpen={group.defaultOpen ?? holds(group.nodes, selected)}
150
156
  open={group.open}
151
157
  onOpenChange={group.onOpenChange}
@@ -165,8 +171,12 @@ export interface NavTreeSectionProps {
165
171
  message?: string
166
172
  /** Beside the title, always shown: an `UnreadDot`, typically. */
167
173
  trailing?: ReactNode
168
- /** Beside the title, shown on hover or focus: "Open", typically. */
174
+ /** Beside the title, shown while the row is hovered or keyboard-focused: "Open", typically. */
169
175
  actions?: SectionAction[]
176
+ /** The group's "More options" menu, after its actions: `MenuItem`s. */
177
+ menu?: ReactNode
178
+ /** 16px, before the title; the arrow takes its place on hover. */
179
+ icon?: ReactNode
170
180
  /** Open unless told otherwise. */
171
181
  defaultOpen?: boolean
172
182
  /** The caller owns whether the group is open. */
@@ -189,13 +199,21 @@ export interface NavTreeSectionProps {
189
199
  * `groups`; an app whose groups each read their own data draws them itself,
190
200
  * as `NavTree`'s children.
191
201
  */
192
- export function NavTreeSection({ id, title, message, loading = false, trailing, actions, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
202
+ export function NavTreeSection({ id, title, message, loading = false, trailing, actions, menu, icon, defaultOpen = true, open, onOpenChange, storageKey, onIntent, children }: NavTreeSectionProps) {
193
203
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
194
204
  useEffect(() => () => clearTimeout(timer.current), [])
195
205
  const intent = onIntent
196
206
  return (
197
207
  <div
198
- className="shrink-0"
208
+ // 4px between groups, none above the first: a folded group's heading
209
+ // sits 38px from the next (Katerina, 5 October: 42 was too far from rows
210
+ // 33 apart; halved), and the list starts 16px under its header, as every
211
+ // list column does (on the first group it read 20, Topics against People).
212
+ // The margin is on a group that follows a group (`[&+&]`), not "all but
213
+ // the first child": NavTree draws its quiet title before the groups, so
214
+ // with `first:mt-0` the first group under a title still took the 4px
215
+ // (measured 6px under the title, 2px once the margin went).
216
+ className="shrink-0 [&+&]:mt-1"
199
217
  data-nav-group={id}
200
218
  onPointerEnter={intent ? () => { clearTimeout(timer.current); timer.current = setTimeout(intent, NAV_TREE_INTENT_MS) } : undefined}
201
219
  onPointerLeave={intent ? () => clearTimeout(timer.current) : undefined}
@@ -210,8 +228,11 @@ export function NavTreeSection({ id, title, message, loading = false, trailing,
210
228
  storageKey={storageKey}
211
229
  trailing={trailing}
212
230
  actions={actions}
213
- className="mt-2 shrink-0"
214
- contentClassName="gap-px"
231
+ menu={menu}
232
+ icon={icon}
233
+ className="shrink-0"
234
+ // 2px between rows, as a list column's and a menu's (Katerina, 5 October).
235
+ contentClassName="gap-0.5"
215
236
  >
216
237
  {message !== undefined ? <Line message={message} /> : loading ? <SkeletonList rows={3} /> : children}
217
238
  </CollapsibleSection>
@@ -2,6 +2,7 @@ import type { ComponentPropsWithRef } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import { IconChevronDown } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
+ import { MENU_TRIGGER_CLASSES } from './looks'
5
6
  import { Avatar } from './Avatar'
6
7
  import { Person, type PersonProps } from './Person'
7
8
 
@@ -66,13 +67,13 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
66
67
  aria-haspopup="menu"
67
68
  aria-expanded={open}
68
69
  className={cn(
69
- 'flex h-8 min-h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
70
+ 'flex h-8 min-h-8 items-center gap-1.5 pl-1.5 pr-1.5 text-body-2 text-text-primary',
71
+ MENU_TRIGGER_CLASSES,
70
72
  open && 'bg-bg-hover',
71
73
  // Under a `Menu` the open state is Base UI's, not a prop: the trigger
72
- // carries `data-popup-open` while its menu is up, and sets its own
73
- // `aria-expanded`. Both spellings hold the fill, so this works whether
74
- // the caller drives it or the menu does.
75
- 'data-[popup-open]:bg-bg-hover',
74
+ // carries `data-popup-open` while its menu is up (MENU_TRIGGER_CLASSES
75
+ // holds the fill for it), and sets its own `aria-expanded`; `open`
76
+ // holds it for a caller that drives it.
76
77
  className,
77
78
  )}
78
79
  {...props}
@@ -6,7 +6,8 @@ import * as SectionHeaderStories from './SectionHeader.stories'
6
6
  # SectionHeader
7
7
 
8
8
  The 32px row a section starts with: a SectionLabel, and actions that
9
- appear on hover or focus as IconButtons with tooltips. It is also the
9
+ appear while the row is hovered or keyboard-focused, as IconButtons with
10
+ tooltips — always, on a screen with no hover. It is also the
10
11
  header of a **CollapsibleSection**, which draws the chevron and makes the
11
12
  title the toggle — that is where a section folds.
12
13
 
@@ -24,6 +25,12 @@ title the toggle — that is where a section folds.
24
25
 
25
26
  <Canvas of={SectionHeaderStories.PersistentActions} />
26
27
 
28
+ - `menu` gives the section a "More options" ⋮ after its actions, with what
29
+ acts on the whole section — a folder's Rename, Archive. It is **NavItem**'s
30
+ ⋮, revealed with the actions, and stays while its menu is open.
31
+
32
+ <Canvas of={SectionHeaderStories.WithMenu} />
33
+
27
34
  - `look="quiet"`: a small grey label over a list whose rows are rows, not
28
35
  headings — "Folders" over the folders — with its action held on screen.
29
36
  It never lights up under the pointer; its button does.
@@ -80,7 +87,7 @@ It does these by itself. Write none of them around it.
80
87
  | It does | So you never write | Refused when written by hand |
81
88
  |---|---|---|
82
89
  | With `chevron`, its title is a button that calls `onToggle` on a click, Enter or Space, and says whether it is open | a button of your own, or `aria-expanded` | |
83
- | Shows its actions while one of them has focus | a focus style of your own | |
90
+ | Shows its actions while the keyboard is in the row — on the title or on one of them — and always on a screen with no hover | a focus style of your own | |
84
91
  | Keeps its 32px in a scrolling column | `shrink-0` | |
85
92
 
86
93
  ## Keys
@@ -88,7 +95,7 @@ It does these by itself. Write none of them around it.
88
95
  | Key | Does |
89
96
  |---|---|
90
97
  | Enter, Space | on the title, with `chevron`: calls `onToggle` |
91
- | Tab | the title, then each action — the actions show while one has focus |
98
+ | Tab | the title, then each action — the actions show from the title on |
92
99
 
93
100
  ## Props
94
101
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconPlus, IconSortDescending } from '@tabler/icons-react'
3
3
  import { Chip } from './Chip'
4
+ import { MenuItem, MenuSeparator } from './Menu'
4
5
  import { SectionHeader } from './SectionHeader'
5
6
 
6
7
  /** The 32px row a section starts with. Hover it: the row fills, and its actions appear. A section that folds is CollapsibleSection, whose header this is. */
@@ -49,6 +50,23 @@ export const StillOnHover: Story = {
49
50
  },
50
51
  }
51
52
 
53
+ /** `menu`: the section's "More options", after its actions and revealed with them. The row keeps its hover look while the menu is open. */
54
+ export const WithMenu: Story = {
55
+ args: {
56
+ chevron: true,
57
+ isExpanded: true,
58
+ onToggle: () => {},
59
+ menu: (
60
+ <>
61
+ <MenuItem label="Add" />
62
+ <MenuItem label="Rename" />
63
+ <MenuSeparator />
64
+ <MenuItem label="Delete" destructive />
65
+ </>
66
+ ),
67
+ },
68
+ }
69
+
52
70
  /** A count beside the title, held on screen while the actions come and go. */
53
71
  export const WithTrailing: Story = {
54
72
  args: {
@@ -7,7 +7,10 @@
7
7
  import { afterEach, describe, expect, it, vi } from 'vitest'
8
8
  import { cleanup, render, screen } from '@testing-library/react'
9
9
  import userEvent from '@testing-library/user-event'
10
+ import { MenuItem } from './Menu'
11
+ import { NavItem } from './NavItem'
10
12
  import { SectionHeader } from './SectionHeader'
13
+ import { UnreadDot } from './UnreadDot'
11
14
 
12
15
  afterEach(cleanup)
13
16
 
@@ -48,6 +51,55 @@ describe('SectionHeader', () => {
48
51
  })
49
52
  })
50
53
 
54
+ /**
55
+ * When the buttons show. The pointer and the menu are Chrome's to prove (the
56
+ * stories); what is pinned here is which element each reveal asks.
57
+ */
58
+ describe('SectionHeader, the reveal', () => {
59
+ const menu = <MenuItem label="Rename" />
60
+ const actions = [{ icon: <span />, tooltip: 'Add', onClick: () => {} }]
61
+
62
+ it('shows the buttons once the title’s toggle has keyboard focus, as a NavItem shows its ⋮ for its link', () => {
63
+ const { container } = render(<SectionHeader title="Section" chevron onToggle={() => {}} actions={actions} menu={menu} />)
64
+ const row = container.firstElementChild as HTMLElement
65
+ const toggle = screen.getByRole('button', { name: 'Section' })
66
+ const buttons = screen.getByRole('button', { name: 'Add' }).parentElement!
67
+ // The row is the group, and it holds the toggle: the reveal asks the row, not the buttons' own box.
68
+ expect(row.className.split(' ')).toContain('group')
69
+ expect(row.contains(toggle)).toBe(true)
70
+ expect(buttons.className.split(' ')).toContain('group-has-[:focus-visible]:opacity-100')
71
+ expect(buttons.className.split(' ')).not.toContain('has-[:focus-visible]:opacity-100')
72
+ // An open menu holds them, asked of their own box: the toggle's aria-expanded must not.
73
+ expect(buttons.className.split(' ')).toContain('has-[[aria-expanded=true]]:opacity-100')
74
+ })
75
+
76
+ it('on a screen with no hover the buttons always show, and a dot under them steps aside', () => {
77
+ const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} actions={actions} menu={menu} />)
78
+ expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className.split(' ')).toContain('[@media(hover:none)]:opacity-100')
79
+ expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('[@media(hover:none)]:has-[[data-unread]]:opacity-0')
80
+ })
81
+
82
+ it('no reveal answers to any focus (`focus-within`): a clicked row kept its focus and lost its icon and dot (0.56.0)', () => {
83
+ const { container } = render(
84
+ <>
85
+ <SectionHeader title="Section" chevron icon={<span />} onToggle={() => {}} trailing={<UnreadDot />} actions={actions} menu={menu} />
86
+ <NavItem href="#" label="Item one" icon={<span />} count={2} hint={<span>Label</span>} menu={menu}>
87
+ <NavItem href="#" label="Item two" unread menu={menu} />
88
+ </NavItem>
89
+ </>,
90
+ )
91
+ const classes = [...container.querySelectorAll('*')].map((e) => e.getAttribute('class') ?? '').join(' ')
92
+ expect(classes).not.toContain('focus-within')
93
+ // The keyboard's reveal is what replaced it.
94
+ expect(classes).toContain('group-has-[:focus-visible]')
95
+ })
96
+
97
+ it('with showActions="always" there is nothing to reveal', () => {
98
+ render(<SectionHeader title="Section" actions={actions} showActions="always" />)
99
+ expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className).not.toContain('opacity-0')
100
+ })
101
+ })
102
+
51
103
  /**
52
104
  * The trailing slot (0.12.2, ADOPTION B23). Peek's Screener header carries its
53
105
  * count as a Chip there, and was the last hand-drawn folding header in the app
@@ -71,4 +123,41 @@ describe('SectionHeader, the trailing slot', () => {
71
123
  // Not part of the hover reveal either — a count is information, not an affordance.
72
124
  expect(count.closest('.opacity-0')).toBe(null)
73
125
  })
126
+
127
+ it('a dot sits in the last button’s place and steps aside for it; a count stays beside the buttons', () => {
128
+ const { container } = render(
129
+ <SectionHeader title="Section" trailing={<UnreadDot />} actions={[{ icon: <span />, tooltip: 'Open', onClick: () => {} }]} menu={<MenuItem label="Rename" />} />,
130
+ )
131
+ const slot = container.querySelector('[data-unread]')!.parentElement!
132
+ // Over the last button, out of the row's flow — not a slot of its own before them — and gone while they show.
133
+ expect(slot.className.split(' ')).toEqual(expect.arrayContaining(['has-[[data-unread]]:absolute', 'has-[[data-unread]]:right-1', 'has-[[data-unread]]:w-6', 'group-hover:has-[[data-unread]]:opacity-0']))
134
+ expect(slot.matches(':has([data-unread])')).toBe(true)
135
+ expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull()
136
+ })
137
+
138
+ it('a dot is found by what it draws, not by its element: one in a wrapper of the app’s own steps aside too', () => {
139
+ render(
140
+ <SectionHeader title="Section" trailing={<span data-testid="wrapped"><UnreadDot /></span>} menu={<MenuItem label="Rename" />} />,
141
+ )
142
+ const slot = screen.getByTestId('wrapped').parentElement!
143
+ expect(slot.matches(':has([data-unread])')).toBe(true)
144
+ expect(slot.className.split(' ')).toContain('has-[[data-unread]]:absolute')
145
+ // A count holds no dot, so the same slot keeps it beside the buttons.
146
+ cleanup()
147
+ render(<SectionHeader title="Section" trailing={<span data-testid="count">2</span>} menu={<MenuItem label="Rename" />} />)
148
+ expect(screen.getByTestId('count').parentElement!.matches(':has([data-unread])')).toBe(false)
149
+ })
150
+
151
+ it('an icon shows at rest, and folding, the arrow is in its place', () => {
152
+ const { container } = render(<SectionHeader title="Section" chevron icon={<span data-icon />} onToggle={() => {}} />)
153
+ const icon = container.querySelector('[data-icon]')!
154
+ expect(icon.parentElement!.className).toContain('group-hover:opacity-0')
155
+ // The arrow is the icon's sibling, hidden until the pointer comes.
156
+ expect(icon.parentElement!.parentElement!.querySelector('svg')!.getAttribute('class')).toContain('group-hover:opacity-100')
157
+ })
158
+
159
+ it('a dot with no buttons sits on their axis all the same', () => {
160
+ const { container } = render(<SectionHeader title="Section" trailing={<UnreadDot />} />)
161
+ expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('has-[[data-unread]]:-mr-1')
162
+ })
74
163
  })