@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.
- 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/Divider.d.ts +15 -1
- package/dist/Divider.d.ts.map +1 -1
- package/dist/EmptyState.d.ts +19 -5
- package/dist/EmptyState.d.ts.map +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/NavItem.d.ts.map +1 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/PreviewCard.d.ts.map +1 -1
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ScrollArea.d.ts +46 -0
- package/dist/ScrollArea.d.ts.map +1 -0
- package/dist/SectionHeader.d.ts +31 -10
- package/dist/SectionHeader.d.ts.map +1 -1
- package/dist/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +375 -266
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.tsx +5 -0
- 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/Divider.mdx +9 -0
- package/src/Divider.stories.tsx +25 -0
- package/src/Divider.test.tsx +26 -0
- package/src/Divider.tsx +30 -1
- package/src/EmptyState.mdx +30 -11
- package/src/EmptyState.stories.tsx +7 -3
- package/src/EmptyState.test.tsx +39 -0
- package/src/EmptyState.tsx +23 -6
- package/src/Menu.tsx +11 -2
- package/src/NavItem.tsx +5 -1
- package/src/Popover.tsx +6 -2
- package/src/PreviewCard.tsx +5 -2
- package/src/Reaction.tsx +6 -1
- package/src/ScrollArea.mdx +65 -0
- package/src/ScrollArea.stories.tsx +104 -0
- package/src/ScrollArea.test.tsx +65 -0
- package/src/ScrollArea.tsx +94 -0
- 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/Select.tsx +8 -1
- package/src/Sidebar.mdx +14 -10
- package/src/Sidebar.stories.tsx +17 -15
- package/src/Sidebar.tsx +8 -5
- package/src/index.ts +2 -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/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]
|
|
211
|
+
className={cn('min-w-[180px] outline-none', className)}
|
|
211
212
|
render={<MenuPanel />}
|
|
212
213
|
>
|
|
213
|
-
|
|
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
|
-
|
|
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]
|
|
148
|
+
className={cn('min-w-[180px] outline-none', className)}
|
|
148
149
|
render={<MenuPanel />}
|
|
149
150
|
>
|
|
150
|
-
{
|
|
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>
|
package/src/PreviewCard.tsx
CHANGED
|
@@ -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]
|
|
83
|
+
className={cn('w-[360px] p-3 outline-none', className)}
|
|
83
84
|
render={<MenuPanel />}
|
|
84
85
|
>
|
|
85
|
-
|
|
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
|
-
?
|
|
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
|
+
}
|
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
|
|