@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
@@ -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
+ })
@@ -3,20 +3,37 @@ import { IconMessage2 } from '@tabler/icons-react'
3
3
  import { cn } from './cn'
4
4
 
5
5
  /**
6
- * Peek's EmptyState (2026-08-28), verbatim: a 16px icon over a centred line
7
- * of secondary text. The message is the caller's — a shared component has no
8
- * words of its own for what is missing.
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-col gap-2 items-center', className)}>
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/Menu.tsx CHANGED
@@ -3,6 +3,7 @@ import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { Menu as BaseMenu } from '@base-ui/react/menu'
4
4
  import { cn } from './cn'
5
5
  import { triggerDisabled } from './triggerDisabled'
6
+ import { ScrollArea } from './ScrollArea'
6
7
  import { Kbd } from './Kbd'
7
8
  import { SectionLabel } from './SectionLabel'
8
9
 
@@ -207,10 +208,18 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
207
208
  room. Select's 288 was never this component's — the identity
208
209
  panel got it by accident once and grew a scrollbar at full
209
210
  height. */
210
- className={cn('min-w-[180px] max-h-[var(--available-height)] overflow-y-auto outline-none', className)}
211
+ className={cn('min-w-[180px] outline-none', className)}
211
212
  render={<MenuPanel />}
212
213
  >
213
- <MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
214
+ {/* The height cap sits on the box that scrolls — on the panel it
215
+ let the box grow to its content and nothing scrolled (measured,
216
+ 2026-09-08) — less the panel's own padding, so the panel still
217
+ stops where Floating UI said. The padding stays on the panel,
218
+ where a caller's `className` can change it; the divider rule
219
+ moves with the rows. A menu that fits draws exactly as before. */}
220
+ <ScrollArea viewportClassName="max-h-[calc(var(--available-height)_-_1rem)]" contentClassName="flex flex-col [&>[role=separator]]:mx-0">
221
+ <MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
222
+ </ScrollArea>
214
223
  </BaseMenu.Popup>
215
224
  </BaseMenu.Positioner>
216
225
  </BaseMenu.Portal>
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
- <span className="shrink-0 font-mono text-caption tabular-nums text-text-muted">{count}</span>
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/Popover.tsx CHANGED
@@ -2,6 +2,7 @@ import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'reac
2
2
  import { Popover as BasePopover } from '@base-ui/react/popover'
3
3
  import { cn } from './cn'
4
4
  import { triggerDisabled } from './triggerDisabled'
5
+ import { ScrollArea } from './ScrollArea'
5
6
  import { MenuPanel } from './Menu'
6
7
 
7
8
  /**
@@ -144,10 +145,13 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
144
145
  */
145
146
  initialFocus={trigger ? undefined : false}
146
147
  finalFocus={finalFocus}
147
- className={cn('min-w-[180px] max-h-[var(--available-height)] overflow-y-auto outline-none', className)}
148
+ className={cn('min-w-[180px] outline-none', className)}
148
149
  render={<MenuPanel />}
149
150
  >
150
- {children}
151
+ {/* As in Menu: the cap on the scrolling box, less the panel's padding; the padding stays on the panel. */}
152
+ <ScrollArea viewportClassName="max-h-[calc(var(--available-height)_-_1rem)]" contentClassName="flex flex-col [&>[role=separator]]:mx-0">
153
+ {children}
154
+ </ScrollArea>
151
155
  </BasePopover.Popup>
152
156
  </BasePopover.Positioner>
153
157
  </BasePopover.Portal>
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { PreviewCard as BasePreviewCard } from '@base-ui/react/preview-card'
3
3
  import { cn } from './cn'
4
+ import { ScrollArea } from './ScrollArea'
4
5
  import { MenuPanel } from './Menu'
5
6
 
