@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.
Files changed (34) hide show
  1. package/dist/components/DetailPanel/DetailPanel.meta.d.ts.map +1 -1
  2. package/dist/components/Dialog/Dialog.meta.d.ts.map +1 -1
  3. package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
  4. package/dist/components/Menu/Menu.meta.d.ts.map +1 -1
  5. package/dist/components/Popover/Popover.d.ts +41 -0
  6. package/dist/components/Popover/Popover.d.ts.map +1 -0
  7. package/dist/components/Popover/Popover.meta.d.ts +3 -0
  8. package/dist/components/Popover/Popover.meta.d.ts.map +1 -0
  9. package/dist/components/Popover/Popover.stories.d.ts +11 -0
  10. package/dist/components/Popover/Popover.stories.d.ts.map +1 -0
  11. package/dist/components/Popover/outside.d.ts +2 -0
  12. package/dist/components/Popover/outside.d.ts.map +1 -0
  13. package/dist/components/Popover/position.d.ts +27 -0
  14. package/dist/components/Popover/position.d.ts.map +1 -0
  15. package/dist/components/Popover/styles.css.d.ts +61 -0
  16. package/dist/components/Popover/styles.css.d.ts.map +1 -0
  17. package/dist/index.css +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1645 -1381
  21. package/dist/index.js.map +1 -1
  22. package/package.json +2 -2
  23. package/src/components/DetailPanel/DetailPanel.meta.ts +6 -0
  24. package/src/components/Dialog/Dialog.meta.ts +6 -0
  25. package/src/components/Drawer/Drawer.meta.ts +6 -0
  26. package/src/components/Menu/Menu.meta.ts +5 -1
  27. package/src/components/Popover/Popover.meta.ts +69 -0
  28. package/src/components/Popover/Popover.stories.tsx +250 -0
  29. package/src/components/Popover/Popover.tsx +293 -0
  30. package/src/components/Popover/outside.ts +9 -0
  31. package/src/components/Popover/position.ts +48 -0
  32. package/src/components/Popover/styles.css.ts +75 -0
  33. package/src/components/Tooltip/Tooltip.meta.ts +1 -1
  34. 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.2.0",
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.2.0"
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: ['Picking a value — use Select', 'Primary navigation — use SideNav or Tabs'],
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
+ }