@estiva-app/ui 0.10.1 → 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 (63) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/Breadcrumb.d.ts +7 -1
  3. package/dist/Breadcrumb.d.ts.map +1 -1
  4. package/dist/Chip.d.ts.map +1 -1
  5. package/dist/CollapsibleSection.d.ts +42 -0
  6. package/dist/CollapsibleSection.d.ts.map +1 -0
  7. package/dist/Divider.d.ts +15 -1
  8. package/dist/Divider.d.ts.map +1 -1
  9. package/dist/EmptyState.d.ts +19 -5
  10. package/dist/EmptyState.d.ts.map +1 -1
  11. package/dist/Menu.d.ts.map +1 -1
  12. package/dist/NavItem.d.ts.map +1 -1
  13. package/dist/Popover.d.ts.map +1 -1
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/Reaction.d.ts.map +1 -1
  16. package/dist/ScrollArea.d.ts +46 -0
  17. package/dist/ScrollArea.d.ts.map +1 -0
  18. package/dist/SectionHeader.d.ts +31 -10
  19. package/dist/SectionHeader.d.ts.map +1 -1
  20. package/dist/Select.d.ts.map +1 -1
  21. package/dist/Sidebar.d.ts.map +1 -1
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +375 -266
  25. package/dist/index.js.map +4 -4
  26. package/package.json +1 -1
  27. package/src/AppShell.tsx +5 -0
  28. package/src/Breadcrumb.mdx +6 -0
  29. package/src/Breadcrumb.stories.tsx +12 -0
  30. package/src/Breadcrumb.test.tsx +10 -0
  31. package/src/Breadcrumb.tsx +17 -2
  32. package/src/Chip.tsx +10 -2
  33. package/src/CollapsibleSection.mdx +72 -0
  34. package/src/CollapsibleSection.stories.tsx +38 -0
  35. package/src/CollapsibleSection.test.tsx +81 -0
  36. package/src/CollapsibleSection.tsx +92 -0
  37. package/src/Divider.mdx +9 -0
  38. package/src/Divider.stories.tsx +25 -0
  39. package/src/Divider.test.tsx +26 -0
  40. package/src/Divider.tsx +30 -1
  41. package/src/EmptyState.mdx +30 -11
  42. package/src/EmptyState.stories.tsx +7 -3
  43. package/src/EmptyState.test.tsx +39 -0
  44. package/src/EmptyState.tsx +23 -6
  45. package/src/Menu.tsx +11 -2
  46. package/src/NavItem.tsx +5 -1
  47. package/src/Popover.tsx +6 -2
  48. package/src/PreviewCard.tsx +5 -2
  49. package/src/Reaction.tsx +6 -1
  50. package/src/ScrollArea.mdx +65 -0
  51. package/src/ScrollArea.stories.tsx +104 -0
  52. package/src/ScrollArea.test.tsx +65 -0
  53. package/src/ScrollArea.tsx +94 -0
  54. package/src/SectionHeader.mdx +26 -17
  55. package/src/SectionHeader.stories.tsx +4 -35
  56. package/src/SectionHeader.test.tsx +40 -0
  57. package/src/SectionHeader.tsx +70 -39
  58. package/src/Select.tsx +8 -1
  59. package/src/Sidebar.mdx +14 -10
  60. package/src/Sidebar.stories.tsx +17 -15
  61. package/src/Sidebar.tsx +8 -5
  62. package/src/index.ts +2 -0
  63. package/stories/Choosing.mdx +2 -1
@@ -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/Select.tsx CHANGED
@@ -2,6 +2,7 @@ import { IconCheck, IconChevronDown } from '@tabler/icons-react'
2
2
  import { Select as BaseSelect } from '@base-ui/react/select'
3
3
  import type { ReactNode } from 'react'
4
4
  import { cn } from './cn'
5
+ import { ScrollArea } from './ScrollArea'
5
6
 
