@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/dist/ChipInput.d.ts +8 -0
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/Menu.d.ts +12 -2
- package/dist/Menu.d.ts.map +1 -1
- package/dist/Popover.d.ts +17 -2
- package/dist/Popover.d.ts.map +1 -1
- package/dist/index.js +15 -8
- package/dist/index.js.map +3 -3
- package/package.json +2 -1
- package/src/ChipInput.mdx +9 -0
- package/src/ChipInput.stories.tsx +0 -4
- package/src/ChipInput.test.tsx +78 -2
- package/src/ChipInput.tsx +31 -2
- package/src/Menu.test.tsx +20 -0
- package/src/Menu.tsx +17 -5
- package/src/Popover.mdx +4 -2
- package/src/Popover.stories.tsx +11 -9
- package/src/Popover.test.tsx +38 -0
- package/src/Popover.tsx +20 -4
- package/src/ReactionPicker.mdx +1 -1
- package/src/ReactionPicker.stories.tsx +2 -1
- package/src/Toolbar.stories.tsx +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.12.
|
|
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' } },
|
package/src/ChipInput.test.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
|
231
|
-
*
|
|
232
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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={
|
|
246
|
+
<div ref={setMarker} className="text-body-2 text-text-secondary">
|
|
245
247
|
twenty rows, capped at 160px
|
|
246
248
|
</div>
|
|
247
|
-
<Popover anchor={
|
|
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}
|
package/src/Popover.test.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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=
|
|
203
|
+
contentClassName={cn('flex flex-col p-2 [&>[role=separator]]:mx-0', contentClassName)}
|
|
188
204
|
>
|
|
189
205
|
{children}
|
|
190
206
|
</ScrollArea>
|
package/src/ReactionPicker.mdx
CHANGED
|
@@ -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
|
|
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} />
|
package/src/Toolbar.stories.tsx
CHANGED
|
@@ -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
|
|
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>
|