@estiva-app/ui 0.11.0 → 0.12.1
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/AppShell.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +7 -1
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Chip.d.ts.map +1 -1
- package/dist/CollapsibleSection.d.ts +42 -0
- package/dist/CollapsibleSection.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +19 -5
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts.map +1 -1
- package/dist/SectionHeader.d.ts +31 -10
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +202 -129
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.mdx +15 -0
- package/src/AppShell.stories.tsx +15 -0
- package/src/AppShell.test.tsx +39 -0
- package/src/AppShell.tsx +22 -7
- package/src/Breadcrumb.mdx +6 -0
- package/src/Breadcrumb.stories.tsx +12 -0
- package/src/Breadcrumb.test.tsx +10 -0
- package/src/Breadcrumb.tsx +17 -2
- package/src/Chip.tsx +10 -2
- package/src/CollapsibleSection.mdx +72 -0
- package/src/CollapsibleSection.stories.tsx +38 -0
- package/src/CollapsibleSection.test.tsx +81 -0
- package/src/CollapsibleSection.tsx +92 -0
- package/src/EmptyState.mdx +30 -10
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +39 -0
- package/src/EmptyState.tsx +23 -6
- package/src/NavItem.tsx +5 -1
- package/src/ScrollArea.mdx +6 -0
- package/src/ScrollArea.stories.tsx +29 -0
- package/src/ScrollArea.test.tsx +15 -0
- package/src/ScrollArea.tsx +17 -4
- package/src/SectionHeader.mdx +26 -17
- package/src/SectionHeader.stories.tsx +4 -35
- package/src/SectionHeader.test.tsx +40 -0
- package/src/SectionHeader.tsx +70 -39
- package/src/Sidebar.mdx +14 -10
- package/src/Sidebar.stories.tsx +17 -15
- package/src/index.ts +1 -0
- package/stories/Choosing.mdx +2 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The two manners the page claims (Katerina, 2026-09-09): `page` draws an
|
|
4
|
+
* icon over the line, `section` draws the line alone. Where each is
|
|
5
|
+
* aligned is a class the page names and Chrome shows; jsdom pins the
|
|
6
|
+
* structure.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import { EmptyState } from './EmptyState'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
describe('EmptyState', () => {
|
|
15
|
+
it('draws an icon over the line for a page, centred', () => {
|
|
16
|
+
const { container } = render(<EmptyState message="Nothing here yet." />)
|
|
17
|
+
expect(container.querySelector('svg')).not.toBeNull()
|
|
18
|
+
expect(screen.getByText('Nothing here yet.').className).toContain('text-center')
|
|
19
|
+
// In the middle of its box both ways: it takes a flex column's room and centres in it.
|
|
20
|
+
expect(container.firstElementChild!.className).toContain('items-center')
|
|
21
|
+
expect(container.firstElementChild!.className).toContain('justify-center')
|
|
22
|
+
expect(container.firstElementChild!.className).toContain('flex-1')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('draws the line alone for a section, and nothing else', () => {
|
|
26
|
+
const { container } = render(<EmptyState scope="section" message="Nothing here yet." />)
|
|
27
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
28
|
+
const line = screen.getByText('Nothing here yet.')
|
|
29
|
+
expect(container.firstElementChild).toBe(line)
|
|
30
|
+
expect(line.tagName).toBe('P')
|
|
31
|
+
expect(line.className).not.toContain('text-center')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps the caller icon for a page', () => {
|
|
35
|
+
const { container } = render(<EmptyState icon={<i data-testid="own" />} message="Nothing here yet." />)
|
|
36
|
+
expect(container.querySelector('[data-testid="own"]')).not.toBeNull()
|
|
37
|
+
expect(container.querySelector('svg')).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
})
|
package/src/EmptyState.tsx
CHANGED
|
@@ -3,20 +3,37 @@ import { IconMessage2 } from '@tabler/icons-react'
|
|
|
3
3
|
import { cn } from './cn'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The place where content will be once there is some, in two manners
|
|
7
|
+
* (Katerina, 2026-09-09):
|
|
8
|
+
*
|
|
9
|
+
* - `page` — the whole page is empty: a 16px icon over a centred line of
|
|
10
|
+
* secondary text, **in the middle of its box both ways** (Katerina,
|
|
11
|
+
* 2026-09-09: it sat near the top). It grows to the room a flex column
|
|
12
|
+
* gives it (`flex-1`) and centres inside that; a caller that draws it
|
|
13
|
+
* straight into a page hands it the height (`className="h-full"`).
|
|
14
|
+
* Peek's EmptyState (2026-08-28) otherwise; the default.
|
|
15
|
+
* - `section` — one section of a page is empty, and the page has other
|
|
16
|
+
* things on it: the line alone, left-aligned, no icon. A section's
|
|
17
|
+
* emptiness is a line among the page's content, not a stage of its own.
|
|
18
|
+
*
|
|
19
|
+
* The message is the caller's — a shared component has no words of its own
|
|
20
|
+
* for what is missing.
|
|
9
21
|
*/
|
|
10
22
|
export interface EmptyStateProps {
|
|
11
|
-
/** 16px, stroke 1.5. A speech bubble when absent. */
|
|
23
|
+
/** 16px, stroke 1.5. A speech bubble when absent. Drawn for a `page` only. */
|
|
12
24
|
icon?: ReactNode
|
|
13
25
|
message: string
|
|
26
|
+
/** `page` when the whole page is empty; `section` when one part of it is. */
|
|
27
|
+
scope?: 'page' | 'section'
|
|
14
28
|
className?: string
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
export function EmptyState({ icon, message, className }: EmptyStateProps) {
|
|
31
|
+
export function EmptyState({ icon, message, scope = 'page', className }: EmptyStateProps) {
|
|
32
|
+
if (scope === 'section') {
|
|
33
|
+
return <p className={cn('text-body-2 text-text-secondary', className)}>{message}</p>
|
|
34
|
+
}
|
|
18
35
|
return (
|
|
19
|
-
<div className={cn('flex flex-
|
|
36
|
+
<div className={cn('flex flex-1 flex-col items-center justify-center gap-2', className)}>
|
|
20
37
|
<span className="text-text-secondary">{icon ?? <IconMessage2 size={16} stroke={1.5} />}</span>
|
|
21
38
|
<p className="text-body-2 text-text-secondary text-center">{message}</p>
|
|
22
39
|
</div>
|
package/src/NavItem.tsx
CHANGED
|
@@ -45,7 +45,11 @@ export function NavItem({ label, href, count, countLabel, active = false, icon,
|
|
|
45
45
|
<span className="flex-1 truncate">{label}</span>
|
|
46
46
|
{count ? (
|
|
47
47
|
<WithTooltip label={countLabel ?? `${count} open`}>
|
|
48
|
-
|
|
48
|
+
{/* A 16px box, centred: the slot is an icon's width, so a number
|
|
49
|
+
here sits on the same axis as a SectionHeader's action above it
|
|
50
|
+
— right edges alone left a digit 4px off a 16px icon (Katerina,
|
|
51
|
+
2026-09-09). A three-digit count grows the box leftwards. */}
|
|
52
|
+
<span className="min-w-4 shrink-0 text-center font-mono text-caption tabular-nums text-text-muted">{count}</span>
|
|
49
53
|
</WithTooltip>
|
|
50
54
|
) : null}
|
|
51
55
|
</a>
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -41,6 +41,12 @@ import { ScrollArea } from '@estiva-app/ui'
|
|
|
41
41
|
- The region needs a height (or a `max-h-*`) to have anything to scroll.
|
|
42
42
|
- The native scrollbar is hidden by Base UI; a native `overflow-y-auto` on
|
|
43
43
|
the same element would draw a second one.
|
|
44
|
+
- A region keeps the wheel only in an axis it can actually scroll: a list
|
|
45
|
+
that reaches its end does not hand the wheel to the page behind it, but a
|
|
46
|
+
sideways table inside a scrolling page passes a wheel *down* through to
|
|
47
|
+
the page, and a region whose content fits passes everything. Measured in
|
|
48
|
+
Chrome: wheel down over a sideways region inside a page, the page moves
|
|
49
|
+
and the region does not; a sideways swipe, the other way round.
|
|
44
50
|
|
|
45
51
|
## Keys
|
|
46
52
|
|
|
@@ -73,3 +73,32 @@ export const Both: Story = {
|
|
|
73
73
|
</ScrollArea>
|
|
74
74
|
),
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/** A sideways region inside a scrolling page — a table in a content column. A wheel down over it moves the page; a swipe sideways moves the region. */
|
|
78
|
+
export const SidewaysInsideAPage: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
|
|
81
|
+
<div className="flex flex-col gap-px">
|
|
82
|
+
{rows.slice(0, 3).map((row) => (
|
|
83
|
+
<p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
84
|
+
{row}
|
|
85
|
+
</p>
|
|
86
|
+
))}
|
|
87
|
+
<ScrollArea orientation="horizontal" className="my-1 rounded-md border border-border-default" viewportClassName="p-2">
|
|
88
|
+
<div className="flex w-max gap-2">
|
|
89
|
+
{rows.slice(0, 12).map((row) => (
|
|
90
|
+
<div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
|
|
91
|
+
{row}
|
|
92
|
+
</div>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
</ScrollArea>
|
|
96
|
+
{rows.slice(3).map((row) => (
|
|
97
|
+
<p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
98
|
+
{row}
|
|
99
|
+
</p>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</ScrollArea>
|
|
103
|
+
),
|
|
104
|
+
}
|
package/src/ScrollArea.test.tsx
CHANGED
|
@@ -48,3 +48,18 @@ describe('ScrollArea', () => {
|
|
|
48
48
|
void v
|
|
49
49
|
})
|
|
50
50
|
})
|
|
51
|
+
|
|
52
|
+
describe('ScrollArea and the page behind it', () => {
|
|
53
|
+
// Measured in Chrome (2026-09-09, PLAN Finding 40): with an unconditional
|
|
54
|
+
// `overscroll-contain` the page under a sideways table moved 0px on a
|
|
55
|
+
// wheel down, 446px without it; with these two classes 400px, and the
|
|
56
|
+
// table 0. jsdom cannot scroll; it pins that the contain is per axis and
|
|
57
|
+
// waits for Base UI's overflow attribute rather than being unconditional.
|
|
58
|
+
it('contains the wheel per axis, and only while that axis overflows', () => {
|
|
59
|
+
render(<ScrollArea orientation="horizontal"><p>Inside</p></ScrollArea>)
|
|
60
|
+
const viewport = screen.getByText('Inside').parentElement!.parentElement!
|
|
61
|
+
expect(viewport.className).toContain('data-[has-overflow-x]:overscroll-x-contain')
|
|
62
|
+
expect(viewport.className).toContain('data-[has-overflow-y]:overscroll-y-contain')
|
|
63
|
+
expect(viewport.className).not.toMatch(/(^|\s)overscroll-contain(\s|$)/)
|
|
64
|
+
})
|
|
65
|
+
})
|
package/src/ScrollArea.tsx
CHANGED
|
@@ -54,10 +54,23 @@ export function ScrollArea({ orientation = 'vertical', className, viewportClassN
|
|
|
54
54
|
const horizontal = orientation !== 'vertical'
|
|
55
55
|
return (
|
|
56
56
|
<BaseScrollArea.Root className={cn('relative min-h-0 min-w-0', className)}>
|
|
57
|
-
{/* `overscroll-contain
|
|
58
|
-
|
|
59
|
-
kept since Peek
|
|
60
|
-
|
|
57
|
+
{/* `overscroll-contain`, per axis and only while that axis has more to
|
|
58
|
+
show: a list that reaches its end does not hand the wheel to the page
|
|
59
|
+
behind it — the rule every popup here has kept since Peek — but a
|
|
60
|
+
region with nothing to scroll one way passes that way's wheel through.
|
|
61
|
+
Base UI's viewport is `overflow: scroll` on both axes whatever
|
|
62
|
+
`orientation` says, so a sideways table is also a vertical scroll box
|
|
63
|
+
with no room in it, and an unconditional `contain` stopped the page
|
|
64
|
+
under every table and board (Katerina, 2026-09-09: 0px of page scroll
|
|
65
|
+
with the pointer over Ship's table, 446px without the line; PLAN
|
|
66
|
+
Finding 40). `data-has-overflow-x` / `-y` are Base UI's word for
|
|
67
|
+
"this axis really overflows", written on the viewport as it changes. */}
|
|
68
|
+
<BaseScrollArea.Viewport
|
|
69
|
+
className={cn(
|
|
70
|
+
'h-full w-full outline-none data-[has-overflow-x]:overscroll-x-contain data-[has-overflow-y]:overscroll-y-contain',
|
|
71
|
+
viewportClassName,
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
61
74
|
{/* Base UI gives the content box `min-width: fit-content`, which is
|
|
62
75
|
right when the region scrolls sideways and wrong when it does not:
|
|
63
76
|
a row's `truncate` needs a box no wider than the viewport. */}
|
package/src/SectionHeader.mdx
CHANGED
|
@@ -5,23 +5,26 @@ import * as SectionHeaderStories from './SectionHeader.stories'
|
|
|
5
5
|
|
|
6
6
|
# SectionHeader
|
|
7
7
|
|
|
8
|
-
The 32px row a section starts with: a SectionLabel,
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
10
|
+
header of a **CollapsibleSection**, which draws the chevron and makes the
|
|
11
|
+
title the toggle — that is where a section folds.
|
|
11
12
|
|
|
12
13
|
<Canvas of={SectionHeaderStories.WithActions} />
|
|
13
14
|
|
|
14
15
|
## When
|
|
15
16
|
|
|
16
|
-
- A sidebar or panel section that
|
|
17
|
-
|
|
17
|
+
- A sidebar or panel section that carries its own actions — add, sort,
|
|
18
|
+
filter.
|
|
18
19
|
- `showActions="always"` keeps the actions visible — for touch, or for a
|
|
19
20
|
section whose actions are the point.
|
|
20
21
|
|
|
21
|
-
<Canvas of={SectionHeaderStories.
|
|
22
|
+
<Canvas of={SectionHeaderStories.PersistentActions} />
|
|
22
23
|
|
|
23
24
|
## When not
|
|
24
25
|
|
|
26
|
+
- A section that opens and closes → **CollapsibleSection**: the header,
|
|
27
|
+
the rows, the slide, and a `storageKey` that remembers.
|
|
25
28
|
- A title with no chevron and no actions → **SectionLabel** alone.
|
|
26
29
|
- A heading inside a menu → **MenuSection**.
|
|
27
30
|
- Page-level headings → the `h*` type tokens.
|
|
@@ -32,20 +35,26 @@ hover as IconButtons with tooltips.
|
|
|
32
35
|
import { SectionHeader } from '@estiva-app/ui'
|
|
33
36
|
import { IconPlus } from '@tabler/icons-react'
|
|
34
37
|
|
|
35
|
-
<SectionHeader
|
|
36
|
-
title="Sections"
|
|
37
|
-
chevron
|
|
38
|
-
isExpanded={expanded}
|
|
39
|
-
onToggle={() => setExpanded((v) => !v)}
|
|
40
|
-
actions={[{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: add }]}
|
|
41
|
-
/>
|
|
38
|
+
<SectionHeader title="Sections" actions={[{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: add }]} />
|
|
42
39
|
```
|
|
43
40
|
|
|
41
|
+
- `chevron`, `isExpanded` and `onToggle` are CollapsibleSection's to set
|
|
42
|
+
(decided 2026-09-09: one component folds, and it is that one). With
|
|
43
|
+
them, the title is a button that fills the row up to the actions — the
|
|
44
|
+
whole row is the hit target, the keyboard can toggle it, and it says its
|
|
45
|
+
state (`aria-expanded`).
|
|
44
46
|
- An action is `{ icon, tooltip, onClick }` — the tooltip doubles as the
|
|
45
|
-
action's accessible name
|
|
46
|
-
section.
|
|
47
|
-
-
|
|
48
|
-
|
|
47
|
+
action's accessible name. The actions sit beside the title button, not
|
|
48
|
+
inside it, so a click on one never also toggles the section.
|
|
49
|
+
- `render` swaps the title's element in Base UI's manner; it is how
|
|
50
|
+
CollapsibleSection makes the title a `Collapsible.Trigger`.
|
|
51
|
+
|
|
52
|
+
## Keys
|
|
53
|
+
|
|
54
|
+
| Key | Does |
|
|
55
|
+
|---|---|
|
|
56
|
+
| Enter, Space | on the title, with `chevron`: calls `onToggle` |
|
|
57
|
+
| Tab | the title, then each action — the actions show while one has focus |
|
|
49
58
|
|
|
50
59
|
## Props
|
|
51
60
|
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
2
|
import { IconPlus, IconSortDescending } from '@tabler/icons-react'
|
|
4
3
|
import { SectionHeader } from './SectionHeader'
|
|
5
4
|
|
|
6
|
-
/** The 32px row a section starts with. Hover it: the row fills, and its actions appear. */
|
|
5
|
+
/** 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. */
|
|
7
6
|
const meta = {
|
|
8
7
|
title: 'Navigation/SectionHeader',
|
|
9
8
|
component: SectionHeader,
|
|
10
9
|
decorators: [(Story) => <div className="w-[280px]"><Story /></div>],
|
|
11
|
-
args: { title: '
|
|
12
|
-
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] } },
|
|
10
|
+
args: { title: 'Section', showActions: 'hover' },
|
|
11
|
+
argTypes: { showActions: { control: 'inline-radio', options: ['hover', 'always'] }, chevron: { control: false }, isExpanded: { control: false } },
|
|
13
12
|
} satisfies Meta<typeof SectionHeader>
|
|
14
13
|
|
|
15
14
|
export default meta
|
|
@@ -17,30 +16,9 @@ type Story = StoryObj<typeof meta>
|
|
|
17
16
|
|
|
18
17
|
export const Plain: Story = {}
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
export const Collapsible: Story = {
|
|
22
|
-
args: { title: 'Your documents', chevron: true },
|
|
23
|
-
render: (args) => {
|
|
24
|
-
const [expanded, setExpanded] = useState(true)
|
|
25
|
-
return (
|
|
26
|
-
<div className="flex flex-col gap-1">
|
|
27
|
-
<SectionHeader {...args} isExpanded={expanded} onToggle={() => setExpanded((v) => !v)} />
|
|
28
|
-
{expanded &&
|
|
29
|
-
['Quarterly plan', 'Reading list'].map((row) => (
|
|
30
|
-
<div key={row} className="rounded-lg px-2 py-1.5 text-[14px] leading-[140%] text-text-primary hover:bg-bg-hover">
|
|
31
|
-
{row}
|
|
32
|
-
</div>
|
|
33
|
-
))}
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
},
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Peek's add/sort pair, in its original order — revealed on hover, and a click on one never toggles the section. */
|
|
19
|
+
/** Actions beside the title, in the order given — revealed on hover or focus. */
|
|
40
20
|
export const WithActions: Story = {
|
|
41
21
|
args: {
|
|
42
|
-
title: 'Sections',
|
|
43
|
-
chevron: true,
|
|
44
22
|
actions: [
|
|
45
23
|
{ icon: <IconSortDescending size={16} stroke={1.5} />, tooltip: 'Sort by', onClick: () => {} },
|
|
46
24
|
{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} },
|
|
@@ -48,18 +26,9 @@ export const WithActions: Story = {
|
|
|
48
26
|
},
|
|
49
27
|
}
|
|
50
28
|
|
|
51
|
-
/** Actions without the collapse — a fixed section that still offers Add on hover. */
|
|
52
|
-
export const ActionsOnly: Story = {
|
|
53
|
-
args: {
|
|
54
|
-
title: 'Pinned',
|
|
55
|
-
actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
|
|
59
29
|
/** The actions held on screen — `showActions="always"` — for a section whose affordance should not hide. */
|
|
60
30
|
export const PersistentActions: Story = {
|
|
61
31
|
args: {
|
|
62
|
-
title: 'Pinned',
|
|
63
32
|
showActions: 'always',
|
|
64
33
|
actions: [{ icon: <IconPlus size={16} stroke={1.5} />, tooltip: 'Add', onClick: () => {} }],
|
|
65
34
|
},
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The page's claims: with `chevron` the title is a button that toggles by
|
|
4
|
+
* click and by key and says its state; an action beside it acts without
|
|
5
|
+
* toggling; without `chevron` there is no button to find.
|
|
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 { SectionHeader } from './SectionHeader'
|
|
11
|
+
|
|
12
|
+
afterEach(cleanup)
|
|
13
|
+
|
|
14
|
+
describe('SectionHeader', () => {
|
|
15
|
+
it('with a chevron the title is a button that toggles, by click and by key', async () => {
|
|
16
|
+
const onToggle = vi.fn()
|
|
17
|
+
render(<SectionHeader title="Section" chevron isExpanded onToggle={onToggle} />)
|
|
18
|
+
const title = screen.getByRole('button', { name: 'Section' })
|
|
19
|
+
expect(title.getAttribute('aria-expanded')).toBe('true')
|
|
20
|
+
await userEvent.click(title)
|
|
21
|
+
title.focus()
|
|
22
|
+
await userEvent.keyboard('{Enter}')
|
|
23
|
+
expect(onToggle).toHaveBeenCalledTimes(2)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('an action beside the title acts, and never toggles', async () => {
|
|
27
|
+
const onToggle = vi.fn()
|
|
28
|
+
const add = vi.fn()
|
|
29
|
+
render(<SectionHeader title="Section" chevron onToggle={onToggle} actions={[{ icon: <i />, tooltip: 'Add', onClick: add }]} />)
|
|
30
|
+
await userEvent.click(screen.getByRole('button', { name: 'Add' }))
|
|
31
|
+
expect(add).toHaveBeenCalledTimes(1)
|
|
32
|
+
expect(onToggle).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('without a chevron there is no button', () => {
|
|
36
|
+
render(<SectionHeader title="Section" />)
|
|
37
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
38
|
+
expect(screen.getByText('Section')).not.toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
package/src/SectionHeader.tsx
CHANGED
|
@@ -1,21 +1,37 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
|
+
import { useRender } from '@base-ui/react/use-render'
|
|
3
4
|
import { cn } from './cn'
|
|
4
5
|
import { IconButton } from './IconButton'
|
|
5
6
|
import { SectionLabel } from './SectionLabel'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The 32px row a section starts with (Peek's SectionHeader, 2026-09-01):
|
|
9
|
-
* a SectionLabel, an optional collapse chevron that makes the
|
|
10
|
-
* toggle, and actions that appear
|
|
10
|
+
* a SectionLabel, an optional collapse chevron that makes the title the
|
|
11
|
+
* toggle, and actions that appear while the row is hovered or focused, as
|
|
11
12
|
* IconButtons with tooltips.
|
|
12
13
|
*
|
|
14
|
+
* Three things changed on 2026-09-09, for `CollapsibleSection`:
|
|
15
|
+
*
|
|
16
|
+
* - **The title is a button when it toggles.** It was a `div` with an
|
|
17
|
+
* `onClick`, so the keyboard could not open or close a section at all.
|
|
18
|
+
* The button fills the row up to the actions, so the whole row is still
|
|
19
|
+
* the hit target; the actions sit beside it rather than inside it,
|
|
20
|
+
* because a button inside a button is invalid HTML — and was why every
|
|
21
|
+
* action had to stop its click from also toggling.
|
|
22
|
+
* - **The hover is CSS.** The fill and the actions' reveal were React
|
|
23
|
+
* state (`isHovered`), which the README forbids — a mount cannot stay in
|
|
24
|
+
* step with a transition. `group-hover` now; the actions are in the row
|
|
25
|
+
* at `opacity-0` until hovered or focused, so a keyboard user reaches
|
|
26
|
+
* them too.
|
|
27
|
+
* - **`render`** lets `CollapsibleSection` hand in Base UI's
|
|
28
|
+
* `Collapsible.Trigger` as the title button — one header for the
|
|
29
|
+
* hand-held section (`isExpanded` / `onToggle`) and the Base UI one,
|
|
30
|
+
* with no second click handler.
|
|
31
|
+
*
|
|
13
32
|
* One API change from Peek's original, safe because no call site used the
|
|
14
33
|
* old shape yet: the two hard-wired action slots (add, sort) became
|
|
15
|
-
* `actions` — the caller brings the icon, the tooltip and the handler
|
|
16
|
-
* header owns the hover reveal and stops the click from also toggling the
|
|
17
|
-
* section. Peek's add/sort pair is the WithActions story, in its original
|
|
18
|
-
* order.
|
|
34
|
+
* `actions` — the caller brings the icon, the tooltip and the handler.
|
|
19
35
|
*/
|
|
20
36
|
export interface SectionAction {
|
|
21
37
|
/** 16px, stroke 1.5. */
|
|
@@ -26,55 +42,70 @@ export interface SectionAction {
|
|
|
26
42
|
|
|
27
43
|
export interface SectionHeaderProps {
|
|
28
44
|
title: string
|
|
29
|
-
/** Collapsible: draws the chevron and makes the
|
|
45
|
+
/** Collapsible: draws the chevron and makes the title a button that toggles. */
|
|
30
46
|
chevron?: boolean
|
|
31
47
|
isExpanded?: boolean
|
|
32
48
|
onToggle?: () => void
|
|
33
49
|
/** Right-aligned, in the order given. */
|
|
34
50
|
actions?: SectionAction[]
|
|
35
|
-
/** `hover` reveals the actions
|
|
51
|
+
/** `hover` reveals the actions while the row is hovered or focused; `always` keeps them. */
|
|
36
52
|
showActions?: 'hover' | 'always'
|
|
53
|
+
/**
|
|
54
|
+
* What the title renders as, in Base UI's manner. A plain button with
|
|
55
|
+
* `onToggle` by default; `CollapsibleSection` hands in `Collapsible.Trigger`.
|
|
56
|
+
*/
|
|
57
|
+
render?: useRender.RenderProp
|
|
37
58
|
className?: string
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, actions, showActions = 'hover', className }: SectionHeaderProps) {
|
|
41
|
-
const
|
|
61
|
+
export function SectionHeader({ title, chevron = false, isExpanded = true, onToggle, actions, showActions = 'hover', render, className }: SectionHeaderProps) {
|
|
62
|
+
const titleElement = useRender({
|
|
63
|
+
render: render ?? (chevron ? <button type="button" onClick={onToggle} aria-expanded={isExpanded} /> : <span />),
|
|
64
|
+
props: {
|
|
65
|
+
// `text-left`: a button centres its text. `h-full` and `flex-1`: the
|
|
66
|
+
// whole row up to the actions is the hit target, as it was when the
|
|
67
|
+
// row itself carried the click.
|
|
68
|
+
className: 'flex h-full min-w-0 flex-1 items-center gap-1 text-left',
|
|
69
|
+
children: (
|
|
70
|
+
<>
|
|
71
|
+
{chevron && (
|
|
72
|
+
<IconChevronRight
|
|
73
|
+
size={12}
|
|
74
|
+
stroke={1.5}
|
|
75
|
+
className={cn('shrink-0 text-text-secondary transition-transform duration-150', isExpanded && 'rotate-90')}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
<SectionLabel>{title}</SectionLabel>
|
|
79
|
+
</>
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
})
|
|
42
83
|
|
|
43
84
|
return (
|
|
44
85
|
<div
|
|
45
86
|
className={cn(
|
|
46
|
-
'flex h-[32px] items-center
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
'group flex h-[32px] items-center gap-1 rounded-lg px-2 transition-colors',
|
|
88
|
+
// The fill says "this does something": a row with a toggle or actions
|
|
89
|
+
// lights up, a fixed heading over rows does not (2026-09-09, the
|
|
90
|
+
// Sidebar's fixed group).
|
|
91
|
+
(chevron || (actions && actions.length > 0)) && 'hover:bg-bg-hover',
|
|
49
92
|
className,
|
|
50
93
|
)}
|
|
51
|
-
onClick={chevron ? onToggle : undefined}
|
|
52
|
-
onMouseEnter={() => setIsHovered(true)}
|
|
53
|
-
onMouseLeave={() => setIsHovered(false)}
|
|
54
94
|
>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<div className="flex items-center gap-1">
|
|
95
|
+
{titleElement}
|
|
96
|
+
{actions && actions.length > 0 && (
|
|
97
|
+
<div
|
|
98
|
+
// `-mr-1`: an IconButton is 24px around a 16px icon, so at the row's
|
|
99
|
+
// `px-2` its icon ended 12px from the edge while a NavItem's number
|
|
100
|
+
// ends 8px from it; pulled 4px, the icon and the number share a
|
|
101
|
+
// right edge (Katerina, 2026-09-09; measured 4px off, then 0).
|
|
102
|
+
className={cn(
|
|
103
|
+
'-mr-1 flex shrink-0 items-center gap-1',
|
|
104
|
+
showActions === 'hover' && 'opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100',
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
68
107
|
{actions.map((action) => (
|
|
69
|
-
<IconButton
|
|
70
|
-
key={action.tooltip}
|
|
71
|
-
tooltip={action.tooltip}
|
|
72
|
-
aria-label={action.tooltip}
|
|
73
|
-
onClick={(event) => {
|
|
74
|
-
event.stopPropagation()
|
|
75
|
-
action.onClick()
|
|
76
|
-
}}
|
|
77
|
-
>
|
|
108
|
+
<IconButton key={action.tooltip} tooltip={action.tooltip} aria-label={action.tooltip} onClick={action.onClick}>
|
|
78
109
|
{action.icon}
|
|
79
110
|
</IconButton>
|
|
80
111
|
))}
|
package/src/Sidebar.mdx
CHANGED
|
@@ -16,8 +16,10 @@ the caller's — this is the shell.
|
|
|
16
16
|
- The structured frame's navigation (AppShell's `solid` variant), filled
|
|
17
17
|
with **NavItem** rows — the sidebar pairs with the solid TopBar, as the
|
|
18
18
|
Rail pairs with the floating one.
|
|
19
|
-
- A labelled group inside it
|
|
20
|
-
|
|
19
|
+
- A labelled group inside it, of two kinds (the Composed story shows
|
|
20
|
+
both): one the reader can fold away and that stays how they left it —
|
|
21
|
+
a **CollapsibleSection** — and one that stays open, a **SectionHeader**
|
|
22
|
+
over its rows.
|
|
21
23
|
- It scrolls on its own — a long list never scrolls the frame away.
|
|
22
24
|
|
|
23
25
|
<Canvas of={SidebarStories.Scrolls} />
|
|
@@ -33,14 +35,15 @@ the caller's — this is the shell.
|
|
|
33
35
|
## How
|
|
34
36
|
|
|
35
37
|
```tsx
|
|
36
|
-
import { Sidebar, NavItem,
|
|
38
|
+
import { Sidebar, NavItem, CollapsibleSection, SectionHeader } from '@estiva-app/ui'
|
|
37
39
|
|
|
38
40
|
<Sidebar>
|
|
39
41
|
<NavItem href="/documents" label="Documents" active />
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
</
|
|
43
|
-
<
|
|
42
|
+
<CollapsibleSection title="Collections" storageKey="app.sidebar.collections" className="mt-2 shrink-0" contentClassName="gap-px">
|
|
43
|
+
<NavItem href="/collections/12" label="Quarterly plan" count={7} countLabel="7 open" />
|
|
44
|
+
</CollapsibleSection>
|
|
45
|
+
<SectionHeader title="Pinned" className="mt-2 shrink-0" />
|
|
46
|
+
<NavItem href="/documents/3" label="Reading list" />
|
|
44
47
|
</Sidebar>
|
|
45
48
|
```
|
|
46
49
|
|
|
@@ -48,9 +51,10 @@ import { Sidebar, NavItem, SectionLabel } from '@estiva-app/ui'
|
|
|
48
51
|
the page.** Keep NavItem's rule: a thin wrapper computes `active` from the
|
|
49
52
|
location, intercepts the click and navigates in place — see **NavItem**.
|
|
50
53
|
|
|
51
|
-
Anything you place directly in the column needs `shrink-0` (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
Anything you place directly in the column needs `shrink-0` (both headings
|
|
55
|
+
above take it in `className`):
|
|
56
|
+
the column scrolls on overflow, and a flex child without it gets
|
|
57
|
+
compressed instead — rows must keep their height.
|
|
54
58
|
|
|
55
59
|
It is a `nav` region — pass `aria-label` when "Workspace" is not the
|
|
56
60
|
right name for what it navigates.
|
package/src/Sidebar.stories.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { CollapsibleSection } from './CollapsibleSection'
|
|
3
4
|
import { NavItem } from './NavItem'
|
|
4
|
-
import {
|
|
5
|
+
import { SectionHeader } from './SectionHeader'
|
|
5
6
|
import { Sidebar } from './Sidebar'
|
|
6
7
|
|
|
7
8
|
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
@@ -18,7 +19,7 @@ const meta = {
|
|
|
18
19
|
export default meta
|
|
19
20
|
type Story = StoryObj<typeof meta>
|
|
20
21
|
|
|
21
|
-
/** Entries, then
|
|
22
|
+
/** Entries, then two kinds of group: one that folds (a CollapsibleSection) and one that stays open (a SectionHeader over its rows). `mt-2` between groups, `shrink-0` so the column scrolls rather than squashes. */
|
|
22
23
|
export const Composed: Story = {
|
|
23
24
|
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
24
25
|
// the count chip is muted text, 3.06:1 on the active row in signal (AA 4.5:1).
|
|
@@ -27,17 +28,19 @@ export const Composed: Story = {
|
|
|
27
28
|
<Sidebar {...args}>
|
|
28
29
|
<NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
|
|
29
30
|
<NavItem href="#" label="Item two" icon={placeholder} count={5} countLabel="5 active" />
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
31
|
+
<CollapsibleSection title="Group one" className="mt-2 shrink-0" contentClassName="gap-px">
|
|
32
|
+
<NavItem href="#" label="Item three" count={7} countLabel="7 open" />
|
|
33
|
+
<NavItem href="#" label="Item four" count={2} countLabel="2 open" />
|
|
34
|
+
<NavItem href="#" label="Item five" />
|
|
35
|
+
</CollapsibleSection>
|
|
36
|
+
<SectionHeader title="Group two" className="mt-2 shrink-0" />
|
|
37
|
+
<NavItem href="#" label="Item six" icon={placeholder} />
|
|
38
|
+
<NavItem href="#" label="Item seven" icon={placeholder} />
|
|
36
39
|
</Sidebar>
|
|
37
40
|
),
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/** The column scrolls on its own — a long
|
|
43
|
+
/** The column scrolls on its own — a long group never scrolls the frame away. */
|
|
41
44
|
export const Scrolls: Story = {
|
|
42
45
|
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
43
46
|
// the count chips are muted text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
|
|
@@ -45,12 +48,11 @@ export const Scrolls: Story = {
|
|
|
45
48
|
render: (args) => (
|
|
46
49
|
<Sidebar {...args}>
|
|
47
50
|
<NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" />
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
))}
|
|
51
|
+
<CollapsibleSection title="Group" className="mt-2 shrink-0" contentClassName="gap-px">
|
|
52
|
+
{Array.from({ length: 40 }, (_, i) => (
|
|
53
|
+
<NavItem key={i} href="#" label={`Item ${i + 2}`} active={i === 2} count={((i * 7) % 9) + 1} countLabel={`${((i * 7) % 9) + 1} open`} />
|
|
54
|
+
))}
|
|
55
|
+
</CollapsibleSection>
|
|
54
56
|
</Sidebar>
|
|
55
57
|
),
|
|
56
58
|
}
|