@estiva-app/ui 0.12.9 → 0.12.10

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.9",
3
+ "version": "0.12.10",
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.test.tsx CHANGED
@@ -326,3 +326,23 @@ describe('rows on a bare MenuPanel', () => {
326
326
  expect(screen.queryByRole('group')).toBeNull()
327
327
  })
328
328
  })
329
+
330
+ /** As `Popover`: the padding is the content's, and a caller sets it there
331
+ * (PLAN Finding 60 — Peek's Later menu asked for `p-1` on `className` and got
332
+ * 12px from 0.12.6 on). */
333
+ describe('Menu, padding', () => {
334
+ it('takes a caller’s padding on contentClassName, instead of the 8px', async () => {
335
+ render(
336
+ <Menu trigger={<Button>Open</Button>} contentClassName="p-1">
337
+ <MenuItem label="Item one" onClick={() => {}} />
338
+ </Menu>,
339
+ )
340
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }))
341
+ const row = await screen.findByRole('menuitem', { name: 'Item one' })
342
+ // The scrolling content is the box that carries the separator rule.
343
+ const content = row.closest('[class*="role=separator"]') as HTMLElement
344
+ const classes = content.className.split(/\s+/)
345
+ expect(classes).toContain('p-1')
346
+ expect(classes).not.toContain('p-2')
347
+ })
348
+ })
package/src/Menu.tsx CHANGED
@@ -142,8 +142,18 @@ export interface MenuProps {
142
142
  * closes the menu by itself, so this is only for content that is not one. */
143
143
  actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
144
144
  children: ReactNode
145
- /** On the menu's surface — its width, its internal rhythm. */
145
+ /** On the menu's surface — its width. **Not its padding**: see
146
+ * `contentClassName`. */
146
147
  className?: string
148
+ /**
149
+ * The padding around the rows, as a class. Default `p-2`, 8px.
150
+ *
151
+ * It is on the scrolling content, not on the panel, so the scrollbar hugs the
152
+ * panel's edge (D63) — and so a padding class on `className` adds to it
153
+ * rather than replacing it. Peek's Later menu asked for `p-1` there and got
154
+ * 12px from `0.12.6` on (PLAN Finding 60). Set it here.
155
+ */
156
+ contentClassName?: string
147
157
  }
148
158
 
149
159
  /**
@@ -163,7 +173,7 @@ const VIEWPORT_PAD = 8
163
173
  const HOVER_OPEN_DELAY = 0
164
174
  const HOVER_CLOSE_DELAY = 150
165
175
 
166
- export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className }: MenuProps) {
176
+ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className, contentClassName }: MenuProps) {
167
177
  return (
168
178
  <BaseMenu.Root
169
179
  open={open}
@@ -227,9 +237,11 @@ export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpe
227
237
  * as tall as Floating UI allowed.
228
238
  *
229
239
  * 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">
240
+ * asking for different padding needs `contentClassName`. That
241
+ * sentence was written at 0.12.6 and the prop was not: a `p-1` on
242
+ * `className` added 4px to these 8px instead of replacing them
243
+ * (PLAN Finding 60). The prop exists now. */}
244
+ <ScrollArea viewportClassName="max-h-[var(--available-height)]" contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}>
233
245
  <MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
234
246
  </ScrollArea>
235
247
  </BaseMenu.Popup>
package/src/Popover.mdx CHANGED
@@ -64,8 +64,10 @@ import { Popover } from '@estiva-app/ui'
64
64
  force it. Leave them off and the panel keeps its own state.
65
65
  - `ariaLabel` names the panel. A panel with a visible heading can point at it
66
66
  with `aria-labelledby` instead.
67
- - Width, padding and internal rhythm are yours, through `className` the
68
- panel is a surface, not a layout.
67
+ - **Width goes on `className`, padding on `contentClassName`.** The padding
68
+ sits on the scrolling content so the scrollbar hugs the panel's edge, and a
69
+ padding class on `className` adds to it rather than replacing it. Default
70
+ 8px; **a toolbar asks for `contentClassName="p-1"`**.
69
71
 
70
72
  <Canvas of={PopoverStories.FromATrigger} />
