@estiva-app/ui 0.10.1 → 0.11.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/Divider.d.ts +15 -1
- package/dist/Divider.d.ts.map +1 -1
- package/dist/Menu.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/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +272 -234
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.tsx +5 -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/Menu.tsx +11 -2
- package/src/Popover.tsx +6 -2
- package/src/PreviewCard.tsx +5 -2
- package/src/Reaction.tsx +6 -1
- package/src/ScrollArea.mdx +59 -0
- package/src/ScrollArea.stories.tsx +75 -0
- package/src/ScrollArea.test.tsx +50 -0
- package/src/ScrollArea.tsx +81 -0
- package/src/Select.tsx +8 -1
- package/src/Sidebar.tsx +8 -5
- package/src/index.ts +1 -0
package/package.json
CHANGED
package/src/AppShell.tsx
CHANGED
|
@@ -76,6 +76,11 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
76
76
|
{nav}
|
|
77
77
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
78
78
|
{banner}
|
|
79
|
+
{/* Not a ScrollArea: the apps' pages scroll inside themselves (Ship's
|
|
80
|
+
detail columns are `h-full` grids with their own regions), and a
|
|
81
|
+
region here kept a bar on Ship's page for overflow that was not
|
|
82
|
+
there (2026-09-09). A page that does scroll here gets its region
|
|
83
|
+
where it scrolls. */}
|
|
79
84
|
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
|
80
85
|
</div>
|
|
81
86
|
</div>
|
package/src/Divider.mdx
CHANGED
|
@@ -20,6 +20,13 @@ carries the `separator` role for assistive tech.
|
|
|
20
20
|
|
|
21
21
|
<Canvas of={DividerStories.Vertical} />
|
|
22
22
|
|
|
23
|
+
- With a `label`, words sit in the middle of the line — a date between two
|
|
24
|
+
days of messages, or where "new since you last read this" begins.
|
|
25
|
+
`tone="warning"` for the second: the label in the warning colour, the
|
|
26
|
+
lines in its wash. The line is named by its label for assistive tech.
|
|
27
|
+
|
|
28
|
+
<Canvas of={DividerStories.Warning} />
|
|
29
|
+
|
|
23
30
|
## When not
|
|
24
31
|
|
|
25
32
|
- Between every row of a list — spacing does that; a hairline per row is
|
|
@@ -34,6 +41,8 @@ import { Divider } from '@estiva-app/ui'
|
|
|
34
41
|
|
|
35
42
|
<Divider />
|
|
36
43
|
<Divider orientation="vertical" />
|
|
44
|
+
<Divider label="Today" />
|
|
45
|
+
<Divider label="New since you last read this" tone="warning" />
|
|
37
46
|
```
|
|
38
47
|
|
|
39
48
|
## Props
|
package/src/Divider.stories.tsx
CHANGED
|
@@ -42,3 +42,28 @@ export const Vertical: Story = {
|
|
|
42
42
|
</div>
|
|
43
43
|
),
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Words in the middle of the line — a date between two days of messages. */
|
|
47
|
+
export const WithALabel: Story = {
|
|
48
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
49
|
+
// the label is muted caption text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
|
|
50
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
51
|
+
render: () => (
|
|
52
|
+
<div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
|
|
53
|
+
<p className="px-3 pb-2">Yesterday's last message</p>
|
|
54
|
+
<Divider label="Today" />
|
|
55
|
+
<p className="px-3 pt-2">Today's first</p>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Asking for attention: where "new since you last read this" begins. The warning colour, because the accent could not be read on Ship's background. */
|
|
61
|
+
export const Warning: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<div className="w-96 rounded-lg border border-border-default bg-bg-surface py-3 text-body-2 text-text-primary">
|
|
64
|
+
<p className="px-3 pb-2">Yes — it re-reads the folder union and the archived one lands last.</p>
|
|
65
|
+
<Divider label="New since you last read this" tone="warning" />
|
|
66
|
+
<p className="px-3 pt-2">Same here, from Peek.</p>
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/** What the Divider page claims, pinned: the role, and a label that names the line. */
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
5
|
+
import { Divider } from './Divider'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
describe('Divider', () => {
|
|
10
|
+
it('is a separator, horizontal by default', () => {
|
|
11
|
+
render(<Divider />)
|
|
12
|
+
expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('horizontal')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('with a label, the separator is named by it and the words are drawn once', () => {
|
|
16
|
+
render(<Divider label="New since you last read this" tone="warning" />)
|
|
17
|
+
const rule = screen.getByRole('separator', { name: 'New since you last read this' })
|
|
18
|
+
expect(rule.textContent).toBe('New since you last read this')
|
|
19
|
+
expect(rule.querySelectorAll('[aria-hidden="true"]')).toHaveLength(2)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('vertical ignores a label — there is no middle to put it in', () => {
|
|
23
|
+
render(<Divider orientation="vertical" label="Nope" />)
|
|
24
|
+
expect(screen.getByRole('separator').textContent).toBe('')
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/Divider.tsx
CHANGED
|
@@ -8,10 +8,39 @@ import { cn } from './cn'
|
|
|
8
8
|
*/
|
|
9
9
|
export interface DividerProps {
|
|
10
10
|
orientation?: 'horizontal' | 'vertical'
|
|
11
|
+
/**
|
|
12
|
+
* Words in the middle of the line — "New since you last read this", a
|
|
13
|
+
* date. Horizontal only; the line splits around the label.
|
|
14
|
+
*/
|
|
15
|
+
label?: string
|
|
16
|
+
/**
|
|
17
|
+
* `warning` for a line that asks for attention: the label in the warning
|
|
18
|
+
* colour, the lines in its wash. Peek and Ship both drew their "new since
|
|
19
|
+
* you last read" rule in the accent, which measures 3.3:1 on Ship's
|
|
20
|
+
* background and could not be read (Katerina, 2026-09-08); the warning
|
|
21
|
+
* colour measures 9.6:1 there. Default: the muted text, `border-subtle`
|
|
22
|
+
* lines, for a date.
|
|
23
|
+
*/
|
|
24
|
+
tone?: 'default' | 'warning'
|
|
11
25
|
className?: string
|
|
12
26
|
}
|
|
13
27
|
|
|
14
|
-
export function Divider({ orientation = 'horizontal', className }: DividerProps) {
|
|
28
|
+
export function Divider({ orientation = 'horizontal', label, tone = 'default', className }: DividerProps) {
|
|
29
|
+
if (label && orientation === 'horizontal') {
|
|
30
|
+
const line = tone === 'warning' ? 'bg-warning-muted' : 'bg-border-subtle'
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role="separator"
|
|
34
|
+
aria-orientation="horizontal"
|
|
35
|
+
aria-label={label}
|
|
36
|
+
className={cn('flex shrink-0 items-center gap-2 mx-3', className)}
|
|
37
|
+
>
|
|
38
|
+
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
39
|
+
<span className={cn('shrink-0 text-caption', tone === 'warning' ? 'text-warning-default' : 'text-text-muted')}>{label}</span>
|
|
40
|
+
<span aria-hidden="true" className={cn('h-px flex-1', line)} />
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
15
44
|
return (
|
|
16
45
|
<div
|
|
17
46
|
role="separator"
|
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/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,59 @@
|
|
|
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
|
+
|
|
45
|
+
## Keys
|
|
46
|
+
|
|
47
|
+
| Key | Does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| ↑ ↓ | scroll the viewport when it has focus, as any scrolling box |
|
|
50
|
+
| Page Up / Down, Home, End | as any scrolling box |
|
|
51
|
+
|
|
52
|
+
## Gaps, stated
|
|
53
|
+
|
|
54
|
+
- The bar has no arrows and no click-on-track paging; the wheel, the keys
|
|
55
|
+
and dragging the thumb are the ways to move.
|
|
56
|
+
|
|
57
|
+
## Props
|
|
58
|
+
|
|
59
|
+
<Controls of={ScrollAreaStories.Default} />
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
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`: 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)}>
|
|
61
|
+
{/* Base UI gives the content box `min-width: fit-content`, which is
|
|
62
|
+
right when the region scrolls sideways and wrong when it does not:
|
|
63
|
+
a row's `truncate` needs a box no wider than the viewport. */}
|
|
64
|
+
<BaseScrollArea.Content className={contentClassName} style={horizontal ? undefined : { minWidth: 0 }}>
|
|
65
|
+
{children}
|
|
66
|
+
</BaseScrollArea.Content>
|
|
67
|
+
</BaseScrollArea.Viewport>
|
|
68
|
+
{vertical && (
|
|
69
|
+
<BaseScrollArea.Scrollbar orientation="vertical" className={cn(BAR, 'w-1.5 justify-center py-0.5 pr-0.5')}>
|
|
70
|
+
<BaseScrollArea.Thumb className={cn(THUMB, 'w-full')} />
|
|
71
|
+
</BaseScrollArea.Scrollbar>
|
|
72
|
+
)}
|
|
73
|
+
{horizontal && (
|
|
74
|
+
<BaseScrollArea.Scrollbar orientation="horizontal" className={cn(BAR, 'h-1.5 flex-col justify-center px-0.5 pb-0.5')}>
|
|
75
|
+
<BaseScrollArea.Thumb className={cn(THUMB, 'h-full')} />
|
|
76
|
+
</BaseScrollArea.Scrollbar>
|
|
77
|
+
)}
|
|
78
|
+
{vertical && horizontal && <BaseScrollArea.Corner />}
|
|
79
|
+
</BaseScrollArea.Root>
|
|
80
|
+
)
|
|
81
|
+
}
|
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="
|
|
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.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
|
-
{
|
|
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'
|