6
7
  /**
@@ -79,10 +80,12 @@ export function PreviewCard({ content, children, side = 'right', delay = OPEN_DE
79
80
  className="z-50 data-[anchor-hidden]:hidden"
80
81
  >
81
82
  <BasePreviewCard.Popup
82
- className={cn('w-[360px] max-h-[min(300px,var(--available-height))] gap-3 overflow-y-auto p-3 outline-none [&>*]:shrink-0', className)}
83
+ className={cn('w-[360px] p-3 outline-none', className)}
83
84
  render={<MenuPanel />}
84
85
  >
85
- {content}
86
+ <ScrollArea viewportClassName="max-h-[calc(min(300px,var(--available-height))_-_1.5rem)]" contentClassName="flex flex-col gap-3 [&>*]:shrink-0">
87
+ {content}
88
+ </ScrollArea>
86
89
  </BasePreviewCard.Popup>
87
90
  </BasePreviewCard.Positioner>
88
91
  </BasePreviewCard.Portal>
package/src/Reaction.tsx CHANGED
@@ -65,7 +65,12 @@ export function Reaction({ emoji, count, pressed = false, className, type, ...pr
65
65
  'border transition-colors',
66
66
  'disabled:cursor-not-allowed disabled:opacity-50',
67
67
  pressed
68
- ? 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover'
68
+ ? /* In the ship theme the accent on its own wash measures 2.70:1
69
+ (Finding 4), and the count was the thing that vanished
70
+ (Katerina, 2026-09-08). The number reads in the text colour
71
+ there; the accent keeps the edge and the fill. Signal keeps
72
+ Peek's blue-on-wash, which reads. */
73
+ 'border-accent-primary bg-accent-muted text-accent-primary hover:border-accent-hover ship:text-text-primary'
69
74
  : 'border-border-default bg-bg-inset text-text-primary hover:border-border-strong hover:bg-bg-hover',
70
75
  className,
71
76
  )}
