@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.
- 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/Field.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 +279 -235
- package/dist/index.js.map +4 -4
- package/dist/triggerDisabled.d.ts +10 -0
- package/dist/triggerDisabled.d.ts.map +1 -0
- 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/Field.test.tsx +36 -8
- package/src/Field.tsx +26 -19
- package/src/Menu.tsx +17 -2
- package/src/Popover.tsx +10 -3
- 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/Toolbar.test.tsx +55 -0
- package/src/index.ts +1 -0
- package/src/triggerDisabled.ts +13 -0
|
@@ -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/Toolbar.test.tsx
CHANGED
|
@@ -8,6 +8,8 @@ import { cleanup, render, screen } from '@testing-library/react'
|
|
|
8
8
|
import userEvent from '@testing-library/user-event'
|
|
9
9
|
import { IconButton } from './IconButton'
|
|
10
10
|
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
|
|
11
|
+
import { Menu, MenuItem } from './Menu'
|
|
12
|
+
import { Popover } from './Popover'
|
|
11
13
|
|
|
12
14
|
afterEach(cleanup)
|
|
13
15
|
|
|
@@ -196,3 +198,56 @@ describe('a loose row, for comparison', () => {
|
|
|
196
198
|
expect(walk).toEqual(['One', 'Two', 'Three', 'After'])
|
|
197
199
|
})
|
|
198
200
|
})
|
|
201
|
+
|
|
202
|
+
describe('a ToolbarButton with a reason, as a trigger', () => {
|
|
203
|
+
/*
|
|
204
|
+
Found by Ship's adoption (2026-09-08, Finding 39): the message's tools put
|
|
205
|
+
"React" (a Popover trigger) and "…" (a Menu trigger) on one strip, and with
|
|
206
|
+
a `disabledReason` both came out `aria-disabled="false"`. The part that
|
|
207
|
+
renders the trigger writes its own disabled state over the button's.
|
|
208
|
+
*/
|
|
209
|
+
it('stays disabled, reachable, and closed as a Popover trigger', async () => {
|
|
210
|
+
const user = userEvent.setup()
|
|
211
|
+
render(
|
|
212
|
+
<Toolbar aria-label="Tools">
|
|
213
|
+
<Popover
|
|
214
|
+
ariaLabel="Reactions"
|
|
215
|
+
trigger={
|
|
216
|
+
<ToolbarButton aria-label="React" disabledReason="Sign in to react.">
|
|
217
|
+
{dot}
|
|
218
|
+
</ToolbarButton>
|
|
219
|
+
}
|
|
220
|
+
>
|
|
221
|
+
<p>the picker</p>
|
|
222
|
+
</Popover>
|
|
223
|
+
</Toolbar>,
|
|
224
|
+
)
|
|
225
|
+
const button = screen.getByRole('button', { name: 'React' })
|
|
226
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
227
|
+
expect(button.getAttribute('tabindex')).toBe('0')
|
|
228
|
+
await user.click(button)
|
|
229
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('stays disabled, reachable, and closed as a Menu trigger', async () => {
|
|
233
|
+
const user = userEvent.setup()
|
|
234
|
+
render(
|
|
235
|
+
<Toolbar aria-label="Tools">
|
|
236
|
+
<Menu
|
|
237
|
+
trigger={
|
|
238
|
+
<ToolbarButton aria-label="More" disabledReason="Sign in first.">
|
|
239
|
+
{dot}
|
|
240
|
+
</ToolbarButton>
|
|
241
|
+
}
|
|
242
|
+
>
|
|
243
|
+
<MenuItem label="Delete" onClick={() => {}} />
|
|
244
|
+
</Menu>
|
|
245
|
+
</Toolbar>,
|
|
246
|
+
)
|
|
247
|
+
const button = screen.getByRole('button', { name: 'More' })
|
|
248
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
249
|
+
expect(button.getAttribute('tabindex')).toBe('0')
|
|
250
|
+
await user.click(button)
|
|
251
|
+
expect(screen.queryByRole('menu')).toBeNull()
|
|
252
|
+
})
|
|
253
|
+
})
|
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'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the element a `Menu` or `Popover` was handed as its trigger is
|
|
5
|
+
* disabled — by `disabled`, or by a `disabledReason` (which disables the
|
|
6
|
+
* button and keeps it reachable). Base UI's trigger parts keep a disabled
|
|
7
|
+
* state of their own and write it over the rendered button's, so they have to
|
|
8
|
+
* be told (Finding 39, 2026-09-08).
|
|
9
|
+
*/
|
|
10
|
+
export function triggerDisabled(trigger: ReactElement): boolean {
|
|
11
|
+
const props = trigger.props as { disabled?: boolean; disabledReason?: string }
|
|
12
|
+
return !!(props.disabled || props.disabledReason)
|
|
13
|
+
}
|