@estiva-app/ui 0.12.3 → 0.12.5
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/Button.d.ts.map +1 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/Popover.d.ts +22 -1
- package/dist/Popover.d.ts.map +1 -1
- package/dist/index.js +27 -9
- package/dist/index.js.map +2 -2
- package/package.json +1 -1
- package/src/Button.stories.tsx +29 -0
- package/src/Button.test.tsx +18 -0
- package/src/Button.tsx +12 -7
- package/src/IconButton.tsx +6 -0
- package/src/Popover.stories.tsx +36 -1
- package/src/Popover.test.tsx +17 -0
- package/src/Popover.tsx +28 -3
package/package.json
CHANGED
package/src/Button.stories.tsx
CHANGED
|
@@ -30,6 +30,35 @@ export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
|
|
|
30
30
|
export const WithAReason: Story = { args: { variant: 'primary', disabledReason: 'Sign in to add items' } }
|
|
31
31
|
|
|
32
32
|
/** Every variant × size × icon × disabled combination on one canvas. */
|
|
33
|
+
/* The box it is dropped into cannot change its height: a Button states `h-8`
|
|
34
|
+
(or `h-6` when small), and a stretching parent only stretches a child whose
|
|
35
|
+
height is `auto`. The row below says no `items-*` at all, which is the box
|
|
36
|
+
that drew an IconButton 228px tall — this stays 32. */
|
|
37
|
+
export const InATallRow: Story = {
|
|
38
|
+
parameters: { controls: { disable: true } },
|
|
39
|
+
render: () => (
|
|
40
|
+
<div className="flex h-[260px] w-[240px] justify-center rounded-lg border border-border-subtle p-4">
|
|
41
|
+
<Button variant="outlined">Button</Button>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* And the caller says where it sits. This column asks for the left; a Button
|
|
47
|
+
carries no `align-self` of its own, so the left is what it gets. */
|
|
48
|
+
export const InAColumnThatAsksForTheLeft: Story = {
|
|
49
|
+
parameters: { controls: { disable: true } },
|
|
50
|
+
render: () => (
|
|
51
|
+
<div className="flex w-[240px] flex-col items-start gap-2 rounded-lg border border-border-subtle p-4">
|
|
52
|
+
<Button variant="outlined" size="small">
|
|
53
|
+
Copy link
|
|
54
|
+
</Button>
|
|
55
|
+
<Button variant="outlined" size="small">
|
|
56
|
+
Pair with another Folder…
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
export const AllVariants: Story = {
|
|
34
63
|
parameters: { controls: { disable: true } },
|
|
35
64
|
render: () => (
|
package/src/Button.test.tsx
CHANGED
|
@@ -105,6 +105,24 @@ describe('Button', () => {
|
|
|
105
105
|
expect(held).not.toHaveBeenCalled()
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
+
/* jsdom computes no layout, so the class list is what a test can hold on to.
|
|
109
|
+
Both halves matter: the height is why a stretching parent cannot change
|
|
110
|
+
this button, and the absence of `align-self` is why its caller keeps the
|
|
111
|
+
say on where it sits. The pixels behind both were measured in Chrome on
|
|
112
|
+
2026-09-12 — 32px in a 260px row either way, and left in a column that
|
|
113
|
+
asks for the left. */
|
|
114
|
+
it('states its own height and no alignment of its own', () => {
|
|
115
|
+
const { rerender } = render(<Button>Default</Button>)
|
|
116
|
+
const defaultButton = screen.getByRole('button', { name: 'Default' })
|
|
117
|
+
expect(defaultButton.className).toContain('h-8')
|
|
118
|
+
expect(defaultButton.className).not.toContain('self-')
|
|
119
|
+
|
|
120
|
+
rerender(<Button size="small">Small</Button>)
|
|
121
|
+
const smallButton = screen.getByRole('button', { name: 'Small' })
|
|
122
|
+
expect(smallButton.className).toContain('h-6')
|
|
123
|
+
expect(smallButton.className).not.toContain('self-')
|
|
124
|
+
})
|
|
125
|
+
|
|
108
126
|
it('passes native props through', () => {
|
|
109
127
|
render(
|
|
110
128
|
<Button form="f1" aria-pressed="true" data-x="y" className="mt-2">
|
package/src/Button.tsx
CHANGED
|
@@ -66,13 +66,18 @@ export function Button({
|
|
|
66
66
|
focusableWhenDisabled={!!disabledReason}
|
|
67
67
|
className={(state) =>
|
|
68
68
|
cn(
|
|
69
|
-
// `self-center`
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
69
|
+
// No `self-center` here, deliberately — `IconButton` needs it and this
|
|
70
|
+
// does not, and the difference is the `h-8` / `h-6` on the next two
|
|
71
|
+
// lines. `align-items: stretch` only stretches a child whose cross
|
|
72
|
+
// size is `auto`; a Button always states its height, so a stretching
|
|
73
|
+
// parent cannot change it. Measured in Chrome on 2026-09-12, in the
|
|
74
|
+
// 260px row of Finding 49: 32px with `self-center` and 32px without.
|
|
75
|
+
// What the class DID do was override the caller on the other axis —
|
|
76
|
+
// in a column it beats the parent's `items-start`, which centred the
|
|
77
|
+
// four actions in Ship's `ProjectRail` and the trigger in five of
|
|
78
|
+
// this package's own story frames, each of which asks for the top.
|
|
79
|
+
// A control does not get to decide where its caller puts it.
|
|
80
|
+
'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
|
|
76
81
|
size === 'default' && 'h-8 text-btn-default',
|
|
77
82
|
size === 'small' && 'h-6 text-btn-small',
|
|
78
83
|
// Extra right padding beside a leading icon, for optical balance.
|
package/src/IconButton.tsx
CHANGED
|
@@ -54,6 +54,12 @@ export function IconButton({
|
|
|
54
54
|
// happened here: a row with no `items-*` drew this 24 wide and 228
|
|
55
55
|
// tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
|
|
56
56
|
// is the size of its icon and its padding, whatever box it lands in.
|
|
57
|
+
//
|
|
58
|
+
// This is the one of the two that needs it: an IconButton states no
|
|
59
|
+
// height, so its cross size is `auto` and a stretching parent takes
|
|
60
|
+
// it. Measured in the same 260px row on 2026-09-12: 24px with this
|
|
61
|
+
// class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
|
|
62
|
+
// stretched and carries no `self-center` — see the note there.
|
|
57
63
|
'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
|
|
58
64
|
!state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
|
|
59
65
|
!state.disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
|
package/src/Popover.stories.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { IconBold, IconItalic, IconLink } from '@tabler/icons-react'
|
|
3
|
-
import { useRef, useState, type KeyboardEvent } from 'react'
|
|
3
|
+
import { useRef, useState, type KeyboardEvent, useCallback } from 'react'
|
|
4
4
|
import { Button } from './Button'
|
|
5
5
|
import { MenuPanel } from './Menu'
|
|
6
6
|
import { Popover } from './Popover'
|
|
@@ -221,6 +221,41 @@ export const FlippedForRoom: Story = {
|
|
|
221
221
|
),
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* `maxHeight` caps the scrolling box. Without one a panel grows to the room
|
|
226
|
+
* the positioner has — right for a panel as tall as its content, wrong for a
|
|
227
|
+
* long list, because a panel that fits neither above nor below its anchor is
|
|
228
|
+
* moved to the **side** of it. A cap keeps the choice between above and below.
|
|
229
|
+
*
|
|
230
|
+
* The cap goes here and not on `className`: `className` is the panel, and the
|
|
231
|
+
* panel's children scroll in a viewport of their own.
|
|
232
|
+
*/
|
|
233
|
+
export const Capped: Story = {
|
|
234
|
+
parameters: { controls: { disable: true }, layout: 'fullscreen' },
|
|
235
|
+
render: function Capped() {
|
|
236
|
+
/* Anchored and open, so the cap is the thing you see rather than a button
|
|
237
|
+
you have to press first. A rect is all an anchor needs. */
|
|
238
|
+
const [rect, setRect] = useState<DOMRect | null>(null)
|
|
239
|
+
const mark = useCallback((el: HTMLDivElement | null) => {
|
|
240
|
+
setRect(el ? el.getBoundingClientRect() : null)
|
|
241
|
+
}, [])
|
|
242
|
+
return (
|
|
243
|
+
<div className="flex h-[420px] w-full items-center justify-center">
|
|
244
|
+
<div ref={mark} className="text-body-2 text-text-secondary">
|
|
245
|
+
twenty rows, capped at 160px
|
|
246
|
+
</div>
|
|
247
|
+
<Popover anchor={rect} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
|
|
248
|
+
{Array.from({ length: 20 }, (_, i) => (
|
|
249
|
+
<span key={i} className="text-body-2 text-text-primary py-1">
|
|
250
|
+
Row {i + 1}
|
|
251
|
+
</span>
|
|
252
|
+
))}
|
|
253
|
+
</Popover>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
224
259
|
/**
|
|
225
260
|
* `align="center"` puts the panel's middle over the anchor's, wherever in the
|
|
226
261
|
* line that is — what a toolbar over a selection wants.
|
package/src/Popover.test.tsx
CHANGED
|
@@ -193,6 +193,23 @@ describe('Popover', () => {
|
|
|
193
193
|
* the two edges were mapped.
|
|
194
194
|
*/
|
|
195
195
|
describe('Popover, centred on its anchor', () => {
|
|
196
|
+
it('caps the scrolling box, not the panel', async () => {
|
|
197
|
+
/* The cap has to land on the viewport: a `max-h` on the panel is overrun
|
|
198
|
+
by the viewport's own cap and the content draws through the panel's
|
|
199
|
+
border. jsdom computes no layout, so the class is what this can hold. */
|
|
200
|
+
render(
|
|
201
|
+
<Popover trigger={<Button>Open</Button>} ariaLabel="A panel" maxHeight="max-h-[160px]">
|
|
202
|
+
<span>Inside</span>
|
|
203
|
+
</Popover>,
|
|
204
|
+
)
|
|
205
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open' }))
|
|
206
|
+
const panel = await screen.findByRole('dialog')
|
|
207
|
+
expect(panel.className).not.toContain('max-h-[160px]')
|
|
208
|
+
const viewport = panel.querySelector('[class*="max-h-"]')
|
|
209
|
+
expect(viewport?.className).toContain('max-h-[160px]')
|
|
210
|
+
expect(viewport?.className).not.toContain('available-height')
|
|
211
|
+
})
|
|
212
|
+
|
|
196
213
|
it('asks Base UI for the middle', async () => {
|
|
197
214
|
render(
|
|
198
215
|
<Popover trigger={<Button>Open</Button>} align="center" ariaLabel="A panel">
|
package/src/Popover.tsx
CHANGED
|
@@ -93,6 +93,27 @@ export interface PopoverProps {
|
|
|
93
93
|
children: ReactNode
|
|
94
94
|
/** On the panel's surface — its width, its internal rhythm. */
|
|
95
95
|
className?: string
|
|
96
|
+
/**
|
|
97
|
+
* A cap on the scrolling box, as a class — `max-h-[360px]`. Without one the
|
|
98
|
+
* panel grows to the room the positioner has, which is the right default for
|
|
99
|
+
* a panel that is as tall as its content.
|
|
100
|
+
*
|
|
101
|
+
* **It cannot go on `className`.** That lands on the panel, and the panel's
|
|
102
|
+
* children sit inside a `ScrollArea` whose viewport carries its own cap — so
|
|
103
|
+
* a `max-h` on the panel is overrun by the viewport and the content draws
|
|
104
|
+
* straight through the panel's border (measured with Peek's `/` menu,
|
|
105
|
+
* 2026-09-12: a 400px panel with 559px of rows hanging out of it). The cap
|
|
106
|
+
* belongs on the viewport, as `DialogShell` takes `bodyMaxHeight`.
|
|
107
|
+
*
|
|
108
|
+
* **When a panel needs one.** A panel taller than the room above *and* below
|
|
109
|
+
* its anchor is not flipped by the positioner — it is moved to the side of
|
|
110
|
+
* the anchor, which for a type-ahead over a caret is not where it belongs.
|
|
111
|
+
* A cap keeps the choice between above and below.
|
|
112
|
+
*
|
|
113
|
+
* It replaces the available-height cap rather than adding to it, so a caller
|
|
114
|
+
* that sets one owns it.
|
|
115
|
+
*/
|
|
116
|
+
maxHeight?: string
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/** The 4px between the panel and what it hangs from, and the 8px it keeps
|
|
@@ -100,7 +121,7 @@ export interface PopoverProps {
|
|
|
100
121
|
const GAP = 4
|
|
101
122
|
const VIEWPORT_PAD = 8
|
|
102
123
|
|
|
103
|
-
export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className }: PopoverProps) {
|
|
124
|
+
export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className, maxHeight }: PopoverProps) {
|
|
104
125
|
/* A rect is not an element, so it becomes a virtual anchor — the one shape
|
|
105
126
|
Floating UI takes besides an element. */
|
|
106
127
|
const anchorTarget = useMemo(() => {
|
|
@@ -157,8 +178,12 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
|
|
|
157
178
|
className={cn('min-w-[180px] outline-none', className)}
|
|
158
179
|
render={<MenuPanel />}
|
|
159
180
|
>
|
|
160
|
-
{/* As in Menu: the cap on the scrolling box, less the panel's padding; the padding stays on the panel.
|
|
161
|
-
|
|
181
|
+
{/* As in Menu: the cap on the scrolling box, less the panel's padding; the padding stays on the panel.
|
|
182
|
+
A caller's `maxHeight` replaces it — see the prop. */}
|
|
183
|
+
<ScrollArea
|
|
184
|
+
viewportClassName={maxHeight ?? 'max-h-[calc(var(--available-height)_-_1rem)]'}
|
|
185
|
+
contentClassName="flex flex-col [&>[role=separator]]:mx-0"
|
|
186
|
+
>
|
|
162
187
|
{children}
|
|
163
188
|
</ScrollArea>
|
|
164
189
|
</BasePopover.Popup>
|