@lovett/ui 0.0.10 → 0.0.11
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/index.d.ts +666 -13
- package/dist/index.js +1835 -344
- package/dist/index.js.map +1 -1
- package/dist/styles.css +16 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +9 -0
- package/package.json +1 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/dropdown-menu.test.tsx +418 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/index.ts +106 -0
- package/src/lib/anchor.ts +427 -0
- package/src/lib/focus.ts +32 -0
- package/src/lib/layer-stack.ts +188 -0
- package/src/lib/refs.ts +31 -0
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +31 -48
- package/src/popover.tsx +407 -0
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +16 -2
- package/src/tokens.css +9 -0
- package/src/tooltip.tsx +297 -0
package/src/metric-card.tsx
CHANGED
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
* Token discipline: all colors via public tokens. Tone variant gives
|
|
12
12
|
* the value text an accent/success/warning/destructive/info color;
|
|
13
13
|
* background is always neutral surface-overlay.
|
|
14
|
+
*
|
|
15
|
+
* `copyable` (ADR-145 amendment, 2026-09-04): the card becomes a button
|
|
16
|
+
* that copies `copyValue` (or the string `value`) and toasts
|
|
17
|
+
* "Copied <label>". Same hover/focus affordance as ValueChip and the
|
|
18
|
+
* copyable StatRow. Inert by default so a read-only KPI never advertises
|
|
19
|
+
* an interaction it doesn't have.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import type { ReactNode } from 'react'
|
|
23
|
+
import { cn } from './lib/utils'
|
|
24
|
+
import { copyText } from './lib/clipboard'
|
|
17
25
|
|
|
18
26
|
export type MetricCardTone = 'neutral' | 'accent' | 'success' | 'warning' | 'destructive' | 'info'
|
|
19
27
|
|
|
@@ -46,6 +54,11 @@ export interface MetricCardProps {
|
|
|
46
54
|
labelPosition?: 'top' | 'bottom'
|
|
47
55
|
/** Optional className override for the outer container. */
|
|
48
56
|
className?: string
|
|
57
|
+
/** Make the whole card a copy-to-clipboard button. */
|
|
58
|
+
copyable?: boolean
|
|
59
|
+
/** What a copyable card writes. Defaults to `value` when it is a string;
|
|
60
|
+
* REQUIRED when `value` is a node (there is nothing else to copy). */
|
|
61
|
+
copyValue?: string
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export function MetricCard({
|
|
@@ -55,6 +68,8 @@ export function MetricCard({
|
|
|
55
68
|
icon,
|
|
56
69
|
labelPosition = 'bottom',
|
|
57
70
|
className,
|
|
71
|
+
copyable = false,
|
|
72
|
+
copyValue,
|
|
58
73
|
}: MetricCardProps) {
|
|
59
74
|
const labelEl =
|
|
60
75
|
labelPosition === 'top' ? (
|
|
@@ -89,29 +104,49 @@ export function MetricCard({
|
|
|
89
104
|
</div>
|
|
90
105
|
)
|
|
91
106
|
|
|
107
|
+
const body =
|
|
108
|
+
labelPosition === 'top' ? (
|
|
109
|
+
<>
|
|
110
|
+
{labelEl}
|
|
111
|
+
{valueEl}
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
{valueEl}
|
|
116
|
+
{labelEl}
|
|
117
|
+
</>
|
|
118
|
+
)
|
|
119
|
+
const surfaceClass = className ?? 'flex flex-col gap-1 px-4 py-3 border'
|
|
120
|
+
const surfaceStyle = {
|
|
121
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
122
|
+
borderColor: 'rgb(var(--border))',
|
|
123
|
+
borderRadius: 'var(--radius-lg)',
|
|
124
|
+
} as const
|
|
125
|
+
|
|
126
|
+
const text = copyValue ?? (typeof value === 'string' ? value : undefined)
|
|
127
|
+
if (copyable && text !== undefined) {
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => void copyText(text, label)}
|
|
132
|
+
title={`Copy ${label}`}
|
|
133
|
+
aria-label={`Copy ${label}: ${text}`}
|
|
134
|
+
className={cn(
|
|
135
|
+
surfaceClass,
|
|
136
|
+
'text-left cursor-pointer transition-[border-color,box-shadow]',
|
|
137
|
+
'hover:border-[rgb(var(--border-strong))] hover:[box-shadow:var(--shadow-sm)]',
|
|
138
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
139
|
+
)}
|
|
140
|
+
style={surfaceStyle}
|
|
141
|
+
>
|
|
142
|
+
{body}
|
|
143
|
+
</button>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
return (
|
|
93
|
-
<div
|
|
94
|
-
|
|
95
|
-
className ??
|
|
96
|
-
'flex flex-col gap-1 px-4 py-3 border'
|
|
97
|
-
}
|
|
98
|
-
style={{
|
|
99
|
-
background: 'rgb(var(--surface-overlay-soft))',
|
|
100
|
-
borderColor: 'rgb(var(--border))',
|
|
101
|
-
borderRadius: 'var(--radius-lg)',
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{labelPosition === 'top' ? (
|
|
105
|
-
<>
|
|
106
|
-
{labelEl}
|
|
107
|
-
{valueEl}
|
|
108
|
-
</>
|
|
109
|
-
) : (
|
|
110
|
-
<>
|
|
111
|
-
{valueEl}
|
|
112
|
-
{labelEl}
|
|
113
|
-
</>
|
|
114
|
-
)}
|
|
148
|
+
<div className={surfaceClass} style={surfaceStyle}>
|
|
149
|
+
{body}
|
|
115
150
|
</div>
|
|
116
151
|
)
|
|
117
152
|
}
|
package/src/modal.tsx
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from 'react'
|
|
11
11
|
import { X } from 'lucide-react'
|
|
12
12
|
import { cn } from './lib/utils'
|
|
13
|
+
import { tabbablesWithin } from './lib/focus'
|
|
14
|
+
import { useLayer } from './lib/layer-stack'
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Modal — a centered floating panel on top of a backdrop. Built on
|
|
@@ -56,47 +58,25 @@ import { cn } from './lib/utils'
|
|
|
56
58
|
* the `.cs-frame`-scoped token layer and out of their current stacking /
|
|
57
59
|
* inheritance context, which is a much larger change than an a11y fix
|
|
58
60
|
* should make. Inerting `document.body`'s other children is also wrong:
|
|
59
|
-
* `DropdownMenu`, `TagChipInput` and `FolderTreePicker` portal
|
|
60
|
-
* content THERE, so a select inside a modal would go dead. Focus trap
|
|
61
|
-
* is — the keyboard hole is closed; pointer and AT virtual-cursor access
|
|
61
|
+
* `DropdownMenu`, `Popover`, `TagChipInput` and `FolderTreePicker` portal
|
|
62
|
+
* their content THERE, so a select inside a modal would go dead. Focus trap
|
|
63
|
+
* it is — the keyboard hole is closed; pointer and AT virtual-cursor access
|
|
62
64
|
* to the background remains, which is the documented residual.
|
|
65
|
+
*
|
|
66
|
+
* Escape and dismiss order come from the shared layer stack
|
|
67
|
+
* (`lib/layer-stack.ts`): the topmost open surface — modal, menu or
|
|
68
|
+
* popover — answers Escape alone.
|
|
63
69
|
*/
|
|
64
70
|
|
|
65
71
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* Tabbable candidates. Deliberately attribute-only — no geometry check.
|
|
74
|
-
* `offsetWidth` / `getClientRects()` are always zero in jsdom, so a
|
|
75
|
-
* visibility filter would empty this list under test and silently
|
|
76
|
-
* disable the trap in exactly the environment that verifies it.
|
|
72
|
+
* Dismiss order lives in the shared layer stack (`lib/layer-stack.ts`),
|
|
73
|
+
* which this file used to own as a module-level `openPanels` array. Only
|
|
74
|
+
* the topmost layer answers Escape, so nested modals do not both close on
|
|
75
|
+
* one keypress — and now a DropdownMenu or Popover opened from inside a
|
|
76
|
+
* modal closes on its own first, leaving the modal open. The Tab trap asks
|
|
77
|
+
* for the topmost MODAL (`isTopOfKind`), so a popover above it does not
|
|
78
|
+
* switch the trap off.
|
|
77
79
|
*/
|
|
78
|
-
const FOCUSABLE_SELECTOR = [
|
|
79
|
-
'a[href]',
|
|
80
|
-
'area[href]',
|
|
81
|
-
'button:not([disabled])',
|
|
82
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
83
|
-
'select:not([disabled])',
|
|
84
|
-
'textarea:not([disabled])',
|
|
85
|
-
'iframe',
|
|
86
|
-
'summary',
|
|
87
|
-
'[contenteditable="true"]',
|
|
88
|
-
'[tabindex]',
|
|
89
|
-
].join(',')
|
|
90
|
-
|
|
91
|
-
function tabbablesWithin(panel: HTMLElement): HTMLElement[] {
|
|
92
|
-
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
93
|
-
(el) =>
|
|
94
|
-
el.tabIndex >= 0 &&
|
|
95
|
-
!el.hasAttribute('inert') &&
|
|
96
|
-
el.getAttribute('aria-hidden') !== 'true' &&
|
|
97
|
-
!el.closest('[hidden]'),
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
80
|
|
|
101
81
|
interface ModalProps {
|
|
102
82
|
isOpen: boolean
|
|
@@ -130,13 +110,10 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
130
110
|
? document.activeElement
|
|
131
111
|
: null
|
|
132
112
|
|
|
133
|
-
openPanels.push(panel)
|
|
134
113
|
const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
|
|
135
114
|
;(initial ?? panel).focus()
|
|
136
115
|
|
|
137
116
|
return () => {
|
|
138
|
-
const at = openPanels.indexOf(panel)
|
|
139
|
-
if (at !== -1) openPanels.splice(at, 1)
|
|
140
117
|
// Restoring is the half that gets forgotten: without it, closing a
|
|
141
118
|
// modal drops the caret to <body> and the next Tab restarts from
|
|
142
119
|
// the top of the page.
|
|
@@ -144,21 +121,27 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
144
121
|
}
|
|
145
122
|
}, [isOpen])
|
|
146
123
|
|
|
147
|
-
// Escape
|
|
148
|
-
//
|
|
124
|
+
// Escape: the shared stack dispatches it to the topmost layer only, and
|
|
125
|
+
// reads `onClose` through a ref so an inline arrow does not re-register
|
|
126
|
+
// (or reorder) the layer on every render.
|
|
127
|
+
const layer = useLayer({
|
|
128
|
+
enabled: isOpen,
|
|
129
|
+
kind: 'modal',
|
|
130
|
+
elementRef: panelRef,
|
|
131
|
+
onEscape: () => onClose(),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Tab trap. One document listener; the topmost-MODAL guard keeps nested
|
|
135
|
+
// modals from both reacting, and keeps this trap live while a popover
|
|
136
|
+
// sits above it.
|
|
149
137
|
useEffect(() => {
|
|
150
138
|
if (!isOpen) return
|
|
151
139
|
const panel = panelRef.current
|
|
152
140
|
if (!panel) return
|
|
153
141
|
|
|
154
142
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
155
|
-
if (openPanels[openPanels.length - 1] !== panel) return
|
|
156
|
-
|
|
157
|
-
if (e.key === 'Escape') {
|
|
158
|
-
onClose()
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
143
|
if (e.key !== 'Tab') return
|
|
144
|
+
if (!layer.isTopOfKind()) return
|
|
162
145
|
|
|
163
146
|
const active = document.activeElement
|
|
164
147
|
// Focus is outside the panel — almost always a body-level portal
|
|
@@ -199,7 +182,7 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
199
182
|
|
|
200
183
|
document.addEventListener('keydown', handleKeyDown)
|
|
201
184
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
202
|
-
}, [isOpen,
|
|
185
|
+
}, [isOpen, layer])
|
|
203
186
|
|
|
204
187
|
if (!isOpen) return null
|
|
205
188
|
|
package/src/popover.tsx
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover — an anchored, non-modal floating surface with focus management.
|
|
3
|
+
*
|
|
4
|
+
* Compound: `<Popover>` (state) + `<Popover.Trigger>` + `<Popover.Content>`.
|
|
5
|
+
* Controlled (`open` + `onOpenChange`) or uncontrolled (`defaultOpen`).
|
|
6
|
+
* Content portals to `document.body`, positions through
|
|
7
|
+
* `useAnchoredPosition` (flip + clamp), enters with `.ds-enter-pop` once
|
|
8
|
+
* positioned, and registers on the shared dismiss-layer stack — so Escape
|
|
9
|
+
* closes ONLY the topmost surface (a Popover inside a Modal closes before
|
|
10
|
+
* the Modal does) and an outside pointerdown closes it unless the pointer
|
|
11
|
+
* landed in a layer stacked above (a Select opened from inside it).
|
|
12
|
+
*
|
|
13
|
+
* Focus: opening moves focus into the content — the `[data-autofocus]`
|
|
14
|
+
* control if one is marked, else the first tabbable, else the content
|
|
15
|
+
* itself (`tabIndex={-1}`) — once the content is POSITIONED, not on the
|
|
16
|
+
* first frame: that frame is parked off-screen with `visibility: hidden`,
|
|
17
|
+
* and no browser will focus into a hidden subtree (the call is a silent
|
|
18
|
+
* no-op). Closing returns focus to the trigger, EXCEPT
|
|
19
|
+
* after an outside click, where the user has already put focus where they
|
|
20
|
+
* want it and yanking it back to the trigger would be wrong.
|
|
21
|
+
*
|
|
22
|
+
* Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
|
|
23
|
+
* the row / card / context-menu / palette pickers all sit in a popover with
|
|
24
|
+
* search + live counts). Second consumer is the workspace app, which has
|
|
25
|
+
* three hand-rolled popovers today (sidebar user menu, group-settings
|
|
26
|
+
* colour and icon pickers, CalculatorShell's AssumptionsPopover) — each a
|
|
27
|
+
* migration target. DropdownMenu stays a sibling on the same hook and stack
|
|
28
|
+
* rather than a Popover child: a menu does NOT move focus into itself on
|
|
29
|
+
* open, and 137 call sites depend on that.
|
|
30
|
+
*
|
|
31
|
+
* Trigger is an unstyled `<button type="button">` by default — Popover is a
|
|
32
|
+
* generic surface, the caller chooses the trigger's look. Pass `asChild` to
|
|
33
|
+
* make the single child element the trigger instead (a `<Button>`, a table
|
|
34
|
+
* cell, a chip): it receives the ref, the click handler and the ARIA
|
|
35
|
+
* attributes, and the DOM stays one element. The child must be keyboard-
|
|
36
|
+
* operable ON ITS OWN: Trigger adds ARIA + click only, no role, tabIndex or
|
|
37
|
+
* key handler. A native `<button>` / `<a href>` already is; a `<td>` or
|
|
38
|
+
* `<span>` needs `role="button"`, `tabIndex={0}` and its own Enter / Space
|
|
39
|
+
* handling, or only mouse users can open the popover.
|
|
40
|
+
*
|
|
41
|
+
* Content is `role="dialog"` (non-modal — no `aria-modal`) and needs a name
|
|
42
|
+
* from the caller (`aria-label` / `aria-labelledby`). Pass `role="none"`
|
|
43
|
+
* when the content IS the widget (a listbox, a menu) and would otherwise
|
|
44
|
+
* nest roles.
|
|
45
|
+
*
|
|
46
|
+
* Token discipline: the surface reads the INTERNAL `--popover` /
|
|
47
|
+
* `--popover-foreground` (allowed in packages/ui, ESLint-enforced elsewhere)
|
|
48
|
+
* plus public `--border`, `--shadow-lg`, `--radius-lg`. Geometry comes from
|
|
49
|
+
* the hook; motion from `.ds-enter-pop` (`--dur-fast` / `--ease-out`, honours
|
|
50
|
+
* prefers-reduced-motion in styles.css). No hex, no raw px in classes.
|
|
51
|
+
*
|
|
52
|
+
* Usage:
|
|
53
|
+
*
|
|
54
|
+
* <Popover>
|
|
55
|
+
* <Popover.Trigger asChild>
|
|
56
|
+
* <Button variant="secondary">Assignee</Button>
|
|
57
|
+
* </Popover.Trigger>
|
|
58
|
+
* <Popover.Content aria-label="Pick an assignee" align="start">
|
|
59
|
+
* <input data-autofocus placeholder="Search people…" />
|
|
60
|
+
* …
|
|
61
|
+
* </Popover.Content>
|
|
62
|
+
* </Popover>
|
|
63
|
+
*
|
|
64
|
+
* // Virtual anchor (ContextMenu later): no Trigger, position at a point.
|
|
65
|
+
* <Popover open={!!point} onOpenChange={…} anchorRect={point}>
|
|
66
|
+
* <Popover.Content role="none">…</Popover.Content>
|
|
67
|
+
* </Popover>
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import {
|
|
71
|
+
cloneElement,
|
|
72
|
+
createContext,
|
|
73
|
+
forwardRef,
|
|
74
|
+
isValidElement,
|
|
75
|
+
useCallback,
|
|
76
|
+
useContext,
|
|
77
|
+
useEffect,
|
|
78
|
+
useId,
|
|
79
|
+
useMemo,
|
|
80
|
+
useRef,
|
|
81
|
+
useState,
|
|
82
|
+
type ButtonHTMLAttributes,
|
|
83
|
+
type CSSProperties,
|
|
84
|
+
type HTMLAttributes,
|
|
85
|
+
type MouseEvent as ReactMouseEvent,
|
|
86
|
+
type ReactNode,
|
|
87
|
+
type Ref,
|
|
88
|
+
type RefObject,
|
|
89
|
+
} from 'react'
|
|
90
|
+
import { createPortal } from 'react-dom'
|
|
91
|
+
import { cn } from './lib/utils'
|
|
92
|
+
import { composeRefs } from './lib/refs'
|
|
93
|
+
import { tabbablesWithin } from './lib/focus'
|
|
94
|
+
import { useLayer } from './lib/layer-stack'
|
|
95
|
+
import {
|
|
96
|
+
useAnchoredPosition,
|
|
97
|
+
useOutsideClick,
|
|
98
|
+
type AnchorAlign,
|
|
99
|
+
type AnchorRect,
|
|
100
|
+
type AnchorSide,
|
|
101
|
+
} from './lib/anchor'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The popover surface chrome, shared with DropdownMenuContent so a menu and
|
|
105
|
+
* a popover read as the same family. Inline rather than a stylesheet class
|
|
106
|
+
* because the surface tokens are internal and the primitives already own
|
|
107
|
+
* them here (Kbd, DropdownMenu follow the same pattern).
|
|
108
|
+
*/
|
|
109
|
+
export const POPOVER_SURFACE_STYLE: CSSProperties = {
|
|
110
|
+
background: 'rgb(var(--popover))',
|
|
111
|
+
color: 'rgb(var(--popover-foreground))',
|
|
112
|
+
border: '1px solid rgb(var(--border))',
|
|
113
|
+
boxShadow: 'var(--shadow-lg)',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface PopoverContextValue {
|
|
117
|
+
open: boolean
|
|
118
|
+
setOpen: (next: boolean) => void
|
|
119
|
+
triggerRef: RefObject<HTMLElement | null>
|
|
120
|
+
contentId: string
|
|
121
|
+
anchorRect: AnchorRect | null
|
|
122
|
+
/** Set when the close came from an outside pointerdown; skips focus return. */
|
|
123
|
+
interactedOutsideRef: RefObject<boolean>
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PopoverCtx = createContext<PopoverContextValue | null>(null)
|
|
127
|
+
|
|
128
|
+
function usePopoverCtx(consumer: string): PopoverContextValue {
|
|
129
|
+
const ctx = useContext(PopoverCtx)
|
|
130
|
+
if (!ctx) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`${consumer} must be rendered inside <Popover>. ` +
|
|
133
|
+
`Wrap your trigger + content with the Popover root component.`,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return ctx
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface PopoverProps {
|
|
140
|
+
/** Controlled open state. Omit for internal state. */
|
|
141
|
+
open?: boolean
|
|
142
|
+
defaultOpen?: boolean
|
|
143
|
+
/** Fires whenever open transitions. */
|
|
144
|
+
onOpenChange?: (open: boolean) => void
|
|
145
|
+
/**
|
|
146
|
+
* Virtual anchor. When non-null, Content positions against this rect
|
|
147
|
+
* instead of the Trigger — a right-click point, a caret, a cell.
|
|
148
|
+
*/
|
|
149
|
+
anchorRect?: AnchorRect | null
|
|
150
|
+
children: ReactNode
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function PopoverRoot({
|
|
154
|
+
open: controlledOpen,
|
|
155
|
+
defaultOpen,
|
|
156
|
+
onOpenChange,
|
|
157
|
+
anchorRect = null,
|
|
158
|
+
children,
|
|
159
|
+
}: PopoverProps) {
|
|
160
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
|
|
161
|
+
const isControlled = controlledOpen !== undefined
|
|
162
|
+
const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
|
|
163
|
+
const triggerRef = useRef<HTMLElement | null>(null)
|
|
164
|
+
const interactedOutsideRef = useRef(false)
|
|
165
|
+
const generatedId = useId()
|
|
166
|
+
const contentId = `popover-${generatedId.replace(/:/g, '')}`
|
|
167
|
+
|
|
168
|
+
const setOpen = useCallback(
|
|
169
|
+
(next: boolean) => {
|
|
170
|
+
if (!isControlled) setUncontrolledOpen(next)
|
|
171
|
+
onOpenChange?.(next)
|
|
172
|
+
},
|
|
173
|
+
[isControlled, onOpenChange],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const value = useMemo<PopoverContextValue>(
|
|
177
|
+
() => ({ open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef }),
|
|
178
|
+
[open, setOpen, contentId, anchorRect],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return <PopoverCtx.Provider value={value}>{children}</PopoverCtx.Provider>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface PopoverTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
185
|
+
/**
|
|
186
|
+
* Render the single child element as the trigger instead of a `<button>`.
|
|
187
|
+
* The child receives the ref, click handler and ARIA attributes; the DOM
|
|
188
|
+
* stays one element. It must be focusable and keyboard-operable on its
|
|
189
|
+
* own (a `<button>`, an `<a href>`, or `role="button"` + `tabIndex={0}` +
|
|
190
|
+
* Enter / Space handling): Trigger adds no role, tabIndex or key handler.
|
|
191
|
+
*/
|
|
192
|
+
asChild?: boolean
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type TriggerChildProps = HTMLAttributes<HTMLElement> & {
|
|
196
|
+
ref?: Ref<HTMLElement>
|
|
197
|
+
'data-state'?: string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
|
|
201
|
+
function PopoverTrigger({ asChild, onClick, children, ...rest }, forwardedRef) {
|
|
202
|
+
const { open, setOpen, triggerRef, contentId } = usePopoverCtx('Popover.Trigger')
|
|
203
|
+
|
|
204
|
+
const ariaProps = {
|
|
205
|
+
'aria-haspopup': 'dialog',
|
|
206
|
+
'aria-expanded': open,
|
|
207
|
+
'aria-controls': open ? contentId : undefined,
|
|
208
|
+
'data-state': open ? 'open' : 'closed',
|
|
209
|
+
} as const
|
|
210
|
+
|
|
211
|
+
if (asChild) {
|
|
212
|
+
if (!isValidElement<TriggerChildProps>(children)) {
|
|
213
|
+
throw new Error('Popover.Trigger with `asChild` expects exactly one element child.')
|
|
214
|
+
}
|
|
215
|
+
const child = children
|
|
216
|
+
const childOnClick = child.props.onClick
|
|
217
|
+
return cloneElement(child, {
|
|
218
|
+
...rest,
|
|
219
|
+
...ariaProps,
|
|
220
|
+
ref: composeRefs<HTMLElement>(child.props.ref, forwardedRef, triggerRef),
|
|
221
|
+
onClick: (event: ReactMouseEvent<HTMLElement>) => {
|
|
222
|
+
childOnClick?.(event)
|
|
223
|
+
onClick?.(event as ReactMouseEvent<HTMLButtonElement>)
|
|
224
|
+
if (!event.defaultPrevented) setOpen(!open)
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<button
|
|
231
|
+
ref={composeRefs<HTMLElement>(forwardedRef, triggerRef)}
|
|
232
|
+
type="button"
|
|
233
|
+
{...ariaProps}
|
|
234
|
+
onClick={(event) => {
|
|
235
|
+
onClick?.(event)
|
|
236
|
+
if (!event.defaultPrevented) setOpen(!open)
|
|
237
|
+
}}
|
|
238
|
+
{...rest}
|
|
239
|
+
>
|
|
240
|
+
{children}
|
|
241
|
+
</button>
|
|
242
|
+
)
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
export interface PopoverContentProps
|
|
247
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {
|
|
248
|
+
children: ReactNode
|
|
249
|
+
/** Preferred side of the anchor. Default `bottom`. Flips when it cannot fit. */
|
|
250
|
+
side?: AnchorSide
|
|
251
|
+
/** Alignment along the anchor's cross axis. Default `start`. */
|
|
252
|
+
align?: AnchorAlign
|
|
253
|
+
/** Gap from the anchor, px. Default 6. */
|
|
254
|
+
offset?: number
|
|
255
|
+
/** Shift along the alignment axis, px. Default 0. */
|
|
256
|
+
alignOffset?: number
|
|
257
|
+
/** Default true. */
|
|
258
|
+
flip?: boolean
|
|
259
|
+
/** Default true. */
|
|
260
|
+
clampToViewport?: boolean
|
|
261
|
+
/**
|
|
262
|
+
* `dialog` (default) — a named, non-modal dialog; give it `aria-label` or
|
|
263
|
+
* `aria-labelledby`. `none` — no role attribute, for content that is
|
|
264
|
+
* itself the widget.
|
|
265
|
+
*/
|
|
266
|
+
role?: 'dialog' | 'none'
|
|
267
|
+
/** Skip the surface chrome (background / border / shadow / radius / padding). */
|
|
268
|
+
unstyled?: boolean
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
|
|
272
|
+
function PopoverContent(
|
|
273
|
+
{
|
|
274
|
+
children,
|
|
275
|
+
side = 'bottom',
|
|
276
|
+
align = 'start',
|
|
277
|
+
offset = 6,
|
|
278
|
+
alignOffset = 0,
|
|
279
|
+
flip = true,
|
|
280
|
+
clampToViewport = true,
|
|
281
|
+
role = 'dialog',
|
|
282
|
+
unstyled,
|
|
283
|
+
className,
|
|
284
|
+
style,
|
|
285
|
+
...rest
|
|
286
|
+
},
|
|
287
|
+
forwardedRef,
|
|
288
|
+
) {
|
|
289
|
+
const { open, setOpen, triggerRef, contentId, anchorRect, interactedOutsideRef } =
|
|
290
|
+
usePopoverCtx('Popover.Content')
|
|
291
|
+
const contentRef = useRef<HTMLDivElement | null>(null)
|
|
292
|
+
|
|
293
|
+
const {
|
|
294
|
+
ref: positionRef,
|
|
295
|
+
style: positionStyle,
|
|
296
|
+
placement,
|
|
297
|
+
positioned,
|
|
298
|
+
} = useAnchoredPosition<HTMLDivElement>({
|
|
299
|
+
anchorRef: triggerRef,
|
|
300
|
+
anchorRect,
|
|
301
|
+
side,
|
|
302
|
+
align,
|
|
303
|
+
offset,
|
|
304
|
+
alignOffset,
|
|
305
|
+
flip,
|
|
306
|
+
clampToViewport,
|
|
307
|
+
enabled: open,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const layer = useLayer({
|
|
311
|
+
enabled: open,
|
|
312
|
+
kind: 'popover',
|
|
313
|
+
elementRef: contentRef,
|
|
314
|
+
onEscape: () => setOpen(false),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
useOutsideClick(
|
|
318
|
+
[triggerRef, contentRef],
|
|
319
|
+
() => {
|
|
320
|
+
interactedOutsideRef.current = true
|
|
321
|
+
setOpen(false)
|
|
322
|
+
},
|
|
323
|
+
{ enabled: open, layer },
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// Focus in once the content is POSITIONED, back to the trigger on close.
|
|
327
|
+
// Not on `open`: the first open frame is the parked one (off-screen,
|
|
328
|
+
// `visibility: hidden`), and Chromium, Firefox and WebKit all refuse to
|
|
329
|
+
// focus into a visibility:hidden subtree, so a focus() there is a silent
|
|
330
|
+
// no-op and focus stays on the trigger. `positioned` flips true in the
|
|
331
|
+
// re-render the measuring layout effect triggers, after the parked frame
|
|
332
|
+
// has already flushed its passive effects. jsdom does not enforce the
|
|
333
|
+
// rule, which is why the test records `data-positioned` at focus time.
|
|
334
|
+
// Keyed on state, not an inline callback, so `onOpenChange` identity
|
|
335
|
+
// churn never re-runs it mid-interaction.
|
|
336
|
+
useEffect(() => {
|
|
337
|
+
if (!open || !positioned) return
|
|
338
|
+
const node = contentRef.current
|
|
339
|
+
if (!node) return
|
|
340
|
+
interactedOutsideRef.current = false
|
|
341
|
+
// Captured now, not in the cleanup: the trigger does not change while
|
|
342
|
+
// the popover is open, and a remounted one would be disconnected.
|
|
343
|
+
const trigger = triggerRef.current
|
|
344
|
+
const previous =
|
|
345
|
+
document.activeElement instanceof HTMLElement &&
|
|
346
|
+
document.activeElement !== document.body
|
|
347
|
+
? document.activeElement
|
|
348
|
+
: null
|
|
349
|
+
const initial =
|
|
350
|
+
node.querySelector<HTMLElement>('[data-autofocus]') ??
|
|
351
|
+
tabbablesWithin(node)[0] ??
|
|
352
|
+
node
|
|
353
|
+
initial.focus()
|
|
354
|
+
|
|
355
|
+
return () => {
|
|
356
|
+
if (interactedOutsideRef.current) return
|
|
357
|
+
// The content is gone by now, so a focus that was inside it has
|
|
358
|
+
// already fallen to <body>. Anything else means the user moved on.
|
|
359
|
+
const active = document.activeElement
|
|
360
|
+
if (active !== null && active !== document.body && !node.contains(active)) return
|
|
361
|
+
const target = trigger ?? previous
|
|
362
|
+
if (target?.isConnected) target.focus()
|
|
363
|
+
}
|
|
364
|
+
}, [open, positioned, triggerRef, interactedOutsideRef])
|
|
365
|
+
|
|
366
|
+
const ref = useMemo(
|
|
367
|
+
() => composeRefs<HTMLDivElement>(forwardedRef, contentRef, positionRef),
|
|
368
|
+
[forwardedRef, positionRef],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if (!open || typeof document === 'undefined') return null
|
|
372
|
+
|
|
373
|
+
return createPortal(
|
|
374
|
+
<div
|
|
375
|
+
ref={ref}
|
|
376
|
+
id={contentId}
|
|
377
|
+
role={role === 'none' ? undefined : role}
|
|
378
|
+
tabIndex={-1}
|
|
379
|
+
data-slot="popover-content"
|
|
380
|
+
data-state="open"
|
|
381
|
+
data-side={placement?.side}
|
|
382
|
+
data-align={placement?.align}
|
|
383
|
+
data-positioned={positioned ? 'true' : 'false'}
|
|
384
|
+
className={cn(
|
|
385
|
+
'fixed z-[100] outline-none',
|
|
386
|
+
!unstyled && 'rounded-[var(--radius-lg)] p-3',
|
|
387
|
+
positioned && 'ds-enter-pop',
|
|
388
|
+
className,
|
|
389
|
+
)}
|
|
390
|
+
style={{
|
|
391
|
+
...positionStyle,
|
|
392
|
+
...(unstyled ? undefined : POPOVER_SURFACE_STYLE),
|
|
393
|
+
...style,
|
|
394
|
+
}}
|
|
395
|
+
{...rest}
|
|
396
|
+
>
|
|
397
|
+
{children}
|
|
398
|
+
</div>,
|
|
399
|
+
document.body,
|
|
400
|
+
)
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
export const Popover = Object.assign(PopoverRoot, {
|
|
405
|
+
Trigger: PopoverTrigger,
|
|
406
|
+
Content: PopoverContent,
|
|
407
|
+
})
|