@estiva-app/ui 0.11.0 → 0.12.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 (42) hide show
  1. package/dist/Breadcrumb.d.ts +7 -1
  2. package/dist/Breadcrumb.d.ts.map +1 -1
  3. package/dist/Chip.d.ts.map +1 -1
  4. package/dist/CollapsibleSection.d.ts +42 -0
  5. package/dist/CollapsibleSection.d.ts.map +1 -0
  6. package/dist/EmptyState.d.ts +19 -5
  7. package/dist/EmptyState.d.ts.map +1 -1
  8. package/dist/NavItem.d.ts.map +1 -1
  9. package/dist/ScrollArea.d.ts.map +1 -1
  10. package/dist/SectionHeader.d.ts +31 -10
  11. package/dist/SectionHeader.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +126 -55
  15. package/dist/index.js.map +4 -4
  16. package/package.json +1 -1
  17. package/src/Breadcrumb.mdx +6 -0
  18. package/src/Breadcrumb.stories.tsx +12 -0
  19. package/src/Breadcrumb.test.tsx +10 -0
  20. package/src/Breadcrumb.tsx +17 -2
  21. package/src/Chip.tsx +10 -2
  22. package/src/CollapsibleSection.mdx +72 -0
  23. package/src/CollapsibleSection.stories.tsx +38 -0
  24. package/src/CollapsibleSection.test.tsx +81 -0
  25. package/src/CollapsibleSection.tsx +92 -0
  26. package/src/EmptyState.mdx +30 -11
  27. package/src/EmptyState.stories.tsx +7 -3
  28. package/src/EmptyState.test.tsx +39 -0
  29. package/src/EmptyState.tsx +23 -6
  30. package/src/NavItem.tsx +5 -1
  31. package/src/ScrollArea.mdx +6 -0
  32. package/src/ScrollArea.stories.tsx +29 -0
  33. package/src/ScrollArea.test.tsx +15 -0
  34. package/src/ScrollArea.tsx +17 -4
  35. package/src/SectionHeader.mdx +26 -17
  36. package/src/SectionHeader.stories.tsx +4 -35
  37. package/src/SectionHeader.test.tsx +40 -0
  38. package/src/SectionHeader.tsx +70 -39
  39. package/src/Sidebar.mdx +14 -10
  40. package/src/Sidebar.stories.tsx +17 -15
  41. package/src/index.ts +1 -0
  42. package/stories/Choosing.mdx +2 -1
