@estiva-app/ui 0.12.8 → 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.8",
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",
@@ -33,6 +33,7 @@
33
33
  "test:a11y": "vitest run --project storybook-signal --project storybook-ship",
34
34
  "typecheck": "tsc --noEmit",
35
35
  "lint": "eslint .",
36
+ "gates:status": "node scripts/gates-status.mjs",
36
37
  "prepublishOnly": "npm run build"
37
38
  },
38
39
  "peerDependencies": {
package/src/ChipInput.mdx CHANGED
@@ -55,6 +55,15 @@ import { ChipInput } from '@estiva-app/ui'
55
55
  arrow keys move through the suggestions, and a screen reader is told which
56
56
  one is highlighted. The list is as wide as the field and hangs below it,
57
57
  or above when there is no room.
58
+ - **The field's name.** Inside a `Field`, the label names it — pass nothing.
59
+ Anywhere else, pass `aria-label`, or `aria-labelledby` pointing at a
60
+ visible label such as a "To:". With neither, the placeholder is the name,
61
+ and it stays the name after the first chip stops it being drawn.
62
+
63
+ ```tsx
64
+ <span id="to-label">To:</span>
65
+ <ChipInput aria-labelledby="to-label" value={chosen} onChange={setChosen} options={directory} />
66
+ ```
58
67
 
59
68
  ## Keys
60
69
 
@@ -29,10 +29,6 @@ const meta = {
29
29
  title: 'Inputs/ChipInput',
30
30
  component: ChipInput,
31
31
  parameters: {
32
- // axe label is off here: once a chip is in the field the input drops its placeholder
33
- // and has no name of its own, and no prop lets the caller give it one. Settled at the
34
- // Combobox port (PLAN.md stage 5).
35
- a11y: { config: { rules: [{ id: 'label', enabled: false }] } },
36
32
  // The suggestion list portals to document.body at fixed coordinates —
37
33
  // render docs usage in an iframe so it lands where the field is.
38
34
  docs: { story: { inline: false, height: '320px' } },
@@ -13,7 +13,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
13
13
  import { cleanup, render, screen } from '@testing-library/react'
14
14
  import userEvent from '@testing-library/user-event'
15
15
  import { useState } from 'react'
16
- import { ChipInput, type ChipInputOption } from './ChipInput'
16
+ import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
17
+ import { Field } from './Field'
17
18
 
18
19
  afterEach(cleanup)
19
20
 
@@ -23,7 +24,17 @@ const PEOPLE: ChipInputOption[] = [
23
24
  { id: 'alan', label: 'Alan Turing', description: 'Mathematician' },
24
25
  ]
25
26
 
26
- function Harness({ initial = [], excludeIds, onChange }: { initial?: ChipInputOption[]; excludeIds?: string[]; onChange?: (v: ChipInputOption[]) => void }) {
27
+ function Harness({
28
+ initial = [],
29
+ excludeIds,
30
+ onChange,
31
+ naming,
32
+ }: {
33
+ initial?: ChipInputOption[]
34
+ excludeIds?: string[]
35
+ onChange?: (v: ChipInputOption[]) => void
36
+ naming?: { 'aria-label'?: string; 'aria-labelledby'?: string }
37
+ }) {
27
38
  const [value, setValue] = useState(initial)
28
39
  return (
29
40
  <ChipInput
@@ -35,6 +46,7 @@ function Harness({ initial = [], excludeIds, onChange }: { initial?: ChipInputOp
35
46
  options={PEOPLE}
36
47
  excludeIds={excludeIds}
37
48
  placeholder="Search people"
49
+ {...naming}
38
50
  />
39
51
  )
40
52
  }
@@ -139,4 +151,68 @@ describe('ChipInput', () => {
139
151
  await user.keyboard('{Escape}')
140
152
  expect(reachedOutside).toHaveBeenCalledTimes(1)
141
153
  })
154
+
155
+ describe('the field’s name (PLAN Finding 6)', () => {
156
+ /* Chrome names an empty field by its placeholder, and the first chip takes
157
+ the placeholder away. Measured in Chrome's accessibility tree before this
158
+ was fixed: with a chip in, the name was "". */
159
+ it('keeps the placeholder as its name once a chip takes the placeholder away', () => {
160
+ render(<Harness initial={[PEOPLE[0]]} />)
161
+ const input = screen.getByRole('combobox', { name: 'Search people' })
162
+ expect(input.getAttribute('placeholder')).toBe('')
163
+ })
164
+
165
+ it('adds no name of its own while the placeholder is still drawn', () => {
166
+ render(<Harness />)
167
+ expect(screen.getByRole('combobox').getAttribute('aria-label')).toBeNull()
168
+ })
169
+
170
+ it('takes a caller’s aria-label over the placeholder', () => {
171
+ render(<Harness initial={[PEOPLE[0]]} naming={{ 'aria-label': 'To' }} />)
172
+ expect(screen.getByRole('combobox', { name: 'To' })).toBeTruthy()
173
+ })
174
+
175
+ it('takes a caller’s aria-labelledby — a visible “To:”', () => {
176
+ render(
177
+ <div>
178
+ <span id="to">To:</span>
179
+ <Harness initial={[PEOPLE[0]]} naming={{ 'aria-labelledby': 'to' }} />
180
+ </div>,
181
+ )
182
+ expect(screen.getByRole('combobox', { name: 'To:' })).toBeTruthy()
183
+ })
184
+
185
+ /* The trap this pins: Base UI copies a caller's prop over its own even when
186
+ it is `undefined`, so an `aria-labelledby` passed through empty would wipe
187
+ the `Field`'s and the placeholder would name the field instead. */
188
+ it('inside a Field, is named by the Field’s label, chip or no chip', () => {
189
+ render(
190
+ <>
191
+ <Field label="Invite people">
192
+ <Harness />
193
+ </Field>
194
+ <Field label="Reviewers">
195
+ <Harness initial={[PEOPLE[0]]} />
196
+ </Field>
197
+ </>,
198
+ )
199
+ expect(screen.getByRole('combobox', { name: 'Invite people' })).toBeTruthy()
200
+ expect(screen.getByRole('combobox', { name: 'Reviewers' })).toBeTruthy()
201
+ })
202
+ })
203
+ })
204
+
205
+ describe('InputChip', () => {
206
+ it('its ✕ is a button named for the chip, and removes it', async () => {
207
+ const user = userEvent.setup()
208
+ const onRemove = vi.fn()
209
+ render(<InputChip label="Label" onRemove={onRemove} />)
210
+ await user.click(screen.getByRole('button', { name: 'Remove Label' }))
211
+ expect(onRemove).toHaveBeenCalledTimes(1)
212
+ })
213
+
214
+ it('draws no ✕ without onRemove', () => {
215
+ render(<InputChip label="Label" />)
216
+ expect(screen.queryByRole('button')).toBeNull()
217
+ })
142
218
  })
package/src/ChipInput.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { Button as BaseButton } from '@base-ui/react/button'
2
3
  import { Combobox } from '@base-ui/react/combobox'
3
4
  import { IconX } from '@tabler/icons-react'
4
5
  import { cn } from './cn'
@@ -45,8 +46,12 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
45
46
  <div className={cn(CHIP_BOX, chipPadding(!!leading, !!onRemove), className)}>
46
47
  {leading && <span className="flex shrink-0 items-center">{leading}</span>}
47
48
  <span className={CHIP_LABEL}>{label}</span>
49
+ {/* Base UI's `Button`, as every button in the package is (D6). Base UI
50
+ has no chip of its own — its only chips are `Combobox.Chip` and
51
+ `ChipRemove`, which throw outside a combobox — so the ✕ is the one
52
+ part of a chip standing alone that it has a counterpart for. */}
48
53
  {onRemove && (
49
- <button
54
+ <BaseButton
50
55
  type="button"
51
56
  onClick={(e) => {
52
57
  e.stopPropagation()
@@ -56,7 +61,7 @@ export function InputChip({ label, leading, onRemove, className }: InputChipProp
56
61
  aria-label={`Remove ${label}`}
57
62
  >
58
63
  <IconX size={10} stroke={1.5} />
59
- </button>
64
+ </BaseButton>
60
65
  )}
61
66
  </div>
62
67
  )
@@ -112,6 +117,14 @@ export interface ChipInputProps<T extends ChipInputOption = ChipInputOption> {
112
117
  rowLeading?: (option: T) => ReactNode
113
118
  /** Set by a `Field` with `required`; a caller inside one owes nothing. */
114
119
  'aria-required'?: boolean | 'true' | 'false'
120
+ /**
121
+ * The field's name, for a caller that is not inside a `Field` — a `Field`'s
122
+ * label names it already. With neither this nor `aria-labelledby`, the
123
+ * placeholder is the name, and it stays the name once a chip is in.
124
+ */
125
+ 'aria-label'?: string
126
+ /** The id of what names the field on the page — a visible "To:", say. */
127
+ 'aria-labelledby'?: string
115
128
  }
116
129
 
117
130
  export function ChipInput<T extends ChipInputOption = ChipInputOption>({
@@ -158,6 +171,21 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
158
171
  the open state is this component's and not the part's. */
159
172
  const open = query.trim().length > 0
160
173
 
174
+ /* The field's name (PLAN Finding 6). With no chip, Chrome names the field
175
+ by its placeholder; the first chip takes the placeholder away, and the
176
+ field was left with no name at all (measured: ""). So the placeholder
177
+ stays on as the name once it is no longer drawn. A caller's own name
178
+ wins, and so does a `Field`'s label: Base UI points `aria-labelledby` at
179
+ it, and a label by reference outranks `aria-label`.
180
+ Only a name that exists is passed. Base UI copies a caller's prop over its
181
+ own even when the value is `undefined`, so an `aria-labelledby` written
182
+ out empty would wipe the `Field`'s. */
183
+ const ariaLabel = aria['aria-label'] ?? (value.length > 0 ? placeholder : undefined)
184
+ const naming = {
185
+ ...(ariaLabel !== undefined && { 'aria-label': ariaLabel }),
186
+ ...(aria['aria-labelledby'] !== undefined && { 'aria-labelledby': aria['aria-labelledby'] }),
187
+ }
188
+
161
189
  function removeLast() {
162
190
  if (value.length > 0) onChange(value.slice(0, -1))
163
191
  }
@@ -213,6 +241,7 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
213
241
  autoFocus={autoFocus}
214
242
  placeholder={value.length === 0 ? placeholder : ''}
215
243
  aria-required={aria['aria-required']}
244
+ {...naming}
216
245
  onKeyDown={onInputKeyDown}
217
246
  className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
218
247
  />
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>