@duro-app/ui 4.2.0 → 4.3.0
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/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
- package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
- package/dist/components/Popover/Popover.d.ts +41 -0
- package/dist/components/Popover/Popover.d.ts.map +1 -0
- package/dist/components/Popover/Popover.meta.d.ts +3 -0
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -0
- package/dist/components/Popover/Popover.stories.d.ts +11 -0
- package/dist/components/Popover/Popover.stories.d.ts.map +1 -0
- package/dist/components/Popover/outside.d.ts +2 -0
- package/dist/components/Popover/outside.d.ts.map +1 -0
- package/dist/components/Popover/position.d.ts +27 -0
- package/dist/components/Popover/position.d.ts.map +1 -0
- package/dist/components/Popover/styles.css.d.ts +61 -0
- package/dist/components/Popover/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1645 -1381
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
- package/src/components/Dialog/Dialog.meta.ts +6 -0
- package/src/components/Drawer/Drawer.meta.ts +6 -0
- package/src/components/Menu/Menu.meta.ts +5 -1
- package/src/components/Popover/Popover.meta.ts +69 -0
- package/src/components/Popover/Popover.stories.tsx +250 -0
- package/src/components/Popover/Popover.tsx +293 -0
- package/src/components/Popover/outside.ts +9 -0
- package/src/components/Popover/position.ts +48 -0
- package/src/components/Popover/styles.css.ts +75 -0
- package/src/components/Tooltip/Tooltip.meta.ts +1 -1
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@tanstack/react-virtual": "^3.14.6",
|
|
69
|
-
"@duro-app/tokens": "^4.
|
|
69
|
+
"@duro-app/tokens": "^4.3.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -23,6 +23,12 @@ export const meta: ComponentMeta = {
|
|
|
23
23
|
kind: 'contrast',
|
|
24
24
|
relationship: 'Drawer is modal with backdrop; DetailPanel is non-modal and in-flow',
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
component: 'Popover',
|
|
28
|
+
kind: 'contrast',
|
|
29
|
+
relationship:
|
|
30
|
+
'Popover is a small anchored overlay for a few controls; DetailPanel is an in-flow panel for long editing',
|
|
31
|
+
},
|
|
26
32
|
{
|
|
27
33
|
component: 'Table',
|
|
28
34
|
kind: 'composition',
|
|
@@ -30,6 +30,12 @@ export const meta: ComponentMeta = {
|
|
|
30
30
|
kind: 'contrast',
|
|
31
31
|
relationship: 'DetailPanel is non-modal and in-flow; Dialog is modal with backdrop',
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
component: 'Popover',
|
|
35
|
+
kind: 'contrast',
|
|
36
|
+
relationship:
|
|
37
|
+
'Popover is a small non-modal card anchored to an element; Dialog is a modal decision',
|
|
38
|
+
},
|
|
33
39
|
],
|
|
34
40
|
example: `<Dialog.Root open={open} onOpenChange={setOpen}>
|
|
35
41
|
<Dialog.Trigger>
|
|
@@ -30,6 +30,12 @@ export const meta: ComponentMeta = {
|
|
|
30
30
|
kind: 'contrast',
|
|
31
31
|
relationship: 'DetailPanel is non-modal and in-flow; Drawer is modal with backdrop',
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
component: 'Popover',
|
|
35
|
+
kind: 'contrast',
|
|
36
|
+
relationship:
|
|
37
|
+
'Popover is a small card anchored to an element; Drawer takes its place at phone width',
|
|
38
|
+
},
|
|
33
39
|
],
|
|
34
40
|
example: `<Drawer.Root anchor="right" open={open} onOpenChange={setOpen}>
|
|
35
41
|
<Drawer.Trigger>
|
|
@@ -8,7 +8,11 @@ export const meta: ComponentMeta = {
|
|
|
8
8
|
'Navigation links in a dropdown',
|
|
9
9
|
'Overflow menu for toolbar actions',
|
|
10
10
|
],
|
|
11
|
-
whenNotToUse: [
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Picking a value — use Select',
|
|
13
|
+
'Primary navigation — use SideNav or Tabs',
|
|
14
|
+
'A small form tied to the trigger — use Popover',
|
|
15
|
+
],
|
|
12
16
|
anatomy: {
|
|
13
17
|
required: ['Root', 'Trigger', 'Popup', 'Item'],
|
|
14
18
|
optional: ['LinkItem', 'Separator'],
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Non-modal anchored overlay for small interactive content. Compound component — Root is required (throws without it). Anchored to Popover.Trigger, or to a virtual anchor (a () => DOMRect) when there is no Trigger; call reposition() through a ref when the anchor moves.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A small form or controls tied to one element that stay open while you work in them',
|
|
8
|
+
'Anchoring to a spot that is not a button (a canvas point, a text selection) with a virtual anchor',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'A list of actions — use Menu',
|
|
12
|
+
'Hover-only text — use Tooltip',
|
|
13
|
+
'A blocking decision — use Dialog or ConfirmDialog',
|
|
14
|
+
'A long editor, or one that follows the selection — use DetailPanel',
|
|
15
|
+
'Phone width — use Drawer',
|
|
16
|
+
],
|
|
17
|
+
anatomy: {
|
|
18
|
+
required: ['Root', 'Popup'],
|
|
19
|
+
optional: ['Trigger', 'Close'],
|
|
20
|
+
},
|
|
21
|
+
relatedTo: [
|
|
22
|
+
{
|
|
23
|
+
component: 'Menu',
|
|
24
|
+
kind: 'contrast',
|
|
25
|
+
relationship: 'Menu opens a list of actions; Popover holds small forms and controls',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
component: 'Tooltip',
|
|
29
|
+
kind: 'contrast',
|
|
30
|
+
relationship:
|
|
31
|
+
'Tooltip shows hover-only text; Popover holds interactive content and stays open',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
component: 'Dialog',
|
|
35
|
+
kind: 'contrast',
|
|
36
|
+
relationship: 'Dialog is modal and blocks the page; Popover is non-modal and anchored',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
component: 'DetailPanel',
|
|
40
|
+
kind: 'contrast',
|
|
41
|
+
relationship:
|
|
42
|
+
'DetailPanel is an in-flow panel for long editing; Popover is a small anchored overlay',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
component: 'Drawer',
|
|
46
|
+
kind: 'contrast',
|
|
47
|
+
relationship:
|
|
48
|
+
'Drawer is the modal edge panel for phone width; Popover is anchored and non-modal',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
component: 'Field',
|
|
52
|
+
kind: 'composition',
|
|
53
|
+
relationship: 'Place Field.Root with its control inside Popover.Popup for a labelled field',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
example: `const ref = useRef<PopoverHandle>(null)
|
|
57
|
+
const anchor = () => new DOMRect(point.x, point.y, 0, 0)
|
|
58
|
+
|
|
59
|
+
<Popover.Root ref={ref} open={open} onOpenChange={setOpen} anchor={anchor} side="bottom" align="start">
|
|
60
|
+
<Popover.Popup label="Edit label">
|
|
61
|
+
<Field.Root>
|
|
62
|
+
<Field.Label>Label</Field.Label>
|
|
63
|
+
<Input value={label} onChange={(e) => setLabel(e.target.value)} />
|
|
64
|
+
</Field.Root>
|
|
65
|
+
<Popover.Close>Done</Popover.Close>
|
|
66
|
+
</Popover.Popup>
|
|
67
|
+
</Popover.Root>
|
|
68
|
+
// after the anchor moves: ref.current?.reposition()`,
|
|
69
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {useRef, useState} from 'react'
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
+
import {expect, fn, waitFor, within} from 'storybook/test'
|
|
4
|
+
import {css, html} from 'react-strict-dom'
|
|
5
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
6
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
7
|
+
import {Popover, type PopoverHandle} from './Popover'
|
|
8
|
+
import {Button} from '../Button/Button'
|
|
9
|
+
import {Field} from '../Field/Field'
|
|
10
|
+
import {Input} from '../Input/Input'
|
|
11
|
+
import {Select} from '../Select/Select'
|
|
12
|
+
import {Stack} from '../Stack/Stack'
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: 'Components/Popover',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default meta
|
|
19
|
+
type Story = StoryObj
|
|
20
|
+
|
|
21
|
+
// The popup is portalled out of the story's canvas, so query the document.
|
|
22
|
+
const page = () => within(document.body)
|
|
23
|
+
|
|
24
|
+
const styles = css.create({
|
|
25
|
+
stage: {
|
|
26
|
+
position: 'relative',
|
|
27
|
+
height: 320,
|
|
28
|
+
borderWidth: 1,
|
|
29
|
+
borderStyle: 'dashed',
|
|
30
|
+
borderColor: colors.border,
|
|
31
|
+
padding: spacing.md,
|
|
32
|
+
},
|
|
33
|
+
spot: {
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
top: 120,
|
|
36
|
+
left: 160,
|
|
37
|
+
width: 120,
|
|
38
|
+
padding: spacing.sm,
|
|
39
|
+
borderWidth: 1,
|
|
40
|
+
borderStyle: 'solid',
|
|
41
|
+
borderColor: colors.accent,
|
|
42
|
+
},
|
|
43
|
+
shifted: {
|
|
44
|
+
transform: 'translateX(120px)',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export const Default: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<Popover.Root>
|
|
51
|
+
<Popover.Trigger>Rename</Popover.Trigger>
|
|
52
|
+
<Popover.Popup label="Rename">
|
|
53
|
+
<Stack gap="sm">
|
|
54
|
+
<Field.Root>
|
|
55
|
+
<Field.Label>Label</Field.Label>
|
|
56
|
+
<Input defaultValue="Get started" />
|
|
57
|
+
</Field.Root>
|
|
58
|
+
<Popover.Close>Done</Popover.Close>
|
|
59
|
+
</Stack>
|
|
60
|
+
</Popover.Popup>
|
|
61
|
+
</Popover.Root>
|
|
62
|
+
),
|
|
63
|
+
play: async ({canvas, userEvent}) => {
|
|
64
|
+
const trigger = canvas.getByRole('button', {name: 'Rename'})
|
|
65
|
+
await userEvent.click(trigger)
|
|
66
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
67
|
+
const dialog = page().getByRole('dialog', {name: 'Rename'})
|
|
68
|
+
await expect(dialog).toBeVisible()
|
|
69
|
+
// Focus moves to the first field.
|
|
70
|
+
await waitFor(() => expect(page().getByRole('textbox', {name: 'Label'})).toHaveFocus())
|
|
71
|
+
|
|
72
|
+
await userEvent.keyboard('{Escape}')
|
|
73
|
+
await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
|
|
74
|
+
// ...and comes back to the trigger.
|
|
75
|
+
await waitFor(() => expect(trigger).toHaveFocus())
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const CloseButton: Story = {
|
|
80
|
+
render: Default.render,
|
|
81
|
+
play: async ({canvas, userEvent}) => {
|
|
82
|
+
const trigger = canvas.getByRole('button', {name: 'Rename'})
|
|
83
|
+
await userEvent.click(trigger)
|
|
84
|
+
await userEvent.click(page().getByRole('button', {name: 'Done'}))
|
|
85
|
+
await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
|
|
86
|
+
await waitFor(() => expect(trigger).toHaveFocus())
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function WithIgnored({onOpenChange}: {onOpenChange: (open: boolean) => void}) {
|
|
91
|
+
const ignored = useRef<HTMLButtonElement | null>(null)
|
|
92
|
+
return (
|
|
93
|
+
<Stack gap="md">
|
|
94
|
+
<Popover.Root onOpenChange={onOpenChange} ignore={[() => ignored.current]}>
|
|
95
|
+
<Popover.Trigger>Open</Popover.Trigger>
|
|
96
|
+
<Popover.Popup label="Item">
|
|
97
|
+
<Popover.Close>Close</Popover.Close>
|
|
98
|
+
</Popover.Popup>
|
|
99
|
+
</Popover.Root>
|
|
100
|
+
<html.button ref={ignored} type="button">
|
|
101
|
+
Grip (ignored)
|
|
102
|
+
</html.button>
|
|
103
|
+
<html.button type="button">Elsewhere</html.button>
|
|
104
|
+
</Stack>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const OutsidePress: Story = {
|
|
109
|
+
render: () => <WithIgnored onOpenChange={fn()} />,
|
|
110
|
+
play: async ({canvas, userEvent}) => {
|
|
111
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Open'}))
|
|
112
|
+
await expect(page().getByRole('dialog', {name: 'Item'})).toBeVisible()
|
|
113
|
+
|
|
114
|
+
// A press on an ignored element keeps it open.
|
|
115
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Grip (ignored)'}))
|
|
116
|
+
await expect(page().getByRole('dialog', {name: 'Item'})).toBeVisible()
|
|
117
|
+
|
|
118
|
+
// A press anywhere else closes it.
|
|
119
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Elsewhere'}))
|
|
120
|
+
await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const NestedSelectOwnsEscape: Story = {
|
|
125
|
+
render: () => (
|
|
126
|
+
<Popover.Root>
|
|
127
|
+
<Popover.Trigger>Style</Popover.Trigger>
|
|
128
|
+
<Popover.Popup label="Style">
|
|
129
|
+
<Select.Root defaultValue="primary">
|
|
130
|
+
<Select.Trigger aria-label="Variant">
|
|
131
|
+
<Select.Value />
|
|
132
|
+
<Select.Icon />
|
|
133
|
+
</Select.Trigger>
|
|
134
|
+
<Select.Popup>
|
|
135
|
+
<Select.Item value="primary">
|
|
136
|
+
<Select.ItemText>Primary</Select.ItemText>
|
|
137
|
+
</Select.Item>
|
|
138
|
+
<Select.Item value="secondary">
|
|
139
|
+
<Select.ItemText>Secondary</Select.ItemText>
|
|
140
|
+
</Select.Item>
|
|
141
|
+
</Select.Popup>
|
|
142
|
+
</Select.Root>
|
|
143
|
+
</Popover.Popup>
|
|
144
|
+
</Popover.Root>
|
|
145
|
+
),
|
|
146
|
+
play: async ({canvas, userEvent}) => {
|
|
147
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Style'}))
|
|
148
|
+
const variant = page().getByRole('combobox', {name: 'Variant'})
|
|
149
|
+
await userEvent.click(variant)
|
|
150
|
+
await expect(variant).toHaveAttribute('aria-expanded', 'true')
|
|
151
|
+
|
|
152
|
+
// The first Esc closes only the Select...
|
|
153
|
+
await userEvent.keyboard('{Escape}')
|
|
154
|
+
await expect(variant).toHaveAttribute('aria-expanded', 'false')
|
|
155
|
+
await expect(page().getByRole('dialog', {name: 'Style'})).toBeVisible()
|
|
156
|
+
|
|
157
|
+
// ...the second closes the popover.
|
|
158
|
+
await userEvent.keyboard('{Escape}')
|
|
159
|
+
await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function VirtualAnchorDemo() {
|
|
164
|
+
const spot = useRef<HTMLDivElement | null>(null)
|
|
165
|
+
const handle = useRef<PopoverHandle | null>(null)
|
|
166
|
+
const [open, setOpen] = useState(false)
|
|
167
|
+
const [shifted, setShifted] = useState(false)
|
|
168
|
+
return (
|
|
169
|
+
<Stack gap="sm">
|
|
170
|
+
<Button onClick={() => setOpen(true)}>Edit the spot</Button>
|
|
171
|
+
<html.div style={styles.stage}>
|
|
172
|
+
<html.div ref={spot} style={[styles.spot, shifted && styles.shifted]}>
|
|
173
|
+
Spot
|
|
174
|
+
</html.div>
|
|
175
|
+
</html.div>
|
|
176
|
+
<Popover.Root
|
|
177
|
+
ref={handle}
|
|
178
|
+
open={open}
|
|
179
|
+
onOpenChange={setOpen}
|
|
180
|
+
anchor={() => spot.current!.getBoundingClientRect()}
|
|
181
|
+
>
|
|
182
|
+
<Popover.Popup label="Spot">
|
|
183
|
+
<Stack gap="sm">
|
|
184
|
+
<Button
|
|
185
|
+
variant="secondary"
|
|
186
|
+
onClick={() => {
|
|
187
|
+
setShifted((s) => !s)
|
|
188
|
+
// The anchor moved without a scroll or resize: re-measure.
|
|
189
|
+
requestAnimationFrame(() => handle.current?.reposition())
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
Move the spot
|
|
193
|
+
</Button>
|
|
194
|
+
<Popover.Close>Done</Popover.Close>
|
|
195
|
+
</Stack>
|
|
196
|
+
</Popover.Popup>
|
|
197
|
+
</Popover.Root>
|
|
198
|
+
</Stack>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const VirtualAnchor: Story = {
|
|
203
|
+
render: () => <VirtualAnchorDemo />,
|
|
204
|
+
play: async ({canvas, userEvent}) => {
|
|
205
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Edit the spot'}))
|
|
206
|
+
const dialog = page().getByRole('dialog', {name: 'Spot'})
|
|
207
|
+
const spot = canvas.getByText('Spot')
|
|
208
|
+
await waitFor(() =>
|
|
209
|
+
expect(dialog.getBoundingClientRect().left).toBeCloseTo(spot.getBoundingClientRect().left, 0),
|
|
210
|
+
)
|
|
211
|
+
// Below the spot (side bottom).
|
|
212
|
+
await expect(dialog.getBoundingClientRect().top).toBeGreaterThanOrEqual(
|
|
213
|
+
spot.getBoundingClientRect().bottom,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const before = spot.getBoundingClientRect().left
|
|
217
|
+
await userEvent.click(page().getByRole('button', {name: 'Move the spot'}))
|
|
218
|
+
await waitFor(() => expect(spot.getBoundingClientRect().left).toBeGreaterThan(before))
|
|
219
|
+
await waitFor(() =>
|
|
220
|
+
expect(dialog.getBoundingClientRect().left).toBeCloseTo(spot.getBoundingClientRect().left, 0),
|
|
221
|
+
)
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function DisclosureDemo() {
|
|
226
|
+
const [expanded, setExpanded] = useState(true)
|
|
227
|
+
return (
|
|
228
|
+
<Popover.Root>
|
|
229
|
+
<Popover.Trigger>Details</Popover.Trigger>
|
|
230
|
+
<Popover.Popup label="Details">
|
|
231
|
+
<html.button type="button" aria-expanded={expanded} onClick={() => setExpanded((e) => !e)}>
|
|
232
|
+
More
|
|
233
|
+
</html.button>
|
|
234
|
+
</Popover.Popup>
|
|
235
|
+
</Popover.Root>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const DisclosureDoesNotOwnEscape: Story = {
|
|
240
|
+
render: () => <DisclosureDemo />,
|
|
241
|
+
play: async ({canvas, userEvent}) => {
|
|
242
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Details'}))
|
|
243
|
+
const more = page().getByRole('button', {name: 'More'})
|
|
244
|
+
await waitFor(() => expect(more).toHaveFocus())
|
|
245
|
+
await expect(more).toHaveAttribute('aria-expanded', 'true')
|
|
246
|
+
// An expanded disclosure opens no popup of its own: Esc closes the popover.
|
|
247
|
+
await userEvent.keyboard('{Escape}')
|
|
248
|
+
await expect(page().queryByRole('dialog')).not.toBeInTheDocument()
|
|
249
|
+
},
|
|
250
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReactNode,
|
|
3
|
+
type Ref,
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import {createPortal} from 'react-dom'
|
|
15
|
+
import {html} from 'react-strict-dom'
|
|
16
|
+
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
17
|
+
import {styles} from './styles.css'
|
|
18
|
+
import {computePopoverPosition, type PopoverAlign, type PopoverSide} from './position'
|
|
19
|
+
import {isOutsidePress} from './outside'
|
|
20
|
+
|
|
21
|
+
// --- Context ---
|
|
22
|
+
|
|
23
|
+
type IgnoreEntry = Element | null | (() => Element | null)
|
|
24
|
+
|
|
25
|
+
interface PopoverContextValue {
|
|
26
|
+
open: boolean
|
|
27
|
+
setOpen: (open: boolean) => void
|
|
28
|
+
popoverId: string
|
|
29
|
+
side: PopoverSide
|
|
30
|
+
align: PopoverAlign
|
|
31
|
+
anchor?: () => DOMRect
|
|
32
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
33
|
+
popupRef: React.RefObject<HTMLDivElement | null>
|
|
34
|
+
repositionRef: React.RefObject<(() => void) | null>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PopoverContext = createContext<PopoverContextValue | null>(null)
|
|
38
|
+
|
|
39
|
+
function usePopover(part: string) {
|
|
40
|
+
const ctx = useContext(PopoverContext)
|
|
41
|
+
if (!ctx) throw new Error(`Popover.${part} must be used within Popover.Root`)
|
|
42
|
+
return ctx
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FOCUSABLE =
|
|
46
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
47
|
+
|
|
48
|
+
const OFFSET = 4 // `xs` token
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a nested popup control owns this Escape: a Select, Menu or
|
|
52
|
+
* Combobox inside the popup closes on Esc without preventing default, and
|
|
53
|
+
* that Esc must close only it. Only controls that open a popup count — an
|
|
54
|
+
* expanded trigger that declares one (`aria-haspopup`), an expanded combobox,
|
|
55
|
+
* or focus inside a listbox or menu — never a plain disclosure, and never
|
|
56
|
+
* this popover's own Trigger.
|
|
57
|
+
*
|
|
58
|
+
* holds-until: Duro's Select, Menu and Combobox call preventDefault on the
|
|
59
|
+
* Escape they consume; the defaultPrevented check alone then suffices and
|
|
60
|
+
* this selector goes.
|
|
61
|
+
*/
|
|
62
|
+
const NESTED_POPUP_OWNER =
|
|
63
|
+
'[aria-expanded="true"][aria-haspopup], [role="combobox"][aria-expanded="true"], [role="listbox"], [role="menu"]'
|
|
64
|
+
|
|
65
|
+
function nestedOwnsEscape(target: EventTarget | null, ownTrigger: Element | null): boolean {
|
|
66
|
+
if (!(target instanceof Element)) return false
|
|
67
|
+
const owner = target.closest(NESTED_POPUP_OWNER)
|
|
68
|
+
return owner !== null && owner !== ownTrigger
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Root ---
|
|
72
|
+
|
|
73
|
+
export interface PopoverHandle {
|
|
74
|
+
/** Re-measure the anchor and re-place the popup. */
|
|
75
|
+
reposition: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface RootProps {
|
|
79
|
+
children: ReactNode
|
|
80
|
+
open?: boolean
|
|
81
|
+
defaultOpen?: boolean
|
|
82
|
+
onOpenChange?: (open: boolean) => void
|
|
83
|
+
side?: PopoverSide
|
|
84
|
+
align?: PopoverAlign
|
|
85
|
+
/** Virtual anchor: a viewport rect. Replaces the Trigger as the anchor. */
|
|
86
|
+
anchor?: () => DOMRect
|
|
87
|
+
/** Elements (or getters) whose pointerdown does not count as an outside press. */
|
|
88
|
+
ignore?: ReadonlyArray<IgnoreEntry>
|
|
89
|
+
ref?: Ref<PopoverHandle>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function Root({
|
|
93
|
+
children,
|
|
94
|
+
open: openProp,
|
|
95
|
+
defaultOpen = false,
|
|
96
|
+
onOpenChange,
|
|
97
|
+
side = 'bottom',
|
|
98
|
+
align = 'start',
|
|
99
|
+
anchor,
|
|
100
|
+
ignore,
|
|
101
|
+
ref,
|
|
102
|
+
}: RootProps) {
|
|
103
|
+
const [inner, setInner] = useState(defaultOpen)
|
|
104
|
+
const controlled = openProp !== undefined
|
|
105
|
+
const open = controlled ? openProp : inner
|
|
106
|
+
const popoverId = useId()
|
|
107
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
108
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
109
|
+
const repositionRef = useRef<(() => void) | null>(null)
|
|
110
|
+
const previousFocusRef = useRef<HTMLElement | null>(null)
|
|
111
|
+
const ignoreRef = useRef(ignore)
|
|
112
|
+
ignoreRef.current = ignore
|
|
113
|
+
const openRef = useRef(open)
|
|
114
|
+
openRef.current = open
|
|
115
|
+
|
|
116
|
+
const setOpen = useCallback(
|
|
117
|
+
(next: boolean) => {
|
|
118
|
+
if (!controlled) setInner(next)
|
|
119
|
+
if (next !== openRef.current) onOpenChange?.(next)
|
|
120
|
+
},
|
|
121
|
+
[controlled, onOpenChange],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
useImperativeHandle(ref, () => ({reposition: () => repositionRef.current?.()}), [])
|
|
125
|
+
|
|
126
|
+
// Focus: into the popup on open, back to the trigger (or the previously
|
|
127
|
+
// focused element when there is no trigger) on close.
|
|
128
|
+
const wasOpenRef = useRef(false)
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (open && !wasOpenRef.current) {
|
|
131
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null
|
|
132
|
+
const popup = popupRef.current
|
|
133
|
+
if (popup) {
|
|
134
|
+
const first = popup.querySelector<HTMLElement>(FOCUSABLE)
|
|
135
|
+
;(first ?? popup).focus({preventScroll: true})
|
|
136
|
+
}
|
|
137
|
+
} else if (!open && wasOpenRef.current) {
|
|
138
|
+
const target = triggerRef.current ?? previousFocusRef.current
|
|
139
|
+
target?.focus?.()
|
|
140
|
+
previousFocusRef.current = null
|
|
141
|
+
}
|
|
142
|
+
wasOpenRef.current = open
|
|
143
|
+
}, [open])
|
|
144
|
+
|
|
145
|
+
// Esc and outside press.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!open) return
|
|
148
|
+
|
|
149
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
150
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return
|
|
151
|
+
if (nestedOwnsEscape(e.target, triggerRef.current)) return
|
|
152
|
+
e.preventDefault()
|
|
153
|
+
setOpen(false)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function onPointerDown(e: PointerEvent) {
|
|
157
|
+
const ignored = (ignoreRef.current ?? []).map((entry) =>
|
|
158
|
+
typeof entry === 'function' ? entry() : entry,
|
|
159
|
+
)
|
|
160
|
+
const inside = [popupRef.current, triggerRef.current, ...ignored]
|
|
161
|
+
if (isOutsidePress(e.target as Node | null, inside)) setOpen(false)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
document.addEventListener('keydown', onKeyDown)
|
|
165
|
+
document.addEventListener('pointerdown', onPointerDown, true)
|
|
166
|
+
return () => {
|
|
167
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
168
|
+
document.removeEventListener('pointerdown', onPointerDown, true)
|
|
169
|
+
}
|
|
170
|
+
}, [open, setOpen])
|
|
171
|
+
|
|
172
|
+
const ctx: PopoverContextValue = {
|
|
173
|
+
open,
|
|
174
|
+
setOpen,
|
|
175
|
+
popoverId,
|
|
176
|
+
side,
|
|
177
|
+
align,
|
|
178
|
+
anchor,
|
|
179
|
+
triggerRef,
|
|
180
|
+
popupRef,
|
|
181
|
+
repositionRef,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return <PopoverContext.Provider value={ctx}>{children}</PopoverContext.Provider>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Trigger ---
|
|
188
|
+
// Trigger IS the button (like Menu.Trigger): give it a label, not a Button.
|
|
189
|
+
|
|
190
|
+
function Trigger({children}: {children: ReactNode}) {
|
|
191
|
+
const {open, setOpen, popoverId, triggerRef} = usePopover('Trigger')
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<html.button
|
|
195
|
+
ref={triggerRef}
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => setOpen(!open)}
|
|
198
|
+
aria-haspopup="dialog"
|
|
199
|
+
aria-expanded={open}
|
|
200
|
+
aria-controls={open ? popoverId : undefined}
|
|
201
|
+
style={styles.trigger}
|
|
202
|
+
>
|
|
203
|
+
{children}
|
|
204
|
+
</html.button>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Popup ---
|
|
209
|
+
|
|
210
|
+
interface PopupProps {
|
|
211
|
+
children: ReactNode
|
|
212
|
+
/** Accessible name for the dialog. */
|
|
213
|
+
label: string
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function Popup({children, label}: PopupProps) {
|
|
217
|
+
const {open, popoverId, side, align, anchor, triggerRef, popupRef, repositionRef} =
|
|
218
|
+
usePopover('Popup')
|
|
219
|
+
const [coords, setCoords] = useState({top: 0, left: 0})
|
|
220
|
+
// Inside ThemeProvider's portal layer, like Select/Dialog/Drawer: theme
|
|
221
|
+
// tokens cascade into the popup and it stacks above dialogs.
|
|
222
|
+
const mount = usePortalMount()
|
|
223
|
+
|
|
224
|
+
// Measure before paint on open; re-measure on scroll/resize (capture phase
|
|
225
|
+
// catches any scrolling ancestor) and when Root.reposition() is called.
|
|
226
|
+
useLayoutEffect(() => {
|
|
227
|
+
if (!open) {
|
|
228
|
+
repositionRef.current = null
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const update = () => {
|
|
232
|
+
const popup = popupRef.current
|
|
233
|
+
if (!popup) return
|
|
234
|
+
const rect = anchor ? anchor() : triggerRef.current?.getBoundingClientRect()
|
|
235
|
+
if (!rect) return
|
|
236
|
+
const next = computePopoverPosition({
|
|
237
|
+
anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
|
|
238
|
+
popup: {width: popup.offsetWidth, height: popup.offsetHeight},
|
|
239
|
+
viewport: {width: window.innerWidth, height: window.innerHeight},
|
|
240
|
+
side,
|
|
241
|
+
align,
|
|
242
|
+
offset: OFFSET,
|
|
243
|
+
})
|
|
244
|
+
setCoords((prev) =>
|
|
245
|
+
prev.top === next.top && prev.left === next.left ? prev : {top: next.top, left: next.left},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
repositionRef.current = update
|
|
249
|
+
update()
|
|
250
|
+
window.addEventListener('resize', update)
|
|
251
|
+
window.addEventListener('scroll', update, true)
|
|
252
|
+
return () => {
|
|
253
|
+
repositionRef.current = null
|
|
254
|
+
window.removeEventListener('resize', update)
|
|
255
|
+
window.removeEventListener('scroll', update, true)
|
|
256
|
+
}
|
|
257
|
+
}, [open, side, align, anchor, triggerRef, popupRef, repositionRef])
|
|
258
|
+
|
|
259
|
+
if (!open || typeof document === 'undefined') return null
|
|
260
|
+
|
|
261
|
+
return createPortal(
|
|
262
|
+
<html.div
|
|
263
|
+
ref={popupRef}
|
|
264
|
+
id={popoverId}
|
|
265
|
+
role="dialog"
|
|
266
|
+
aria-label={label}
|
|
267
|
+
tabIndex={-1}
|
|
268
|
+
style={[styles.popup, styles.popupPosition(coords.top, coords.left)]}
|
|
269
|
+
>
|
|
270
|
+
{children}
|
|
271
|
+
</html.div>,
|
|
272
|
+
mount ?? document.body,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- Close ---
|
|
277
|
+
|
|
278
|
+
function Close({children}: {children: ReactNode}) {
|
|
279
|
+
const {setOpen} = usePopover('Close')
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<html.button type="button" onClick={() => setOpen(false)} style={styles.close}>
|
|
283
|
+
{children}
|
|
284
|
+
</html.button>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const Popover = {
|
|
289
|
+
Root,
|
|
290
|
+
Trigger,
|
|
291
|
+
Popup,
|
|
292
|
+
Close,
|
|
293
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// True when a pointerdown target lies outside the popup, the trigger and every
|
|
2
|
+
// ignored element. `inside` may hold nulls (refs not yet attached).
|
|
3
|
+
export function isOutsidePress(
|
|
4
|
+
target: Node | null,
|
|
5
|
+
inside: ReadonlyArray<Element | null>,
|
|
6
|
+
): boolean {
|
|
7
|
+
if (!target) return true
|
|
8
|
+
return !inside.some((el) => el != null && el.contains(target))
|
|
9
|
+
}
|