@lovett/ui 0.0.10 → 0.1.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/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -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
|
+
}
|