6
7
  /**
7
8
  * Peek's Select (2026-08-28), verbatim, plus what Ship added: an option may
@@ -125,8 +126,13 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
125
126
  clamped it — the two numbers `fitMenu` used to compute here. The
126
127
  288px is the old `max-h-72`, now a ceiling on that room rather
127
128
  than a height applied blind. */
128
- className="max-h-[min(288px,var(--available-height))] min-w-[var(--anchor-width)] overflow-y-auto rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
129
+ className="min-w-[var(--anchor-width)] rounded-lg border border-border-default bg-bg-elevated p-1 shadow-lg"
129
130
  >
131
+ {/* The list scrolls in a ScrollArea: the bar takes no width, so a
132
+ long list is exactly as wide as a short one (Katerina,
133
+ 2026-09-08). The cap sits on the box that scrolls, less the
134
+ panel's padding, so 288px stays 288px. */}
135
+ <ScrollArea viewportClassName="max-h-[calc(min(288px,var(--available-height))_-_0.5rem)]" contentClassName="flex flex-col">
130
136
  {options.map((option) => (
131
137
  <BaseSelect.Item
132
138
  key={option.value}
@@ -146,6 +152,7 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
146
152
  />
147
153
  </BaseSelect.Item>
148
154
  ))}
155
+ </ScrollArea>
149
156
  </BaseSelect.Popup>
150
157
  </BaseSelect.Positioner>
151
158
  </BaseSelect.Portal>
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/Sidebar.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
+ import { ScrollArea } from './ScrollArea'
3
4
 
4
5
  /**
5
6
  * The navigation column: 240px, a hairline on its right, the surface
@@ -21,12 +22,14 @@ export function Sidebar({ 'aria-label': ariaLabel = 'Workspace', children, class
21
22
  return (
22
23
  <nav
23
24
  aria-label={ariaLabel}
24
- className={cn(
25
- 'flex w-60 shrink-0 flex-col gap-px overflow-y-auto border-r border-border-default bg-bg-surface px-2.5 py-3',
26
- className,
27
- )}
25
+ className={cn('flex w-60 shrink-0 flex-col border-r border-border-default bg-bg-surface', className)}
28
26
  >
29
- {children}
27
+ {/* The rows scroll in a ScrollArea: the bar takes no width, so the
28
+ column's padding reads the same with a long list as with a short
29
+ one (Katerina, 2026-09-08). The padding and the gap are the box's. */}
30
+ <ScrollArea className="min-h-0 flex-1" contentClassName="flex flex-col gap-px px-2.5 py-3">
31
+ {children}
32
+ </ScrollArea>
30
33
  </nav>
31
34
  )
32
35
  }
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export { Breadcrumb, type BreadcrumbProps, type Crumb } from './Breadcrumb'
35
35
  export { EnterHint, Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub, type MenuItemProps, type MenuPanelProps, type MenuProps, type MenuSubProps } from './Menu'
36
36
  export { NavItem, type NavItemProps } from './NavItem'
37
37
  export { Popover, type PopoverProps } from './Popover'
38
+ export { ScrollArea, type ScrollAreaProps } from './ScrollArea'
38
39
  export { ReactionPicker, type ReactionOption, type ReactionPickerProps } from './ReactionPicker'
39
40
  export { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator, type ToolbarProps, type ToolbarButtonProps, type ToolbarInputProps } from './Toolbar'
40
41
  export { PreviewCard, type PreviewCardProps } from './PreviewCard'
@@ -48,6 +49,7 @@ export { Property, type PropertyProps } from './Property'
48
49
  export { Reaction, type ReactionProps } from './Reaction'
49
50
  export { SearchInput, type SearchInputProps } from './SearchInput'
50
51
  export { SectionHeader, type SectionAction, type SectionHeaderProps } from './SectionHeader'
52
+ export { CollapsibleSection, type CollapsibleSectionProps } from './CollapsibleSection'
51
53
  export { SectionLabel } from './SectionLabel'
52
54
  export { Tabs, type TabDef, type TabsProps } from './Tabs'
53
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 |