@estiva-app/ui 0.10.0 → 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.
@@ -0,0 +1,10 @@
1
+ import type { ReactElement } from 'react';
2
+ /**
3
+ * Whether the element a `Menu` or `Popover` was handed as its trigger is
4
+ * disabled — by `disabled`, or by a `disabledReason` (which disables the
5
+ * button and keeps it reachable). Base UI's trigger parts keep a disabled
6
+ * state of their own and write it over the rendered button's, so they have to
7
+ * be told (Finding 39, 2026-09-08).
8
+ */
9
+ export declare function triggerDisabled(trigger: ReactElement): boolean;
10
+ //# sourceMappingURL=triggerDisabled.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"triggerDisabled.d.ts","sourceRoot":"","sources":["../src/triggerDisabled.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAA;AAEzC;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAG9D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
@@ -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"
@@ -192,16 +192,44 @@ describe('Field', () => {
192
192
  expect(document.getElementById(describedBy!)?.textContent).toBe('That is not a Folder link.')
193
193
  })
194
194
 
195
- it('renders no line, and no wrapper, when there is neither', () => {
196
- // The port must not move a pixel for the callers that predate these props,
197
- // so the control stays a direct child of the Field exactly as before.
198
- const { container } = render(
199
- <Field label="Title">
195
+ it('keeps the same control, and its focus, when an error appears and clears', () => {
196
+ // Finding 38 (Ship's adoption, 2026-09-08): the control used to be wrapped
197
+ // only when there was a line under it, so React re-created it the moment
198
+ // an error appeared or cleared — and a person typing into a field whose
199
+ // error clears on input lost focus after the first keystroke. The control
200
+ // sits in the same place now whatever is under it.
201
+ const { rerender } = render(
202
+ <Field label="Folder link">
203
+ <TextInput defaultValue="" />
204
+ </Field>,
205
+ )
206
+ const input = screen.getByLabelText('Folder link')
207
+ input.focus()
208
+ rerender(
209
+ <Field label="Folder link" error="That is not a Folder link.">
210
+ <TextInput defaultValue="" />
211
+ </Field>,
212
+ )
213
+ expect(screen.getByLabelText('Folder link')).toBe(input)
214
+ expect(document.activeElement).toBe(input)
215
+ rerender(
216
+ <Field label="Folder link">
217
+ <TextInput defaultValue="" />
218
+ </Field>,
219
+ )
220
+ expect(screen.getByLabelText('Folder link')).toBe(input)
221
+ expect(document.activeElement).toBe(input)
222
+ })
223
+
224
+ it('keeps the required asterisk out of the accessible name', () => {
225
+ // Ship's test asked for a combobox named "Project" and found "Project*"
226
+ // (Finding 38). The asterisk is a picture; `aria-required` is the word.
227
+ render(
228
+ <Field label="Title" required>
200
229
  <TextInput defaultValue="" />
201
230
  </Field>,
202
231
  )
203
- const root = container.firstElementChild!
204
- expect(root.children).toHaveLength(2)
205
- expect(root.children[1].tagName).toBe('INPUT')
232
+ expect(screen.getByRole('textbox', { name: 'Title' })).toBeTruthy()
233
+ expect(screen.getByText('*').getAttribute('aria-hidden')).toBe('true')
206
234
  })
207
235
  })
package/src/Field.tsx CHANGED
@@ -58,7 +58,7 @@ export interface FieldProps {
58
58
  }
59
59
 
60
60
  export function Field({ label, required = false, helper, error, children }: FieldProps) {
61
- const line = error ?? helper
61
+
62
62
  /*
63
63
  The asterisk is a picture of `required`; this is the word for it. Base UI's
64
64
  `Field` has no `required` to propagate, so the control is marked here —
@@ -80,27 +80,34 @@ export function Field({ label, required = false, helper, error, children }: Fiel
80
80
  `cn.test.ts` pins that. */}
81
81
  <BaseField.Label className={cn('text-input-label text-text-primary', required && 'flex items-center')}>
82
82
  {label}
83
- {required && <span className="text-error-default ml-0.5">*</span>}
83
+ {/* A picture, not a word: `aria-required` on the control says it, and
84
+ without this the control was named "Title*" (Finding 38). */}
85
+ {required && (
86
+ <span aria-hidden="true" className="text-error-default ml-0.5">
87
+ *
88
+ </span>
89
+ )}
84
90
  </BaseField.Label>
85
91
  {/*
86
- No line, no wrapper: every caller that predates `helper` and `error`
87
- keeps the exact DOM it had, so the port cannot move a pixel. With a
88
- line, this is the 6px stack Ship and Peek were both writing by hand.
92
+ The control sits in the same place whether or not there is a line
93
+ under it. It used to be wrapped only when there was one, and React
94
+ then re-created the control the moment an error appeared or cleared
95
+ so a person typing into a field whose error clears on input lost
96
+ focus after the first keystroke (found by Ship's adoption, 2026-09-08,
97
+ Finding 38). A one-child flex column draws exactly as the bare control
98
+ did; the 6px stack Ship and Peek both wrote by hand appears only with a
99
+ line.
89
100
  */}
90
- {line == null ? (
91
- control
92
- ) : (
93
- <div className="flex flex-col gap-1.5">
94
- {control}
95
- {error != null ? (
96
- <BaseField.Error match className="text-caption text-error-default">
97
- {error}
98
- </BaseField.Error>
99
- ) : (
100
- <BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
101
- )}
102
- </div>
103
- )}
101
+ <div className="flex flex-col gap-1.5">
102
+ {control}
103
+ {error != null ? (
104
+ <BaseField.Error match className="text-caption text-error-default">
105
+ {error}
106
+ </BaseField.Error>
107
+ ) : helper != null ? (
108
+ <BaseField.Description className="text-caption text-text-muted">{helper}</BaseField.Description>
109
+ ) : null}
110
+ </div>
104
111
  </BaseField.Root>
105
112
  )
