@estiva-app/ui 0.12.4 → 0.12.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
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/Menu.tsx CHANGED
@@ -208,16 +208,28 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
208
208
  room. Select's 288 was never this component's — the identity
209
209
  panel got it by accident once and grew a scrollbar at full
210
210
  height. */
211
- className={cn('min-w-[180px] outline-none', className)}
211
+ className={cn('min-w-[180px] outline-none p-0', className)}
212
212
  render={<MenuPanel />}
213
213
  >
214
214
  {/* The height cap sits on the box that scrolls — on the panel it
215
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">
216
+ 2026-09-08).
217
+ *
218
+ * And **the padding is on the content, not on the panel** (D63,
219
+ * 2026-09-13). With it on the panel the scrolling box was inset by
220
+ * it, so the bar floated 9px in from the panel's edge where every
221
+ * other scrolling surface in the suite draws it at 1px —
222
+ * `DialogShell` had it right and said so in its own comment, and
223
+ * this is the same arrangement. The rows do not move: the padding
224
+ * that used to be the panel's is now the content's, at the same
225
+ * 8px. The cap loses its `- 1rem` for the same reason — the
226
+ * padding is inside the scrolling box now, so the panel is exactly
227
+ * as tall as Floating UI allowed.
228
+ *
229
+ * A caller's `className` still lands on the panel, so a caller
230
+ * asking for different padding needs `contentClassName` — which is
231
+ * what `MenuPanel` is for when one is used on its own. */}
232
+ <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0">
221
233
  <MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
222
234
  </ScrollArea>
223
235
  </BaseMenu.Popup>
@@ -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.
@@ -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(() => {
@@ -154,11 +175,17 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
154
175
  */
155
176
  initialFocus={trigger ? undefined : false}
156
177
  finalFocus={finalFocus}
157
- className={cn('min-w-[180px] outline-none', className)}
178
+ className={cn('min-w-[180px] outline-none p-0', 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
- <ScrollArea viewportClassName="max-h-[calc(var(--available-height)_-_1rem)]" contentClassName="flex flex-col [&>[role=separator]]:mx-0">
181
+ {/* As in Menu: the cap on the scrolling box, and the padding on the
182
+ content rather than the panel, so the bar is drawn over the
183
+ padding instead of 9px inside it (D63). A caller's `maxHeight`
184
+ replaces the cap — see the prop. */}
185
+ <ScrollArea
186
+ viewportClassName={maxHeight ?? 'max-h-[var(--available-height)]'}
187
+ contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0"
188
+ >
162
189
  {children}
163
190
  </ScrollArea>
164
191
  </BasePopover.Popup>