@@ -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
+ })
@@ -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`: a list that reaches its end does not hand the
58
- wheel to the page behind it, which is the rule every popup here has
59
- kept since Peek. */}
60
- <BaseScrollArea.Viewport className={cn('h-full w-full overscroll-contain outline-none', viewportClassName)}>
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. */}
@@ -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, an optional collapse
9
- chevron that makes the whole row the toggle, and actions that appear on
10
- hover as IconButtons with tooltips.
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 collapses, or that carries its own
17
- actions — add, sort, filter.
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.Collapsible} />
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, and an action's click never also toggles the
46
- section.
47
- - With `chevron`, the whole row is the toggle no separate hit target to
48
- find.
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: 'Starred', showActions: 'hover' },
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
- /** The chevron makes the whole row the toggle; click it. */
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
+ })
@@ -1,21 +1,37 @@
1
- import { useState, type ReactNode } from 'react'
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 whole row the
10
- * toggle, and actions that appear only while the row is hovered, as
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; the
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 whole row the toggle. */
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 only while the row is hovered; `always` keeps them. */
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 [isHovered, setIsHovered] = useState(false)
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 justify-between rounded-lg px-2 transition-colors',
47
- isHovered && 'bg-bg-hover',
48
- chevron && 'cursor-pointer',
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
- <div className="flex shrink-0 items-center gap-1">
56
- {chevron && (
57
- <IconChevronRight
58
- size={12}
59
- stroke={1.5}
60
- className={cn('text-text-secondary transition-transform duration-150', isExpanded && 'rotate-90')}
61
- />
62
- )}
63
- <SectionLabel>{title}</SectionLabel>
64
- </div>
65
-
66
- {(showActions === 'always' || isHovered) && actions && actions.length > 0 && (
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: a **SectionLabel** in a 32px `px-2` row
20
- above its rows (the Composed story shows the idiom).
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, SectionLabel } from '@estiva-app/ui'
38
+ import { Sidebar, NavItem, CollapsibleSection, SectionHeader } from '@estiva-app/ui'
37
39
 
38
40
  <Sidebar>
39
41
  <NavItem href="/documents" label="Documents" active />
40
- <div className="mt-2 flex h-8 shrink-0 items-center px-2">
41
- <SectionLabel>Collections</SectionLabel>
42
- </div>
43
- <NavItem href="/collections/12" label="Quarterly plan" count={7} countLabel="7 open" />
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` (the heading
52
- row above carries it): the column scrolls on overflow, and a flex child
53
- without it gets compressed instead rows must keep their height.
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.
@@ -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 { SectionLabel } from './SectionLabel'
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 a labelled group the heading is a SectionLabel in a 32px row. */
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
- <div className="mt-2 flex h-8 shrink-0 items-center px-2">
31
- <SectionLabel>Group</SectionLabel>
32
- </div>
33
- <NavItem href="#" label="Item three" count={7} countLabel="7 open" />
34
- <NavItem href="#" label="Item four" count={2} countLabel="2 open" />
35
- <NavItem href="#" label="Item five" />
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 list never scrolls the frame away. */
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
- <div className="mt-2 flex h-8 shrink-0 items-center px-2">
49
- <SectionLabel>Group</SectionLabel>
50
- </div>
51
- {Array.from({ length: 40 }, (_, i) => (
52
- <NavItem key={i} href="#" label={`Item ${i + 2}`} active={i === 2} count={((i * 7) % 9) + 1} countLabel={`${((i * 7) % 9) + 1} open`} />
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
  }
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export { Property, type PropertyProps } from './Property'
49
49
  export { Reaction, type ReactionProps } from './Reaction'
50
50
  export { SearchInput, type SearchInputProps } from './SearchInput'
51
51
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
52
+ export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
52
53
  export { SectionLabel } from './SectionLabel'
53
54
  export { Tabs, type TabDef, type TabsProps } from './Tabs'
54
55
  export { Toast, ToastProvider, useToast, type ToastOptions, type ToastProps, type ToastType } from './Toast'
@@ -68,6 +68,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
68
68
  |---|---|
69
69
  | Label–value rows (a details rail) | **Property** — `row` and `stacked` |
70
70
  | A section heading, chevron, hover actions | **SectionHeader** |
71
+ | A group of rows that opens and closes, and stays how you left it | **CollapsibleSection** — `storageKey` remembers |
71
72
  | The uppercase micro-label | **SectionLabel** |
72
73
  | A hairline | **Divider** |
73
74
  | Where am I, and the way back up | **Breadcrumb** |
@@ -78,7 +79,7 @@ fork freely, owe nothing back, mention it on the package's ticket.
78
79
  | You need | Reach for |
79
80
  |---|---|
80
81
  | Data is on its way | **Skeleton** — shaped like what will arrive |
81
- | Nothing to show | **EmptyState** — says what would fill it |
82
+ | Nothing to show | **EmptyState** — says what would fill it; `page` when the whole page is empty (icon, centred), `section` when one part of it is (the line alone, left) |
82
83
  | It happened, briefly | **Toast**, via `useToast` inside a `ToastProvider` |
83
84
  | It happened, and must not be missed | **Toast** with `durationMs: 0` and an action — it floats and stays until dismissed (D21) |
84
85
  | The whole surface has something to say | **Banner** — the strip under the top bar, full width; `onDismiss` if the reader closes it rather than the app |