106
113
  }
package/src/Menu.tsx CHANGED
@@ -2,6 +2,8 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { Menu as BaseMenu } from '@base-ui/react/menu'
4
4
  import { cn } from './cn'
5
+ import { triggerDisabled } from './triggerDisabled'
6
+ import { ScrollArea } from './ScrollArea'
5
7
  import { Kbd } from './Kbd'
6
8
  import { SectionLabel } from './SectionLabel'
7
9
 
@@ -173,6 +175,11 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
173
175
  >
174
176
  <BaseMenu.Trigger
175
177
  render={trigger}
178
+ /* The part writes its own disabled state over the button's, so a
179
+ disabled trigger came out `aria-disabled="false"` and opened
180
+ (Finding 39, Ship's adoption, 2026-09-08). It is told what the
181
+ button already knows. */
182
+ disabled={triggerDisabled(trigger)}
176
183
  openOnHover={openOnHover}
177
184
  delay={HOVER_OPEN_DELAY}
178
185
  closeDelay={HOVER_CLOSE_DELAY}
@@ -201,10 +208,18 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
201
208
  room. Select's 288 was never this component's — the identity
202
209
  panel got it by accident once and grew a scrollbar at full
203
210
  height. */
204
- 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)}
205
212
  render={<MenuPanel />}
206
213
  >
207
- <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>
208
223
  </BaseMenu.Popup>
209
224
  </BaseMenu.Positioner>
210
225
  </BaseMenu.Portal>
package/src/Popover.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'react'
2
2
  import { Popover as BasePopover } from '@base-ui/react/popover'
3
3
  import { cn } from './cn'
4
+ import { triggerDisabled } from './triggerDisabled'
5
+ import { ScrollArea } from './ScrollArea'
4
6
  import { MenuPanel } from './Menu'
5
7
 
6
8
  /**
@@ -108,7 +110,9 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
108
110
  take the page away from the person using it. */
109
111
  modal={false}
110
112
  >
111
- {trigger && <BasePopover.Trigger render={trigger} />}
113
+ {/* Told the button's disabled state, or it writes `aria-disabled="false"`
114
+ over it and opens anyway (Finding 39). */}
115
+ {trigger && <BasePopover.Trigger render={trigger} disabled={triggerDisabled(trigger)} />}
112
116
  <BasePopover.Portal>
113
117
  <BasePopover.Positioner
114
118
  anchor={anchorTarget}
@@ -141,10 +145,13 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
141
145
  */
142
146
  initialFocus={trigger ? undefined : false}
143
147
  finalFocus={finalFocus}
144
- 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)}
145
149
  render={<MenuPanel />}
146
150
  >
147
- {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>
148
155
  </BasePopover.Popup>
149
156
  </BasePopover.Positioner>
150
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,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
+ })