@@ -0,0 +1,65 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ScrollAreaStories from './ScrollArea.stories'
3
+
4
+ <Meta of={ScrollAreaStories} />
5
+
6
+ # ScrollArea
7
+
8
+ A region that scrolls without taking width for its scrollbar. The bar is
9
+ drawn over the content and shows while the pointer is over the region or
10
+ the content is moving; nothing moves when it appears.
11
+
12
+ <Canvas of={ScrollAreaStories.Default} />
13
+
14
+ ## When
15
+
16
+ - Any box that can hold more than fits: a menu or a dropdown list, a panel,
17
+ a sidebar, the page's content column, a rail.
18
+ - `orientation="horizontal"` for a row wider than its box — a table, a
19
+ board; `"both"` when a thing can run over either way.
20
+
21
+ <Canvas of={ScrollAreaStories.Horizontal} />
22
+
23
+ ## When not
24
+
25
+ - A box that never overflows. A ScrollArea around it changes nothing, and
26
+ is one more element.
27
+ - The page itself. The browser owns that bar.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { ScrollArea } from '@estiva-app/ui'
33
+
34
+ <ScrollArea className="h-[240px]" viewportClassName="p-2">
35
+ {rows}
36
+ </ScrollArea>
37
+ ```
38
+
39
+ - `className` sizes and places the region; `viewportClassName` styles the
40
+ scrolling box inside it — padding, gap, the layout of the content.
41
+ - The region needs a height (or a `max-h-*`) to have anything to scroll.
42
+ - The native scrollbar is hidden by Base UI; a native `overflow-y-auto` on
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.
50
+
51
+ ## Keys
52
+
53
+ | Key | Does |
54
+ |---|---|
55
+ | ↑ ↓ | scroll the viewport when it has focus, as any scrolling box |
56
+ | Page Up / Down, Home, End | as any scrolling box |
57
+
58
+ ## Gaps, stated
59
+
60
+ - The bar has no arrows and no click-on-track paging; the wheel, the keys
61
+ and dragging the thumb are the ways to move.
62
+
63
+ ## Props
64
+
65
+ <Controls of={ScrollAreaStories.Default} />
@@ -0,0 +1,104 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { ScrollArea } from './ScrollArea'
3
+
4
+ /** A region that scrolls without taking width for its scrollbar. */
5
+ const meta = {
6
+ title: 'Layout/ScrollArea',
7
+ component: ScrollArea,
8
+ parameters: { controls: { disable: true } },
9
+ args: { children: null },
10
+ } satisfies Meta<typeof ScrollArea>
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof meta>
14
+
15
+ const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
16
+
17
+ /** A list taller than its box. The bar shows while the pointer is over it or the list is moving. */
18
+ export const Default: Story = {
19
+ render: () => (
20
+ <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
21
+ <ul className="flex flex-col gap-px">
22
+ {rows.map((row) => (
23
+ <li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
24
+ {row}
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ </ScrollArea>
29
+ ),
30
+ }
31
+
32
+ /** Nothing to scroll: the region draws exactly as a plain box would, and no bar. */
33
+ export const Fits: Story = {
34
+ render: () => (
35
+ <ScrollArea className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
36
+ <ul className="flex flex-col gap-px">
37
+ {rows.slice(0, 4).map((row) => (
38
+ <li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
39
+ {row}
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </ScrollArea>
44
+ ),
45
+ }
46
+
47
+ /** A row wider than its box — a table, a board. */
48
+ export const Horizontal: Story = {
49
+ render: () => (
50
+ <ScrollArea orientation="horizontal" className="w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
51
+ <div className="flex w-max gap-2">
52
+ {rows.slice(0, 12).map((row) => (
53
+ <div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
54
+ {row}
55
+ </div>
56
+ ))}
57
+ </div>
58
+ </ScrollArea>
59
+ ),
60
+ }
61
+
62
+ /** Both ways, with the corner where the two bars would meet. */
63
+ export const Both: Story = {
64
+ render: () => (
65
+ <ScrollArea orientation="both" className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface" viewportClassName="p-2">
66
+ <div className="flex w-max flex-col gap-px">
67
+ {rows.map((row) => (
68
+ <div key={row} className="w-[480px] rounded-md px-2 py-1.5 text-body-2 text-text-primary">
69
+ {row} — a line long enough to run past the box
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </ScrollArea>
74
+ ),
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
+ }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What jsdom can see of the ScrollArea page: the content renders inside the
4
+ * region, and the region is Base UI's. Whether the bar takes width, and when
5
+ * it shows, is measured in Chrome (2026-09-08: the viewport keeps its full
6
+ * width with 40 rows overflowing; the native bar is hidden).
7
+ */
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { cleanup, render, screen } from '@testing-library/react'
10
+ import { ScrollArea } from './ScrollArea'
11
+
12
+ afterEach(cleanup)
13
+
14
+ describe('ScrollArea', () => {
15
+ it('draws the content inside a viewport of its own', () => {
16
+ const { container } = render(
17
+ <ScrollArea className="h-40">
18
+ <p>Inside</p>
19
+ </ScrollArea>,
20
+ )
21
+ const inside = screen.getByText('Inside')
22
+ expect(container.firstElementChild).not.toBe(inside)
23
+ expect(container.firstElementChild!.contains(inside)).toBe(true)
24
+ expect(container.firstElementChild!.className).toContain('h-40')
25
+ })
26
+
27
+ it('puts each class prop on its own box: the region, the viewport, the content', () => {
28
+ const { container } = render(
29
+ <ScrollArea className="h-40" viewportClassName="max-h-20" contentClassName="p-2">
30
+ <p>Inside</p>
31
+ </ScrollArea>,
32
+ )
33
+ const region = container.firstElementChild!
34
+ const content = screen.getByText('Inside').parentElement!
35
+ const viewport = content.parentElement!
36
+ expect(region.className).toContain('h-40')
37
+ expect(viewport.className).toContain('max-h-20')
38
+ expect(content.className).toContain('p-2')
39
+ expect(viewport.parentElement).toBe(region)
40
+ })
41
+
42
+ it('keeps a vertical region no wider than itself, and lets a sideways one grow', () => {
43
+ const v = render(<ScrollArea><p>Inside</p></ScrollArea>)
44
+ expect((screen.getByText('Inside').parentElement as HTMLElement).style.minWidth).toBe('0px')
45
+ cleanup()
46
+ render(<ScrollArea orientation="horizontal"><p>Inside</p></ScrollArea>)
47
+ expect((screen.getByText('Inside').parentElement as HTMLElement).style.minWidth).toBe('fit-content')
48
+ void v
49
+ })
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
+ })
@@ -0,0 +1,94 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area'
3
+ import { cn } from './cn'
4
+
5
+ /**
6
+ * A region that scrolls without taking width for its scrollbar.
7
+ *
8
+ * A native scrollbar is part of the layout: the moment content overflows, the
9
+ * bar takes its width from the content, the text column narrows, and the
10
+ * right-hand padding looks wider than the left. Katerina saw it in every
11
+ * scrolling surface of both apps (2026-09-08). Base UI's `ScrollArea` hides
12
+ * the native bar and draws its own over the content, so nothing moves when
13
+ * it appears; the bar shows while the pointer is over the region or the
14
+ * content is moving, and fades otherwise.
15
+ *
16
+ * Three boxes, and each class prop lands on one:
17
+ *
18
+ * - the **region** (`className`): its size and placement — `h-[240px]`, a
19
+ * grid cell, `flex-1 min-h-0`;
20
+ * - the **viewport** (`viewportClassName`): the box that scrolls — a
21
+ * `max-h-*` cap goes here, and nowhere else, because a cap on the region
22
+ * lets the viewport grow to its content and nothing scrolls (measured,
23
+ * 2026-09-08);
24
+ * - the **content** (`contentClassName`): the box the children sit in — the
25
+ * padding, the gap, `flex flex-col`. Base UI watches this box for size
26
+ * changes; without it the bar kept showing on Ship's page for overflow that
27
+ * was no longer there (measured, 2026-09-09).
28
+ *
29
+ * The bar is `border-strong` on nothing, 6px wide, inset 2px from the edge —
30
+ * the thin scrollbar both apps had styled by hand in their `index.css`, drawn
31
+ * once here instead.
32
+ *
33
+ * `orientation` says which way the region scrolls; a table that is wider
34
+ * than its box scrolls `horizontal`, a list `vertical` (the default), a board
35
+ * `both`. A vertical region keeps its content no wider than itself, so a
36
+ * long label still truncates.
37
+ */
38
+ export interface ScrollAreaProps {
39
+ orientation?: 'vertical' | 'horizontal' | 'both'
40
+ /** On the region: its size and placement. */
41
+ className?: string
42
+ /** On the viewport, the box that scrolls: a `max-h-*` cap. */
43
+ viewportClassName?: string
44
+ /** On the content, the box the children sit in: padding, gap, layout. */
45
+ contentClassName?: string
46
+ children: ReactNode
47
+ }
48
+
49
+ const BAR = 'flex touch-none select-none rounded-full opacity-0 transition-opacity delay-300 data-[hovering]:opacity-100 data-[hovering]:delay-0 data-[scrolling]:opacity-100 data-[scrolling]:delay-0'
50
+ const THUMB = 'rounded-full bg-border-strong'
51
+
52
+ export function ScrollArea({ orientation = 'vertical', className, viewportClassName, contentClassName, children }: ScrollAreaProps) {
53
+ const vertical = orientation !== 'horizontal'
54
+ const horizontal = orientation !== 'vertical'
55
+ return (
56
+ <BaseScrollArea.Root className={cn('relative min-h-0 min-w-0', className)}>
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
+ >
74
+ {/* Base UI gives the content box `min-width: fit-content`, which is
75
+ right when the region scrolls sideways and wrong when it does not:
76
+ a row's `truncate` needs a box no wider than the viewport. */}
77
+ <BaseScrollArea.Content className={contentClassName} style={horizontal ? undefined : { minWidth: 0 }}>
78
+ {children}
79
+ </BaseScrollArea.Content>
80
+ </BaseScrollArea.Viewport>
81
+ {vertical && (
82
+ <BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5')}>
83
+ <BaseScrollArea.Thumb className={cn(THUMB, 'w-full')} />
84
+ </BaseScrollArea.Scrollbar>
85
+ )}
86
+ {horizontal && (
87
+ <BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5')}>
88
+ <BaseScrollArea.Thumb className={cn(THUMB, 'h-full')} />
89
+ </BaseScrollArea.Scrollbar>
90
+ )}
91
+ {vertical && horizontal && <BaseScrollArea.Corner />}
92
+ </BaseScrollArea.Root>
93
+ )
94
+ }
@@ -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