@lovett/ui 0.0.9 → 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 +823 -135
- package/dist/index.js +2048 -358
- package/dist/index.js.map +1 -1
- package/dist/styles.css +44 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +123 -8
- 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__/modal.test.tsx +180 -6
- 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/code-block.tsx +15 -26
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +111 -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 +149 -9
- package/src/page-shell.tsx +91 -2
- package/src/popover.tsx +407 -0
- package/src/segmented-pill.tsx +33 -10
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +44 -2
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +123 -8
- package/src/tooltip.tsx +297 -0
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- package/src/v2/tokens.ts +0 -228
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ export { default as MicrosoftLogo, type MicrosoftLogoProps } from './microsoft-l
|
|
|
28
28
|
export {
|
|
29
29
|
BrandMeta,
|
|
30
30
|
BrandFacebook,
|
|
31
|
+
BrandIcon,
|
|
32
|
+
hasBrandIcon,
|
|
31
33
|
BrandInstagram,
|
|
32
34
|
BrandMessenger,
|
|
33
35
|
BrandLinkedIn,
|
|
@@ -45,7 +47,10 @@ export {
|
|
|
45
47
|
AppScroll,
|
|
46
48
|
PageHeaderHost,
|
|
47
49
|
PageHeaderSlotProvider,
|
|
50
|
+
HomeCrumbLink,
|
|
51
|
+
useBreadcrumbHome,
|
|
48
52
|
type Crumb,
|
|
53
|
+
type HomeCrumb,
|
|
49
54
|
} from './page-shell'
|
|
50
55
|
export { Toaster, toast } from './toast'
|
|
51
56
|
|
|
@@ -167,6 +172,112 @@ export {
|
|
|
167
172
|
type EditingCell,
|
|
168
173
|
} from './data-grid'
|
|
169
174
|
|
|
175
|
+
// Anchored-positioning kit + dismiss-layer stack — promoted per ADR-0030
|
|
176
|
+
// Decision G (meta-ads-audit-dashboard task manager: its pickers, sheets
|
|
177
|
+
// and palette need one positioning routine and one Escape order). The
|
|
178
|
+
// workspace app is the other consumer: DropdownMenu (137 call sites),
|
|
179
|
+
// Modal (51 files) and Popover are rebuilt on these in the same change.
|
|
180
|
+
// `useAnchoredPosition` measures / flips / clamps against an element ref
|
|
181
|
+
// OR a virtual rect (ContextMenu's right-click point); `useOutsideClick`
|
|
182
|
+
// is layer-aware; `useLayer` / `useEscapeKey` are the topmost-wins stack
|
|
183
|
+
// extracted from Modal's `openPanels`. Select / Combobox / Tooltip /
|
|
184
|
+
// ContextMenu build on them next; FilterDropdown / FolderTreePicker /
|
|
185
|
+
// TagChipInput still carry local copies of the positioning block
|
|
186
|
+
// (follow-up).
|
|
187
|
+
export {
|
|
188
|
+
useAnchoredPosition,
|
|
189
|
+
computeAnchoredPosition,
|
|
190
|
+
useOutsideClick,
|
|
191
|
+
type AnchorSide,
|
|
192
|
+
type AnchorAlign,
|
|
193
|
+
type AnchorRect,
|
|
194
|
+
type AnchorSize,
|
|
195
|
+
type AnchorPlacement,
|
|
196
|
+
type AnchoredPosition,
|
|
197
|
+
type AnchoredPositionOptions,
|
|
198
|
+
type UseAnchoredPositionOptions,
|
|
199
|
+
type UseAnchoredPositionResult,
|
|
200
|
+
type OutsideClickTarget,
|
|
201
|
+
type UseOutsideClickOptions,
|
|
202
|
+
} from './lib/anchor'
|
|
203
|
+
export {
|
|
204
|
+
useLayer,
|
|
205
|
+
useEscapeKey,
|
|
206
|
+
type LayerKind,
|
|
207
|
+
type LayerHandle,
|
|
208
|
+
type UseLayerOptions,
|
|
209
|
+
type UseEscapeKeyOptions,
|
|
210
|
+
} from './lib/layer-stack'
|
|
211
|
+
|
|
212
|
+
// Popover — anchored, non-modal surface: portal to body, `.ds-enter-pop`
|
|
213
|
+
// once positioned, focus into the content on open and back to the trigger
|
|
214
|
+
// on close, Escape closes the topmost layer only, outside-click closes.
|
|
215
|
+
// Compound: Popover + Popover.Trigger (`asChild` to wrap a <Button> or a
|
|
216
|
+
// cell) + Popover.Content (`role="dialog"` or `"none"`). Promoted per
|
|
217
|
+
// ADR-0030 Decision G; the workspace app's three hand-rolled popovers
|
|
218
|
+
// (sidebar user menu, group-settings colour / icon pickers, calculator
|
|
219
|
+
// AssumptionsPopover) are the migration targets. DropdownMenu shares its
|
|
220
|
+
// surface chrome (`POPOVER_SURFACE_STYLE`) and stack, but stays a sibling
|
|
221
|
+
// because a menu does not move focus into itself on open.
|
|
222
|
+
export {
|
|
223
|
+
Popover,
|
|
224
|
+
POPOVER_SURFACE_STYLE,
|
|
225
|
+
type PopoverProps,
|
|
226
|
+
type PopoverTriggerProps,
|
|
227
|
+
type PopoverContentProps,
|
|
228
|
+
} from './popover'
|
|
229
|
+
|
|
230
|
+
// Select — single-value picker: an `.input-shell` trigger (sm / md, sits
|
|
231
|
+
// flush beside <Input>) that opens a real listbox (APG select-only
|
|
232
|
+
// combobox: role=combobox trigger, aria-activedescendant, role=listbox /
|
|
233
|
+
// option, arrow / Home / End / Enter / Space / type-ahead, disabled
|
|
234
|
+
// options, optional icon + description). Promoted per ADR-0030 Decision G
|
|
235
|
+
// — the meta-ads-audit-dashboard task manager's status / priority / role
|
|
236
|
+
// pickers; its Users page hand-rolled a native <select> with a comment
|
|
237
|
+
// saying the package ships none. The workspace app is the second consumer
|
|
238
|
+
// (26 native <select>s, products/product-form.tsx says the same). Built on
|
|
239
|
+
// useAnchoredPosition + the layer stack; shares the popover surface chrome.
|
|
240
|
+
// `ListboxOption` / `LISTBOX_CLASS` are the row + popup chrome Combobox
|
|
241
|
+
// reuses, exported for the same reason POPOVER_SURFACE_STYLE is.
|
|
242
|
+
export {
|
|
243
|
+
Select,
|
|
244
|
+
ListboxOption,
|
|
245
|
+
LISTBOX_CLASS,
|
|
246
|
+
type SelectProps,
|
|
247
|
+
type SelectOption,
|
|
248
|
+
type SelectSize,
|
|
249
|
+
type ListboxOptionProps,
|
|
250
|
+
} from './select'
|
|
251
|
+
|
|
252
|
+
// Combobox — text input + filtered listbox (APG editable combobox with list
|
|
253
|
+
// autocomplete), single or multi-select with chips, optional grouped options
|
|
254
|
+
// with headings, a per-option live `count` slot and a `renderOption` slot.
|
|
255
|
+
// Async-friendly: `shouldFilter={false}` + `loading` + `onInputValueChange`;
|
|
256
|
+
// it never fetches. Promoted per ADR-0030 Decision G — the task manager's
|
|
257
|
+
// row / card / context-menu / palette pickers are "popover + search + live
|
|
258
|
+
// counts". Second consumer is the workspace app
|
|
259
|
+
// (sem-spec/location-autocomplete.tsx hand-rolls the role; TagChipInput
|
|
260
|
+
// carries the chip model this generalises).
|
|
261
|
+
export {
|
|
262
|
+
Combobox,
|
|
263
|
+
type ComboboxProps,
|
|
264
|
+
type ComboboxSingleProps,
|
|
265
|
+
type ComboboxMultipleProps,
|
|
266
|
+
type ComboboxOption,
|
|
267
|
+
type ComboboxGroup,
|
|
268
|
+
type ComboboxOptionState,
|
|
269
|
+
type ComboboxSize,
|
|
270
|
+
} from './combobox'
|
|
271
|
+
|
|
272
|
+
// Tooltip — hover (300 ms) / focus (immediate) triggered role=tooltip with
|
|
273
|
+
// aria-describedby wiring, Escape-dismissable through the layer stack, never
|
|
274
|
+
// focusable, pointer-events none, token-only inline surface like Kbd.
|
|
275
|
+
// Promoted per ADR-0030 Decision G (task manager: truncated cells, icon-only
|
|
276
|
+
// row actions, timestamps). Second consumer is the workspace app, which
|
|
277
|
+
// hand-rolls two today (tools/_shared/calc/info-tip.tsx,
|
|
278
|
+
// components/ui/sidebar/rail-tooltip.tsx).
|
|
279
|
+
export { Tooltip, type TooltipProps, type TooltipTriggerProps } from './tooltip'
|
|
280
|
+
|
|
170
281
|
// DropdownButton — Button-styled trigger + rich popover menu. Composed
|
|
171
282
|
// over <DropdownMenu> primitives. First consumer: Keywords dashboard's
|
|
172
283
|
// "New Keyword Plan" CTA (Set/Group picker). Reusable across other
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchored positioning — the ONE measure / flip / clamp routine behind every
|
|
3
|
+
* floating surface in @lovett/ui.
|
|
4
|
+
*
|
|
5
|
+
* Four primitives (DropdownMenu, FilterDropdown, FolderTreePicker,
|
|
6
|
+
* TagChipInput) each hand-rolled the same `useLayoutEffect` +
|
|
7
|
+
* `getBoundingClientRect` + clamp + resize/scroll-listener block, with four
|
|
8
|
+
* slightly different gaps: no flip at a viewport edge; no clamp; measuring in
|
|
9
|
+
* a passive effect so the first paint was wrong; `top`/`bottom` only. This
|
|
10
|
+
* module is the single implementation. DropdownMenu is rebuilt on it in the
|
|
11
|
+
* same change; the other three keep their local copies for now (follow-up).
|
|
12
|
+
*
|
|
13
|
+
* Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager)
|
|
14
|
+
* with the workspace app as the first consumer — DropdownMenu and Popover
|
|
15
|
+
* position through this hook, so every menu in the app is on it. Select /
|
|
16
|
+
* Combobox / Tooltip / ContextMenu build on it next.
|
|
17
|
+
*
|
|
18
|
+
* No Floating UI, no Popper (CLAUDE.md §1 allowed deps). Three pieces:
|
|
19
|
+
* • `computeAnchoredPosition` — pure math, unit-tested without a DOM.
|
|
20
|
+
* • `useAnchoredPosition` — measures, positions, tracks resize / scroll /
|
|
21
|
+
* size changes. Anchor is an element (`anchorRef`) OR a virtual rect
|
|
22
|
+
* (`anchorRect`) — ContextMenu passes the right-click point as
|
|
23
|
+
* `{ left: e.clientX, top: e.clientY, width: 0, height: 0 }`.
|
|
24
|
+
* • `useOutsideClick` — pointerdown outside a set of targets, aware of
|
|
25
|
+
* layers stacked above (a nested Select's listbox is not "outside").
|
|
26
|
+
*
|
|
27
|
+
* Two-phase render, unchanged from the DropdownMenu it replaces: the first
|
|
28
|
+
* paint measures the content parked at (-99999, -99999) with
|
|
29
|
+
* `visibility: hidden`, the layout effect computes the real coordinates, and
|
|
30
|
+
* the second paint reveals it. Components gate their entry animation on
|
|
31
|
+
* `positioned` so `.ds-enter-pop` never plays from the parked location.
|
|
32
|
+
*
|
|
33
|
+
* Token discipline: the hook emits geometry only (`position: fixed` +
|
|
34
|
+
* `left` / `top` in px). Surface colour, radius, shadow, padding and the
|
|
35
|
+
* motion class stay with the component.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
*
|
|
39
|
+
* const { ref, style, placement, positioned } =
|
|
40
|
+
* useAnchoredPosition<HTMLDivElement>({
|
|
41
|
+
* anchorRef: triggerRef,
|
|
42
|
+
* side: 'bottom',
|
|
43
|
+
* align: 'start',
|
|
44
|
+
* offset: 6,
|
|
45
|
+
* enabled: open,
|
|
46
|
+
* })
|
|
47
|
+
* return createPortal(
|
|
48
|
+
* <div
|
|
49
|
+
* ref={ref}
|
|
50
|
+
* style={style}
|
|
51
|
+
* data-side={placement?.side}
|
|
52
|
+
* className={cn(positioned && 'ds-enter-pop')}
|
|
53
|
+
* >
|
|
54
|
+
* …
|
|
55
|
+
* </div>,
|
|
56
|
+
* document.body,
|
|
57
|
+
* )
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
useCallback,
|
|
62
|
+
useEffect,
|
|
63
|
+
useLayoutEffect,
|
|
64
|
+
useRef,
|
|
65
|
+
useState,
|
|
66
|
+
type CSSProperties,
|
|
67
|
+
type RefObject,
|
|
68
|
+
} from 'react'
|
|
69
|
+
import type { LayerHandle } from './layer-stack'
|
|
70
|
+
|
|
71
|
+
export type AnchorSide = 'top' | 'bottom' | 'left' | 'right'
|
|
72
|
+
export type AnchorAlign = 'start' | 'center' | 'end'
|
|
73
|
+
|
|
74
|
+
/** Viewport-relative rect. `DOMRect` satisfies it; so does a point. */
|
|
75
|
+
export interface AnchorRect {
|
|
76
|
+
readonly left: number
|
|
77
|
+
readonly top: number
|
|
78
|
+
readonly width: number
|
|
79
|
+
readonly height: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AnchorSize {
|
|
83
|
+
readonly width: number
|
|
84
|
+
readonly height: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The side and alignment actually used after flipping. */
|
|
88
|
+
export interface AnchorPlacement {
|
|
89
|
+
readonly side: AnchorSide
|
|
90
|
+
readonly align: AnchorAlign
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AnchoredPositionOptions {
|
|
94
|
+
/** Preferred side of the anchor. Default `bottom`. */
|
|
95
|
+
side?: AnchorSide
|
|
96
|
+
/** Alignment along the anchor's cross axis. Default `start`. */
|
|
97
|
+
align?: AnchorAlign
|
|
98
|
+
/** Gap between anchor and floating element along `side`, px. Default 6. */
|
|
99
|
+
offset?: number
|
|
100
|
+
/** Shift along the alignment axis, px. Default 0. */
|
|
101
|
+
alignOffset?: number
|
|
102
|
+
/**
|
|
103
|
+
* Flip to the opposite side when the preferred side cannot fit and the
|
|
104
|
+
* opposite side fits — or simply has more room. Default true.
|
|
105
|
+
*/
|
|
106
|
+
flip?: boolean
|
|
107
|
+
/** Keep the floating element inside the viewport, `gutter` px in. Default true. */
|
|
108
|
+
clampToViewport?: boolean
|
|
109
|
+
/** Viewport inset used by both flip and clamp, px. Default 8. */
|
|
110
|
+
gutter?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface AnchoredPosition {
|
|
114
|
+
readonly left: number
|
|
115
|
+
readonly top: number
|
|
116
|
+
readonly placement: AnchorPlacement
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const OPPOSITE: Record<AnchorSide, AnchorSide> = {
|
|
120
|
+
top: 'bottom',
|
|
121
|
+
bottom: 'top',
|
|
122
|
+
left: 'right',
|
|
123
|
+
right: 'left',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isVertical(side: AnchorSide): boolean {
|
|
127
|
+
return side === 'top' || side === 'bottom'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
131
|
+
return Math.max(min, Math.min(value, max))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure placement math. Given the anchor rect, the floating element's size
|
|
136
|
+
* and the viewport size, returns fixed-position coordinates plus the
|
|
137
|
+
* placement actually used. No DOM — this is what the tests pin.
|
|
138
|
+
*/
|
|
139
|
+
export function computeAnchoredPosition(
|
|
140
|
+
anchor: AnchorRect,
|
|
141
|
+
floating: AnchorSize,
|
|
142
|
+
viewport: AnchorSize,
|
|
143
|
+
options: AnchoredPositionOptions = {},
|
|
144
|
+
): AnchoredPosition {
|
|
145
|
+
const {
|
|
146
|
+
side: preferred = 'bottom',
|
|
147
|
+
align = 'start',
|
|
148
|
+
offset = 6,
|
|
149
|
+
alignOffset = 0,
|
|
150
|
+
flip = true,
|
|
151
|
+
clampToViewport = true,
|
|
152
|
+
gutter = 8,
|
|
153
|
+
} = options
|
|
154
|
+
|
|
155
|
+
const anchorRight = anchor.left + anchor.width
|
|
156
|
+
const anchorBottom = anchor.top + anchor.height
|
|
157
|
+
|
|
158
|
+
// Room on each side of the anchor, inside the gutter.
|
|
159
|
+
const room: Record<AnchorSide, number> = {
|
|
160
|
+
top: anchor.top - gutter,
|
|
161
|
+
bottom: viewport.height - anchorBottom - gutter,
|
|
162
|
+
left: anchor.left - gutter,
|
|
163
|
+
right: viewport.width - anchorRight - gutter,
|
|
164
|
+
}
|
|
165
|
+
const needed = (side: AnchorSide): number =>
|
|
166
|
+
(isVertical(side) ? floating.height : floating.width) + offset
|
|
167
|
+
|
|
168
|
+
let side = preferred
|
|
169
|
+
if (flip && room[preferred] < needed(preferred)) {
|
|
170
|
+
const opposite = OPPOSITE[preferred]
|
|
171
|
+
if (room[opposite] >= needed(opposite) || room[opposite] > room[preferred]) {
|
|
172
|
+
side = opposite
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let left: number
|
|
177
|
+
let top: number
|
|
178
|
+
|
|
179
|
+
if (isVertical(side)) {
|
|
180
|
+
top =
|
|
181
|
+
side === 'bottom'
|
|
182
|
+
? anchorBottom + offset
|
|
183
|
+
: anchor.top - floating.height - offset
|
|
184
|
+
switch (align) {
|
|
185
|
+
case 'start':
|
|
186
|
+
left = anchor.left + alignOffset
|
|
187
|
+
break
|
|
188
|
+
case 'center':
|
|
189
|
+
left = anchor.left + anchor.width / 2 - floating.width / 2 + alignOffset
|
|
190
|
+
break
|
|
191
|
+
case 'end':
|
|
192
|
+
left = anchorRight - floating.width - alignOffset
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
left =
|
|
197
|
+
side === 'right'
|
|
198
|
+
? anchorRight + offset
|
|
199
|
+
: anchor.left - floating.width - offset
|
|
200
|
+
switch (align) {
|
|
201
|
+
case 'start':
|
|
202
|
+
top = anchor.top + alignOffset
|
|
203
|
+
break
|
|
204
|
+
case 'center':
|
|
205
|
+
top = anchor.top + anchor.height / 2 - floating.height / 2 + alignOffset
|
|
206
|
+
break
|
|
207
|
+
case 'end':
|
|
208
|
+
top = anchorBottom - floating.height - alignOffset
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (clampToViewport) {
|
|
214
|
+
// `max` first: when the element is wider than the viewport the upper
|
|
215
|
+
// bound is below the gutter, and the gutter wins.
|
|
216
|
+
left = clampNumber(left, gutter, viewport.width - floating.width - gutter)
|
|
217
|
+
top = clampNumber(top, gutter, viewport.height - floating.height - gutter)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
left: Math.round(left),
|
|
222
|
+
top: Math.round(top),
|
|
223
|
+
placement: { side, align },
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface UseAnchoredPositionOptions extends AnchoredPositionOptions {
|
|
228
|
+
/** Element to anchor to. Ignored while `anchorRect` is non-null. */
|
|
229
|
+
anchorRef?: RefObject<Element | null>
|
|
230
|
+
/** Virtual anchor (a right-click point, a text caret). Wins over `anchorRef`. */
|
|
231
|
+
anchorRect?: AnchorRect | null
|
|
232
|
+
/** Measure and track while true. Default true. */
|
|
233
|
+
enabled?: boolean
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface UseAnchoredPositionResult<T extends HTMLElement> {
|
|
237
|
+
/** Attach to the floating element. */
|
|
238
|
+
ref: (node: T | null) => void
|
|
239
|
+
/** The same node as a ref object — for `contains()` checks. */
|
|
240
|
+
floatingRef: RefObject<T | null>
|
|
241
|
+
/** `position: fixed` + coordinates; hidden and parked off-screen until measured. */
|
|
242
|
+
style: CSSProperties
|
|
243
|
+
/** Side / align actually used, or null until measured. */
|
|
244
|
+
placement: AnchorPlacement | null
|
|
245
|
+
/** True once real coordinates are applied. Gate entry motion on this. */
|
|
246
|
+
positioned: boolean
|
|
247
|
+
/** Re-measure now (after content you know changed size, for example). */
|
|
248
|
+
update: () => void
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const PARKED_STYLE: CSSProperties = {
|
|
252
|
+
position: 'fixed',
|
|
253
|
+
// Parked far off-screen, not at (0, 0): if a renderer paints the
|
|
254
|
+
// unpositioned frame despite `visibility: hidden`, nothing flashes at the
|
|
255
|
+
// viewport's top-left corner.
|
|
256
|
+
left: -99999,
|
|
257
|
+
top: -99999,
|
|
258
|
+
visibility: 'hidden',
|
|
259
|
+
opacity: 0,
|
|
260
|
+
pointerEvents: 'none',
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function viewportSize(): AnchorSize {
|
|
264
|
+
// `clientWidth` excludes a vertical scrollbar, which `innerWidth` includes;
|
|
265
|
+
// an end-aligned menu would otherwise sit under the scrollbar. jsdom
|
|
266
|
+
// reports 0 for clientWidth, hence the fallback.
|
|
267
|
+
const root = document.documentElement
|
|
268
|
+
return {
|
|
269
|
+
width: root.clientWidth || window.innerWidth,
|
|
270
|
+
height: root.clientHeight || window.innerHeight,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function samePosition(a: AnchoredPosition | null, b: AnchoredPosition): boolean {
|
|
275
|
+
return (
|
|
276
|
+
a !== null &&
|
|
277
|
+
a.left === b.left &&
|
|
278
|
+
a.top === b.top &&
|
|
279
|
+
a.placement.side === b.placement.side &&
|
|
280
|
+
a.placement.align === b.placement.align
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function useAnchoredPosition<T extends HTMLElement = HTMLElement>(
|
|
285
|
+
options: UseAnchoredPositionOptions,
|
|
286
|
+
): UseAnchoredPositionResult<T> {
|
|
287
|
+
const {
|
|
288
|
+
anchorRef,
|
|
289
|
+
anchorRect = null,
|
|
290
|
+
enabled = true,
|
|
291
|
+
side = 'bottom',
|
|
292
|
+
align = 'start',
|
|
293
|
+
offset = 6,
|
|
294
|
+
alignOffset = 0,
|
|
295
|
+
flip = true,
|
|
296
|
+
clampToViewport = true,
|
|
297
|
+
gutter = 8,
|
|
298
|
+
} = options
|
|
299
|
+
|
|
300
|
+
// The floating node lives in state as well as a ref so the measuring
|
|
301
|
+
// effect re-runs when the element mounts — a RefObject alone cannot
|
|
302
|
+
// signal that.
|
|
303
|
+
const floatingRef = useRef<T | null>(null)
|
|
304
|
+
const [floating, setFloating] = useState<T | null>(null)
|
|
305
|
+
const ref = useCallback((node: T | null) => {
|
|
306
|
+
floatingRef.current = node
|
|
307
|
+
setFloating(node)
|
|
308
|
+
}, [])
|
|
309
|
+
|
|
310
|
+
const [position, setPosition] = useState<AnchoredPosition | null>(null)
|
|
311
|
+
|
|
312
|
+
// Latest virtual rect, read at measure time. Keyed by value (not object
|
|
313
|
+
// identity) so a caller passing a fresh literal each render does not
|
|
314
|
+
// re-subscribe the listeners.
|
|
315
|
+
const anchorRectRef = useRef<AnchorRect | null>(anchorRect)
|
|
316
|
+
useLayoutEffect(() => {
|
|
317
|
+
anchorRectRef.current = anchorRect
|
|
318
|
+
})
|
|
319
|
+
const rectKey = anchorRect
|
|
320
|
+
? `${anchorRect.left}|${anchorRect.top}|${anchorRect.width}|${anchorRect.height}`
|
|
321
|
+
: ''
|
|
322
|
+
|
|
323
|
+
const measure = useCallback(() => {
|
|
324
|
+
const node = floatingRef.current
|
|
325
|
+
if (!node) return
|
|
326
|
+
const rect =
|
|
327
|
+
anchorRectRef.current ?? anchorRef?.current?.getBoundingClientRect() ?? null
|
|
328
|
+
if (!rect) return
|
|
329
|
+
// offsetWidth/Height are layout sizes, untouched by the entry animation's
|
|
330
|
+
// transform. getBoundingClientRect() would read a 0.95-scaled box mid-
|
|
331
|
+
// `.ds-enter-pop` and shift an end-aligned menu by 5% of its width.
|
|
332
|
+
const next = computeAnchoredPosition(
|
|
333
|
+
rect,
|
|
334
|
+
{ width: node.offsetWidth, height: node.offsetHeight },
|
|
335
|
+
viewportSize(),
|
|
336
|
+
{ side, align, offset, alignOffset, flip, clampToViewport, gutter },
|
|
337
|
+
)
|
|
338
|
+
setPosition((prev) => (samePosition(prev, next) ? prev : next))
|
|
339
|
+
}, [anchorRef, side, align, offset, alignOffset, flip, clampToViewport, gutter])
|
|
340
|
+
|
|
341
|
+
useLayoutEffect(() => {
|
|
342
|
+
if (!enabled || !floating) {
|
|
343
|
+
setPosition((prev) => (prev === null ? prev : null))
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
measure()
|
|
347
|
+
window.addEventListener('resize', measure)
|
|
348
|
+
// Capture phase: scrolls inside nested containers do not bubble, and a
|
|
349
|
+
// menu anchored inside one must follow its trigger.
|
|
350
|
+
window.addEventListener('scroll', measure, true)
|
|
351
|
+
let observer: ResizeObserver | null = null
|
|
352
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
353
|
+
observer = new ResizeObserver(() => measure())
|
|
354
|
+
observer.observe(floating)
|
|
355
|
+
const anchorEl = anchorRef?.current
|
|
356
|
+
if (anchorEl) observer.observe(anchorEl)
|
|
357
|
+
}
|
|
358
|
+
return () => {
|
|
359
|
+
window.removeEventListener('resize', measure)
|
|
360
|
+
window.removeEventListener('scroll', measure, true)
|
|
361
|
+
observer?.disconnect()
|
|
362
|
+
}
|
|
363
|
+
}, [enabled, floating, measure, anchorRef, rectKey])
|
|
364
|
+
|
|
365
|
+
const positioned = position !== null
|
|
366
|
+
const style: CSSProperties = positioned
|
|
367
|
+
? { position: 'fixed', left: position.left, top: position.top }
|
|
368
|
+
: PARKED_STYLE
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
ref,
|
|
372
|
+
floatingRef,
|
|
373
|
+
style,
|
|
374
|
+
placement: position?.placement ?? null,
|
|
375
|
+
positioned,
|
|
376
|
+
update: measure,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export type OutsideClickTarget = RefObject<Element | null> | Element | null | undefined
|
|
381
|
+
|
|
382
|
+
export interface UseOutsideClickOptions {
|
|
383
|
+
/** Default true. */
|
|
384
|
+
enabled?: boolean
|
|
385
|
+
/**
|
|
386
|
+
* This surface's dismiss layer. When given, a pointerdown inside any layer
|
|
387
|
+
* stacked ABOVE it (a Select opened from inside a Popover) is treated as
|
|
388
|
+
* inside, not outside.
|
|
389
|
+
*/
|
|
390
|
+
layer?: LayerHandle
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Calls `onOutside` on a `pointerdown` whose target is inside none of
|
|
395
|
+
* `targets`. Handler and targets are read through refs, so inline arrays
|
|
396
|
+
* and arrows do not re-subscribe the listener.
|
|
397
|
+
*/
|
|
398
|
+
export function useOutsideClick(
|
|
399
|
+
targets: ReadonlyArray<OutsideClickTarget>,
|
|
400
|
+
onOutside: (event: PointerEvent) => void,
|
|
401
|
+
options: UseOutsideClickOptions = {},
|
|
402
|
+
): void {
|
|
403
|
+
const { enabled = true, layer } = options
|
|
404
|
+
|
|
405
|
+
const targetsRef = useRef(targets)
|
|
406
|
+
const handlerRef = useRef(onOutside)
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
targetsRef.current = targets
|
|
409
|
+
handlerRef.current = onOutside
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
useEffect(() => {
|
|
413
|
+
if (!enabled) return
|
|
414
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
415
|
+
const target = event.target
|
|
416
|
+
if (!(target instanceof Node)) return
|
|
417
|
+
for (const candidate of targetsRef.current) {
|
|
418
|
+
const el = candidate instanceof Node ? candidate : candidate?.current
|
|
419
|
+
if (el?.contains(target)) return
|
|
420
|
+
}
|
|
421
|
+
if (layer?.containsInLayerAbove(target)) return
|
|
422
|
+
handlerRef.current(event)
|
|
423
|
+
}
|
|
424
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
425
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
426
|
+
}, [enabled, layer])
|
|
427
|
+
}
|
package/src/lib/focus.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabbable-element query shared by the focus-managing surfaces (Modal's
|
|
3
|
+
* trap, Popover's focus-on-open). Extracted from modal.tsx.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately attribute-only — no geometry check. `offsetWidth` /
|
|
6
|
+
* `getClientRects()` are always zero in jsdom, so a visibility filter would
|
|
7
|
+
* empty this list under test and silently disable the trap in exactly the
|
|
8
|
+
* environment that verifies it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FOCUSABLE_SELECTOR = [
|
|
12
|
+
'a[href]',
|
|
13
|
+
'area[href]',
|
|
14
|
+
'button:not([disabled])',
|
|
15
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
16
|
+
'select:not([disabled])',
|
|
17
|
+
'textarea:not([disabled])',
|
|
18
|
+
'iframe',
|
|
19
|
+
'summary',
|
|
20
|
+
'[contenteditable="true"]',
|
|
21
|
+
'[tabindex]',
|
|
22
|
+
].join(',')
|
|
23
|
+
|
|
24
|
+
export function tabbablesWithin(root: HTMLElement): HTMLElement[] {
|
|
25
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
26
|
+
(el) =>
|
|
27
|
+
el.tabIndex >= 0 &&
|
|
28
|
+
!el.hasAttribute('inert') &&
|
|
29
|
+
el.getAttribute('aria-hidden') !== 'true' &&
|
|
30
|
+
!el.closest('[hidden]'),
|
|
31
|
+
)
|
|
32
|
+
}
|