@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.
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +8 -2
- package/dist/CollapsibleSection.d.ts.map +1 -1
- package/dist/CommandPalette.d.ts.map +1 -1
- package/dist/ContainerHeader.d.ts +7 -1
- package/dist/ContainerHeader.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/ListColumn.d.ts +2 -0
- package/dist/ListColumn.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/NavTree.d.ts +11 -3
- package/dist/NavTree.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +24 -5
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/SectionLabel.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/SuggestionMenu.d.ts.map +1 -1
- package/dist/gates/index.js +1 -1
- package/dist/gates/index.js.map +2 -2
- package/dist/index.js +138 -77
- package/dist/index.js.map +4 -4
- package/dist/looks.d.ts +4 -0
- package/dist/looks.d.ts.map +1 -1
- package/package.json +1 -1
- package/registry.json +98 -26
- package/src/ChipInput.test.tsx +8 -0
- package/src/ChipInput.tsx +4 -2
- package/src/CollapsibleSection.mdx +1 -1
- package/src/CollapsibleSection.stories.tsx +14 -1
- package/src/CollapsibleSection.test.tsx +24 -0
- package/src/CollapsibleSection.tsx +11 -5
- package/src/CommandPalette.test.tsx +6 -0
- package/src/CommandPalette.tsx +3 -2
- package/src/ContainerHeader.mdx +4 -0
- package/src/ContainerHeader.stories.tsx +14 -0
- package/src/ContainerHeader.test.tsx +8 -0
- package/src/ContainerHeader.tsx +34 -6
- package/src/IconButton.tsx +2 -1
- package/src/ListColumn.test.tsx +8 -0
- package/src/ListColumn.tsx +4 -2
- package/src/Menu.tsx +5 -4
- package/src/NavItem.mdx +3 -1
- package/src/NavItem.test.tsx +32 -2
- package/src/NavItem.tsx +28 -12
- package/src/NavTree.mdx +16 -1
- package/src/NavTree.stories.tsx +49 -2
- package/src/NavTree.test.tsx +28 -0
- package/src/NavTree.tsx +27 -6
- package/src/PersonTrigger.tsx +6 -5
- package/src/SectionHeader.mdx +10 -3
- package/src/SectionHeader.stories.tsx +18 -0
- package/src/SectionHeader.test.tsx +89 -0
- package/src/SectionHeader.tsx +114 -26
- package/src/SectionLabel.tsx +6 -3
- package/src/Select.test.tsx +8 -0
- package/src/Select.tsx +5 -2
- package/src/Sidebar.tsx +1 -1
- package/src/SuggestionMenu.stories.tsx +15 -0
- package/src/SuggestionMenu.test.tsx +8 -0
- package/src/SuggestionMenu.tsx +4 -2
- package/src/eslint/no-copied-look.test.ts +2 -2
- package/src/gates/app-checks.ts +1 -1
- 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
|
-
'
|
|
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
|
|
package/src/NavItem.test.tsx
CHANGED
|
@@ -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 ⋮
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
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-
|
|
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
|
|
package/src/NavTree.stories.tsx
CHANGED
|
@@ -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} />
|
package/src/NavTree.test.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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>
|
package/src/PersonTrigger.tsx
CHANGED
|
@@ -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
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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}
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
})
|