71
73
 
@@ -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, useCallback } from 'react'
3
+ import { useRef, useState, type KeyboardEvent } from 'react'
4
4
  import { Button } from './Button'
5
5
  import { MenuPanel } from './Menu'
6
6
  import { Popover } from './Popover'
@@ -105,7 +105,8 @@ export const AToolbar: Story = {
105
105
  when there is no room, which is the reason the placement is its job
106
106
  and not ours. */
107
107
  side="top"
108
- className="w-auto min-w-0 p-1"
108
+ className="w-auto min-w-0"
109
+ contentClassName="p-1"
109
110
  >
110
111
  {/* The strip is a `Toolbar`, so the whole row is ONE Tab stop and the
111
112
  arrow keys walk it — four stops before, one after. */}
@@ -234,17 +235,18 @@ export const Capped: Story = {
234
235
  parameters: { controls: { disable: true }, layout: 'fullscreen' },
235
236
  render: function Capped() {
236
237
  /* 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
- }, [])
238
+ you have to press first. Anchored on the ELEMENT, held in state from a
239
+ callback ref: an element is re-measured. It used to be a rect read once
240
+ in the ref, and arriving at this story from another one read it before
241
+ the canvas was laid out — 0 × 0 at the corner, a 0px panel, only the
242
+ line of text showing (PLAN Finding 61). */
243
+ const [marker, setMarker] = useState<HTMLDivElement | null>(null)
242
244
  return (
243
245
  <div className="flex h-[420px] w-full items-center justify-center">
244
- <div ref={mark} className="text-body-2 text-text-secondary">
246
+ <div ref={setMarker} className="text-body-2 text-text-secondary">
245
247
  twenty rows, capped at 160px
246
248
  </div>
247
- <Popover anchor={rect} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
249
+ <Popover anchor={marker} open ariaLabel="A long panel" className="w-[240px]" maxHeight="max-h-[160px]">
248
250
  {Array.from({ length: 20 }, (_, i) => (
249
251
  <span key={i} className="text-body-2 text-text-primary py-1">
250
252
  Row {i + 1}
@@ -223,3 +223,41 @@ describe('Popover, centred on its anchor', () => {
223
223
  expect(positioner?.getAttribute('data-align')).toBe('center')
224
224
  })
225
225
  })
226
+
227
+ /**
228
+ * The padding lives on the scrolling content, so the scrollbar hugs the panel
229
+ * (D63) — and a caller's padding has to land there too. At 0.12.6 a toolbar's
230
+ * `p-1` on `className` was added to the content's `p-2` instead of replacing
231
+ * it, and every toolbar in a Popover grew 8px a side (PLAN Finding 60).
232
+ * Measured in Chrome after the fix: the toolbar 5px inside the panel's edge,
233
+ * as on 0.12.5.
234
+ */
235
+ describe('Popover, padding', () => {
236
+ // The scrolling content is the box that carries the separator rule.
237
+ const contentOf = (child: HTMLElement) => child.closest('[class*="role=separator"]') as HTMLElement
238
+
239
+ it('pads its content 8px by default', async () => {
240
+ render(
241
+ <Popover trigger={<Button>Open</Button>} ariaLabel="A panel">
242
+ <span>Inside</span>
243
+ </Popover>,
244
+ )
245
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }))
246
+ const content = contentOf(await screen.findByText('Inside'))
247
+ expect(content.className).toContain('p-2')
248
+ })
249
+
250
+ it('takes a caller’s padding on contentClassName, instead of the 8px', async () => {
251
+ render(
252
+ <Popover trigger={<Button>Open</Button>} ariaLabel="A toolbar" className="w-auto" contentClassName="p-1">
253
+ <span>Inside</span>
254
+ </Popover>,
255
+ )
256
+ await userEvent.click(screen.getByRole('button', { name: 'Open' }))
257
+ const content = contentOf(await screen.findByText('Inside'))
258
+ expect(content.className.split(/\s+/)).toContain('p-1')
259
+ expect(content.className.split(/\s+/)).not.toContain('p-2')
260
+ // And the panel carries none of it.
261
+ expect((await screen.findByRole('dialog')).className.split(/\s+/)).not.toContain('p-1')
262
+ })
263
+ })
package/src/Popover.tsx CHANGED
@@ -91,8 +91,23 @@ export interface PopoverProps {
91
91
  * point at it instead, with `aria-labelledby`. */
92
92
  ariaLabel?: string
93
93
  children: ReactNode
94
- /** On the panel's surface — its width, its internal rhythm. */
94
+ /**
95
+ * On the panel's surface — its width. **Not its padding**: see
96
+ * `contentClassName`.
97
+ */
95
98
  className?: string
99
+ /**
100
+ * The padding around the children, as a class. Default `p-2`, 8px — what a
101
+ * panel of rows or a small form wants. **A toolbar wants `p-1`.**
102
+ *
103
+ * The padding is on the scrolling content, not on the panel, so a scrollbar
104
+ * is drawn over it and hugs the panel's edge (D63). A padding class on
105
+ * `className` therefore does not replace this one — it adds to it: that is
106
+ * how every toolbar in a `Popover` grew 8px a side at `0.12.6`, measured 13px
107
+ * from the panel's edge to the toolbar where it had been 5px (PLAN Finding
108
+ * 60). Set it here.
109
+ */
110
+ contentClassName?: string
96
111
  /**
97
112
  * A cap on the scrolling box, as a class — `max-h-[360px]`. Without one the
98
113
  * panel grows to the room the positioner has, which is the right default for
@@ -121,7 +136,7 @@ export interface PopoverProps {
121
136
  const GAP = 4
122
137
  const VIEWPORT_PAD = 8
123
138
 
124
- export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className, maxHeight }: PopoverProps) {
139
+ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className, contentClassName, maxHeight }: PopoverProps) {
125
140
  /* A rect is not an element, so it becomes a virtual anchor — the one shape
126
141
  Floating UI takes besides an element. */
127
142
  const anchorTarget = useMemo(() => {
@@ -181,10 +196,11 @@ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open
181
196
  {/* As in Menu: the cap on the scrolling box, and the padding on the
182
197
  content rather than the panel, so the bar is drawn over the
183
198
  padding instead of 9px inside it (D63). A caller's `maxHeight`
184
- replaces the cap see the prop. */}
199
+ replaces the cap, and a caller's `contentClassName` the padding
200
+ — see the props. */}
185
201
  <ScrollArea
186
202
  viewportClassName={maxHeight ?? 'max-h-[var(--available-height)]'}
187
- contentClassName="flex flex-col p-2 [&>[role=separator]]:mx-0"
203
+ contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}
188
204
  >
189
205
  {children}
190
206
  </ScrollArea>
@@ -32,7 +32,7 @@ The reactions on offer, to choose one from — icon buttons holding emoji, on a
32
32
  ```tsx
33
33
  import { ReactionPicker } from '@estiva-app/ui'
34
34
 
35
- <Popover side="top" align="right" trigger={reactButton} ariaLabel="Reactions" className="w-auto p-1">
35
+ <Popover side="top" align="right" trigger={reactButton} ariaLabel="Reactions" className="w-auto" contentClassName="p-1">
36
36
  <ReactionPicker
37
37
  surface={false}
38
38
  options={[
@@ -62,7 +62,8 @@ export const FromATrigger: Story = {
62
62
  side="top"
63
63
  align="right"
64
64
  ariaLabel="Reactions"
65
- className="w-auto min-w-0 p-1"
65
+ className="w-auto min-w-0"
66
+ contentClassName="p-1"
66
67
  trigger={
67
68
  <ToolbarButton aria-label="React" tooltip="React">
68
69
  <IconMoodPlus size={16} stroke={1.5} />
@@ -129,7 +129,8 @@ export const OnAnExistingSurface: Story = {
129
129
  /* Above the control that opened it: a strip acts on what is under it.
130
130
  `side` is the preference; Base UI flips it when there is no room. */
131
131
  side="top"
132
- className="w-auto min-w-0 p-1"
132
+ className="w-auto min-w-0"
133
+ contentClassName="p-1"
133
134
  >
134
135
  <Toolbar aria-label="Formatting" surface={false}>
135
136
  <ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>