@hanzogui/popper 2.0.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/LICENSE +21 -0
- package/dist/cjs/Popper.cjs +593 -0
- package/dist/cjs/Popper.native.js +632 -0
- package/dist/cjs/Popper.native.js.map +1 -0
- package/dist/cjs/index.cjs +18 -0
- package/dist/cjs/index.native.js +21 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/esm/Popper.mjs +544 -0
- package/dist/esm/Popper.mjs.map +1 -0
- package/dist/esm/Popper.native.js +581 -0
- package/dist/esm/Popper.native.js.map +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/index.mjs +2 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/index.native.js +2 -0
- package/dist/esm/index.native.js.map +1 -0
- package/dist/jsx/Popper.mjs +544 -0
- package/dist/jsx/Popper.mjs.map +1 -0
- package/dist/jsx/Popper.native.js +632 -0
- package/dist/jsx/Popper.native.js.map +1 -0
- package/dist/jsx/index.js +2 -0
- package/dist/jsx/index.js.map +1 -0
- package/dist/jsx/index.mjs +2 -0
- package/dist/jsx/index.mjs.map +1 -0
- package/dist/jsx/index.native.js +21 -0
- package/dist/jsx/index.native.js.map +1 -0
- package/package.json +58 -0
- package/src/Popper.tsx +1022 -0
- package/src/index.ts +1 -0
- package/types/Popper.d.ts +183 -0
- package/types/index.d.ts +2 -0
package/src/Popper.tsx
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
// adapted from radix-ui popper
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
3
|
+
import { useComposedRefs } from '@hanzogui/compose-refs'
|
|
4
|
+
import { isWeb, useIsomorphicLayoutEffect } from '@hanzogui/constants'
|
|
5
|
+
import type { SizeTokens, GuiElement, ViewProps } from '@hanzogui/core'
|
|
6
|
+
import {
|
|
7
|
+
LayoutMeasurementController,
|
|
8
|
+
View as GuiView,
|
|
9
|
+
createStyledContext,
|
|
10
|
+
getVariableValue,
|
|
11
|
+
registerLayoutNode,
|
|
12
|
+
styled,
|
|
13
|
+
} from '@hanzogui/core'
|
|
14
|
+
import type { PopupTriggerMap } from '@hanzogui/floating'
|
|
15
|
+
import { FloatingOverrideContext } from '@hanzogui/floating'
|
|
16
|
+
import type {
|
|
17
|
+
Coords,
|
|
18
|
+
Middleware,
|
|
19
|
+
OffsetOptions,
|
|
20
|
+
Placement,
|
|
21
|
+
ReferenceType,
|
|
22
|
+
Side,
|
|
23
|
+
SizeOptions,
|
|
24
|
+
Strategy,
|
|
25
|
+
UseFloatingReturn,
|
|
26
|
+
} from '@hanzogui/floating'
|
|
27
|
+
import {
|
|
28
|
+
arrow,
|
|
29
|
+
flip,
|
|
30
|
+
getOverflowAncestors,
|
|
31
|
+
offset as offsetFn,
|
|
32
|
+
platform,
|
|
33
|
+
shift,
|
|
34
|
+
size as sizeMiddleware,
|
|
35
|
+
useFloating,
|
|
36
|
+
} from '@hanzogui/floating'
|
|
37
|
+
import { getSpace } from '@hanzogui/get-token'
|
|
38
|
+
import type { SizableStackProps, YStackProps } from '@hanzogui/stacks'
|
|
39
|
+
import { YStack } from '@hanzogui/stacks'
|
|
40
|
+
import { startTransition } from '@hanzogui/start-transition'
|
|
41
|
+
import * as React from 'react'
|
|
42
|
+
import { Keyboard, useWindowDimensions } from 'react-native'
|
|
43
|
+
|
|
44
|
+
type ShiftProps = typeof shift extends (options: infer Opts) => void ? Opts : never
|
|
45
|
+
type FlipProps = typeof flip extends (options: infer Opts) => void ? Opts : never
|
|
46
|
+
|
|
47
|
+
/* -------------------------------------------------------------------------------------------------
|
|
48
|
+
* Popper
|
|
49
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
50
|
+
|
|
51
|
+
export type PopperContextShared = {
|
|
52
|
+
open: boolean
|
|
53
|
+
size?: SizeTokens
|
|
54
|
+
hasFloating: boolean
|
|
55
|
+
arrowStyle?: Partial<Coords> & {
|
|
56
|
+
centerOffset: number
|
|
57
|
+
}
|
|
58
|
+
placement?: Placement
|
|
59
|
+
arrowRef: any
|
|
60
|
+
onArrowSize?: (val: number) => void
|
|
61
|
+
transformOrigin?: { x: string; y: string }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PopperContextValue = UseFloatingReturn & PopperContextShared
|
|
65
|
+
|
|
66
|
+
export const PopperContextFast = createStyledContext<PopperContextValue>(
|
|
67
|
+
// since we always provide this we can avoid setting here
|
|
68
|
+
{} as PopperContextValue,
|
|
69
|
+
'Popper__'
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
export const PopperPositionContext = createStyledContext
|
|
73
|
+
|
|
74
|
+
export const { useStyledContext: usePopperContext, Provider: PopperProviderFast } =
|
|
75
|
+
PopperContextFast
|
|
76
|
+
|
|
77
|
+
export type PopperContextSlowValue = Pick<
|
|
78
|
+
UseFloatingReturn,
|
|
79
|
+
'getReferenceProps' | 'update' | 'refs'
|
|
80
|
+
> & {
|
|
81
|
+
onHoverReference?: (event: any) => void
|
|
82
|
+
onLeaveReference?: () => void
|
|
83
|
+
triggerElements?: PopupTriggerMap
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const PopperContextSlow = createStyledContext<PopperContextSlowValue>(
|
|
87
|
+
// since we always provide this we can avoid setting here
|
|
88
|
+
{} as PopperContextSlowValue,
|
|
89
|
+
'PopperSlow__'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
export const { useStyledContext: usePopperContextSlow, Provider: PopperProviderSlow } =
|
|
93
|
+
PopperContextSlow
|
|
94
|
+
|
|
95
|
+
// handles both slow and fast:
|
|
96
|
+
export const PopperProvider = ({
|
|
97
|
+
scope,
|
|
98
|
+
children,
|
|
99
|
+
...context
|
|
100
|
+
}: PopperContextValue & { scope?: string; children?: React.ReactNode }) => {
|
|
101
|
+
// single ref holds all unstable functions — updated every render so the
|
|
102
|
+
// stable wrappers below always forward to the latest version
|
|
103
|
+
const fns = React.useRef(context)
|
|
104
|
+
fns.current = context
|
|
105
|
+
|
|
106
|
+
// stable wrappers that never change identity — objectIdentityKey in
|
|
107
|
+
// createStyledContext produces the same key across renders, so PopperAnchor
|
|
108
|
+
// instances never re-render from context changes (only from parent re-renders)
|
|
109
|
+
const [slowContext] = React.useState(
|
|
110
|
+
(): PopperContextSlowValue => ({
|
|
111
|
+
refs: context.refs,
|
|
112
|
+
triggerElements: context.triggerElements,
|
|
113
|
+
update(...a: []) {
|
|
114
|
+
fns.current.update(...a)
|
|
115
|
+
},
|
|
116
|
+
getReferenceProps(p?: any) {
|
|
117
|
+
return fns.current.getReferenceProps?.(p)
|
|
118
|
+
},
|
|
119
|
+
onHoverReference(e?: any) {
|
|
120
|
+
;(fns.current as any).onHoverReference?.(e)
|
|
121
|
+
},
|
|
122
|
+
onLeaveReference() {
|
|
123
|
+
;(fns.current as any).onLeaveReference?.()
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<PopperProviderFast scope={scope} {...context}>
|
|
130
|
+
<PopperProviderSlow scope={scope} {...slowContext}>
|
|
131
|
+
{children}
|
|
132
|
+
</PopperProviderSlow>
|
|
133
|
+
</PopperProviderFast>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type PopperProps = {
|
|
138
|
+
/**
|
|
139
|
+
* Popper is a component used by other components to create interfaces, so scope is required
|
|
140
|
+
* For example Popover uses it internally and sets a default "POPOVER_SCOPE".
|
|
141
|
+
*/
|
|
142
|
+
scope?: string
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Optional, will disable measuring updates when open is false for better performance
|
|
146
|
+
* */
|
|
147
|
+
open?: boolean
|
|
148
|
+
|
|
149
|
+
size?: SizeTokens
|
|
150
|
+
children?: React.ReactNode
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Determine the preferred placement of the content in relation to the trigger
|
|
154
|
+
*/
|
|
155
|
+
placement?: Placement
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Shifts content horizontally to stay within viewport.
|
|
159
|
+
* Pass an object to override shift options (mainAxis, crossAxis, padding, etc).
|
|
160
|
+
* Defaults: { mainAxis: true, crossAxis: false, padding: 10 }
|
|
161
|
+
* @see https://floating-ui.com/docs/shift
|
|
162
|
+
*/
|
|
163
|
+
stayInFrame?: ShiftProps | boolean
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Allows content to switch sides when space is limited.
|
|
167
|
+
* @see https://floating-ui.com/docs/flip
|
|
168
|
+
*/
|
|
169
|
+
allowFlip?: FlipProps | boolean
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resizes the content to fix inside the screen when space is limited
|
|
173
|
+
* @see https://floating-ui.com/docs/size
|
|
174
|
+
*/
|
|
175
|
+
resize?: boolean | Omit<SizeOptions, 'apply'>
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Choose between absolute or fixed positioning
|
|
179
|
+
*/
|
|
180
|
+
strategy?: Strategy
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Move the content away from the trigger
|
|
184
|
+
* @see https://floating-ui.com/docs/offset
|
|
185
|
+
*/
|
|
186
|
+
offset?: OffsetOptions
|
|
187
|
+
|
|
188
|
+
disableRTL?: boolean
|
|
189
|
+
|
|
190
|
+
passThrough?: boolean
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const checkFloating =
|
|
194
|
+
process.env.HANZO_GUI_TARGET === 'native'
|
|
195
|
+
? {
|
|
196
|
+
name: 'checkFloating',
|
|
197
|
+
fn(data: any) {
|
|
198
|
+
return {
|
|
199
|
+
data: {
|
|
200
|
+
hasFloating: !!data.rects.floating.width,
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
: undefined
|
|
206
|
+
|
|
207
|
+
export type PopperSetupOptions = {
|
|
208
|
+
disableRTL?: boolean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const setupOptions: PopperSetupOptions = {}
|
|
212
|
+
|
|
213
|
+
export function setupPopper(options?: PopperSetupOptions) {
|
|
214
|
+
Object.assign(setupOptions, options)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// forked from radix-ui 👇
|
|
218
|
+
// https://github.com/radix-ui/primitives/blob/1910a8c91c5927e58b8fca3aeaa31411f32fee7c/packages/react/popper/src/Popper.tsx#L359-L399
|
|
219
|
+
function getSideAndAlignFromPlacement(placement: Placement) {
|
|
220
|
+
const [side, align = 'center'] = placement.split('-')
|
|
221
|
+
return [side as Side, align as 'center' | 'start' | 'end'] as const
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const transformOriginMiddleware = (options: {
|
|
225
|
+
arrowWidth: number
|
|
226
|
+
arrowHeight: number
|
|
227
|
+
}): Middleware => ({
|
|
228
|
+
name: 'transformOrigin',
|
|
229
|
+
options,
|
|
230
|
+
fn(data) {
|
|
231
|
+
const { placement, rects, middlewareData } = data
|
|
232
|
+
|
|
233
|
+
const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0
|
|
234
|
+
const isArrowHidden = cannotCenterArrow
|
|
235
|
+
const arrowWidth = isArrowHidden ? 0 : options.arrowWidth
|
|
236
|
+
const arrowHeight = isArrowHidden ? 0 : options.arrowHeight
|
|
237
|
+
|
|
238
|
+
const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement)
|
|
239
|
+
const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[placedAlign]
|
|
240
|
+
|
|
241
|
+
const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2
|
|
242
|
+
const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2
|
|
243
|
+
|
|
244
|
+
let x = ''
|
|
245
|
+
let y = ''
|
|
246
|
+
|
|
247
|
+
if (placedSide === 'bottom') {
|
|
248
|
+
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
|
|
249
|
+
y = `${-arrowHeight}px`
|
|
250
|
+
} else if (placedSide === 'top') {
|
|
251
|
+
x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`
|
|
252
|
+
y = `${rects.floating.height + arrowHeight}px`
|
|
253
|
+
} else if (placedSide === 'right') {
|
|
254
|
+
x = `${-arrowHeight}px`
|
|
255
|
+
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
|
|
256
|
+
} else if (placedSide === 'left') {
|
|
257
|
+
x = `${rects.floating.width + arrowHeight}px`
|
|
258
|
+
y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { data: { x, y } }
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// replaces floating-ui's autoUpdate with gui's batched IO measurement loop
|
|
266
|
+
// keeps scroll/resize listeners for immediate response, but replaces per-element
|
|
267
|
+
// ResizeObserver + IntersectionObserver with the shared layoutOnAnimationFrame loop
|
|
268
|
+
function guiAutoUpdate(
|
|
269
|
+
reference: ReferenceType,
|
|
270
|
+
floating: HTMLElement,
|
|
271
|
+
update: () => void
|
|
272
|
+
): () => void {
|
|
273
|
+
// initial position
|
|
274
|
+
update()
|
|
275
|
+
|
|
276
|
+
// schedule a second update after layout/scroll events settle (e.g. focus-
|
|
277
|
+
// triggered scrolls that cause flip corrections)
|
|
278
|
+
let rafId = requestAnimationFrame(() => {
|
|
279
|
+
update()
|
|
280
|
+
rafId = 0
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const cleanups: (() => void)[] = [
|
|
284
|
+
() => {
|
|
285
|
+
if (rafId) cancelAnimationFrame(rafId)
|
|
286
|
+
},
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
// watch reference element via gui's IO measurement loop
|
|
290
|
+
// only watch reference, NOT floating — watching floating causes loops
|
|
291
|
+
// (computePosition sets position → rect changes → update → repeat)
|
|
292
|
+
if (reference instanceof HTMLElement) {
|
|
293
|
+
cleanups.push(registerLayoutNode(reference, update))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// scroll listeners for immediate response (only for real DOM elements)
|
|
297
|
+
const refAncestors = reference instanceof Element ? getOverflowAncestors(reference) : []
|
|
298
|
+
const ancestors = [...refAncestors, ...getOverflowAncestors(floating)]
|
|
299
|
+
const uniqueAncestors = [...new Set(ancestors)]
|
|
300
|
+
for (const ancestor of uniqueAncestors) {
|
|
301
|
+
ancestor.addEventListener('scroll', update, { passive: true })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// window resize
|
|
305
|
+
window.addEventListener('resize', update)
|
|
306
|
+
|
|
307
|
+
cleanups.push(() => {
|
|
308
|
+
for (const ancestor of uniqueAncestors) {
|
|
309
|
+
ancestor.removeEventListener('scroll', update)
|
|
310
|
+
}
|
|
311
|
+
window.removeEventListener('resize', update)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
return () => cleanups.forEach((fn) => fn())
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function Popper(props: PopperProps) {
|
|
318
|
+
const {
|
|
319
|
+
children,
|
|
320
|
+
size,
|
|
321
|
+
strategy = 'absolute',
|
|
322
|
+
placement = 'bottom',
|
|
323
|
+
stayInFrame,
|
|
324
|
+
allowFlip,
|
|
325
|
+
offset,
|
|
326
|
+
disableRTL,
|
|
327
|
+
resize,
|
|
328
|
+
passThrough,
|
|
329
|
+
open,
|
|
330
|
+
scope,
|
|
331
|
+
} = props
|
|
332
|
+
|
|
333
|
+
const [arrowEl, setArrow] = React.useState<any>(null)
|
|
334
|
+
const [arrowSize, setArrowSize] = React.useState(0)
|
|
335
|
+
const offsetOptions = offset ?? arrowSize
|
|
336
|
+
const floatingStyle = React.useRef({})
|
|
337
|
+
const isOpen = passThrough ? false : (open ?? true)
|
|
338
|
+
|
|
339
|
+
// freeze middleware reference when closed so floating-ui's deepEqual trivially
|
|
340
|
+
// passes (same object) and skips computePosition on re-renders while closed.
|
|
341
|
+
// unlike swapping to [], this retains the last good middleware so cached
|
|
342
|
+
// position data (offset, arrow, transformOrigin) stays correct for reopen.
|
|
343
|
+
const middlewareRef = React.useRef<any[]>([])
|
|
344
|
+
if (isOpen) {
|
|
345
|
+
middlewareRef.current = [
|
|
346
|
+
// order matters: offset first, then flip, then shift, then arrow
|
|
347
|
+
typeof offsetOptions !== 'undefined' ? offsetFn(offsetOptions) : (null as any),
|
|
348
|
+
allowFlip ? flip(typeof allowFlip === 'boolean' ? {} : allowFlip) : (null as any),
|
|
349
|
+
// NOTE: shift's axis terminology is reversed vs flip/offset:
|
|
350
|
+
// for top/bottom placements: mainAxis = horizontal, crossAxis = vertical
|
|
351
|
+
// for left/right placements: mainAxis = vertical, crossAxis = horizontal
|
|
352
|
+
// default to horizontal shift only (mainAxis: true, crossAxis: false)
|
|
353
|
+
stayInFrame
|
|
354
|
+
? shift({
|
|
355
|
+
padding: 10,
|
|
356
|
+
mainAxis: true,
|
|
357
|
+
crossAxis: false,
|
|
358
|
+
...(typeof stayInFrame === 'object' ? stayInFrame : null),
|
|
359
|
+
})
|
|
360
|
+
: (null as any),
|
|
361
|
+
arrowEl ? arrow({ element: arrowEl }) : (null as any),
|
|
362
|
+
checkFloating,
|
|
363
|
+
process.env.HANZO_GUI_TARGET !== 'native' && resize
|
|
364
|
+
? sizeMiddleware({
|
|
365
|
+
padding: typeof stayInFrame === 'object' ? stayInFrame.padding : 10,
|
|
366
|
+
apply({ availableHeight, availableWidth }) {
|
|
367
|
+
if (passThrough) {
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
Object.assign(floatingStyle.current, {
|
|
372
|
+
maxHeight: `${availableHeight}px`,
|
|
373
|
+
maxWidth: `${availableWidth}px`,
|
|
374
|
+
})
|
|
375
|
+
// we wrap PopperContent with one container stack so we need to account for it
|
|
376
|
+
const floatingChild = floating.refs.floating.current?.firstChild
|
|
377
|
+
if (floatingChild && floatingChild instanceof HTMLElement) {
|
|
378
|
+
Object.assign(floatingChild.style, floatingStyle.current)
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
...(typeof resize === 'object' && resize),
|
|
382
|
+
})
|
|
383
|
+
: (null as any),
|
|
384
|
+
// Add size middleware for CSS custom properties (web only)
|
|
385
|
+
process.env.HANZO_GUI_TARGET !== 'native'
|
|
386
|
+
? sizeMiddleware({
|
|
387
|
+
apply({ elements, rects, availableWidth, availableHeight }) {
|
|
388
|
+
const { width: anchorWidth, height: anchorHeight } = rects.reference
|
|
389
|
+
const contentStyle = elements.floating.style
|
|
390
|
+
contentStyle.setProperty(
|
|
391
|
+
'--gui-popper-available-width',
|
|
392
|
+
`${availableWidth}px`
|
|
393
|
+
)
|
|
394
|
+
contentStyle.setProperty(
|
|
395
|
+
'--gui-popper-available-height',
|
|
396
|
+
`${availableHeight}px`
|
|
397
|
+
)
|
|
398
|
+
contentStyle.setProperty(
|
|
399
|
+
'--gui-popper-anchor-width',
|
|
400
|
+
`${anchorWidth}px`
|
|
401
|
+
)
|
|
402
|
+
contentStyle.setProperty(
|
|
403
|
+
'--gui-popper-anchor-height',
|
|
404
|
+
`${anchorHeight}px`
|
|
405
|
+
)
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
: (null as any),
|
|
409
|
+
// Transform origin middleware (web only)
|
|
410
|
+
process.env.HANZO_GUI_TARGET !== 'native'
|
|
411
|
+
? transformOriginMiddleware({
|
|
412
|
+
arrowHeight: arrowSize,
|
|
413
|
+
arrowWidth: arrowSize,
|
|
414
|
+
})
|
|
415
|
+
: (null as any),
|
|
416
|
+
].filter(Boolean)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let floating = useFloating({
|
|
420
|
+
open: isOpen,
|
|
421
|
+
strategy,
|
|
422
|
+
placement,
|
|
423
|
+
sameScrollView: false, // this only takes effect on native
|
|
424
|
+
whileElementsMounted: !isOpen ? undefined : guiAutoUpdate,
|
|
425
|
+
platform:
|
|
426
|
+
(disableRTL ?? setupOptions.disableRTL)
|
|
427
|
+
? {
|
|
428
|
+
...platform,
|
|
429
|
+
isRTL(element) {
|
|
430
|
+
return false
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
: platform,
|
|
434
|
+
middleware: middlewareRef.current,
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if (process.env.HANZO_GUI_TARGET !== 'native') {
|
|
438
|
+
// add our size middleware here
|
|
439
|
+
floating = React.useMemo(() => {
|
|
440
|
+
const og = floating.getFloatingProps
|
|
441
|
+
if (resize && og) {
|
|
442
|
+
floating.getFloatingProps = (props) => {
|
|
443
|
+
return og({
|
|
444
|
+
...props,
|
|
445
|
+
style: {
|
|
446
|
+
...props.style,
|
|
447
|
+
...floatingStyle.current,
|
|
448
|
+
},
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return floating
|
|
453
|
+
}, [floating, resize ? JSON.stringify(resize) : null])
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const { middlewareData } = floating
|
|
457
|
+
|
|
458
|
+
if (process.env.HANZO_GUI_TARGET === 'native') {
|
|
459
|
+
// On Native there's no autoupdate so we call update() when necessary
|
|
460
|
+
|
|
461
|
+
// Subscribe to window dimensions (orientation, scale, etc...)
|
|
462
|
+
const dimensions = useWindowDimensions()
|
|
463
|
+
|
|
464
|
+
// Subscribe to keyboard state
|
|
465
|
+
const [keyboardOpen, setKeyboardOpen] = React.useState(false)
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
const showSubscription = Keyboard.addListener('keyboardDidShow', () => {
|
|
468
|
+
startTransition(() => {
|
|
469
|
+
setKeyboardOpen(true)
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
const hideSubscription = Keyboard.addListener('keyboardDidHide', () => {
|
|
473
|
+
startTransition(() => {
|
|
474
|
+
setKeyboardOpen(false)
|
|
475
|
+
})
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
return () => {
|
|
479
|
+
showSubscription.remove()
|
|
480
|
+
hideSubscription.remove()
|
|
481
|
+
}
|
|
482
|
+
}, [])
|
|
483
|
+
|
|
484
|
+
useIsomorphicLayoutEffect(() => {
|
|
485
|
+
if (passThrough) return
|
|
486
|
+
floating.update()
|
|
487
|
+
}, [passThrough, dimensions, keyboardOpen])
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const popperContext = React.useMemo(() => {
|
|
491
|
+
return {
|
|
492
|
+
size,
|
|
493
|
+
arrowRef: setArrow,
|
|
494
|
+
arrowStyle: middlewareData.arrow,
|
|
495
|
+
onArrowSize: setArrowSize,
|
|
496
|
+
hasFloating: middlewareData.checkFloating?.hasFloating,
|
|
497
|
+
transformOrigin: middlewareData.transformOrigin as
|
|
498
|
+
| { x: string; y: string }
|
|
499
|
+
| undefined,
|
|
500
|
+
open: !!open,
|
|
501
|
+
...floating,
|
|
502
|
+
} satisfies PopperContextValue
|
|
503
|
+
}, [
|
|
504
|
+
open,
|
|
505
|
+
size,
|
|
506
|
+
floating,
|
|
507
|
+
JSON.stringify(middlewareData.arrow || null),
|
|
508
|
+
JSON.stringify(middlewareData.transformOrigin || null),
|
|
509
|
+
])
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<PopperProvider scope={scope} {...popperContext}>
|
|
513
|
+
{/* reset FloatingOverrideContext so it doesn't leak into nested Poppers —
|
|
514
|
+
each Popper consumes the override for its own useFloating, children
|
|
515
|
+
should not inherit it (e.g. a Menu inside a Tooltip's tree) */}
|
|
516
|
+
<FloatingOverrideContext.Provider value={null}>
|
|
517
|
+
{children}
|
|
518
|
+
</FloatingOverrideContext.Provider>
|
|
519
|
+
</PopperProvider>
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* -------------------------------------------------------------------------------------------------
|
|
524
|
+
* PopperAnchor
|
|
525
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
526
|
+
|
|
527
|
+
type PopperAnchorRef = GuiElement
|
|
528
|
+
|
|
529
|
+
export type PopperAnchorExtraProps = {
|
|
530
|
+
virtualRef?: React.RefObject<any>
|
|
531
|
+
scope?: string
|
|
532
|
+
}
|
|
533
|
+
export type PopperAnchorProps = YStackProps
|
|
534
|
+
|
|
535
|
+
export const PopperAnchor = YStack.styleable<PopperAnchorExtraProps>(
|
|
536
|
+
function PopperAnchor(props, forwardedRef) {
|
|
537
|
+
const { virtualRef, scope, ...rest } = props
|
|
538
|
+
const context = usePopperContextSlow(scope)
|
|
539
|
+
const { getReferenceProps, refs, update } = context
|
|
540
|
+
const ref = React.useRef<PopperAnchorRef>(null)
|
|
541
|
+
const triggerId = React.useId()
|
|
542
|
+
|
|
543
|
+
// register this trigger element with the shared trigger map
|
|
544
|
+
// so useHover can detect cursor moves between sibling triggers
|
|
545
|
+
React.useEffect(() => {
|
|
546
|
+
if (!scope || !context.triggerElements || !ref.current) return
|
|
547
|
+
if (!(ref.current instanceof Element)) return
|
|
548
|
+
const el = ref.current as Element
|
|
549
|
+
context.triggerElements.add(triggerId, el)
|
|
550
|
+
return () => {
|
|
551
|
+
context.triggerElements?.delete(triggerId)
|
|
552
|
+
}
|
|
553
|
+
}, [scope, triggerId, context.triggerElements])
|
|
554
|
+
|
|
555
|
+
React.useEffect(() => {
|
|
556
|
+
if (virtualRef) {
|
|
557
|
+
refs.setReference(virtualRef.current)
|
|
558
|
+
// recompute position after setting virtual reference
|
|
559
|
+
update()
|
|
560
|
+
}
|
|
561
|
+
}, [virtualRef])
|
|
562
|
+
|
|
563
|
+
const refProps =
|
|
564
|
+
getReferenceProps?.({
|
|
565
|
+
...rest,
|
|
566
|
+
ref,
|
|
567
|
+
}) || null
|
|
568
|
+
|
|
569
|
+
// Wrap setReference in startTransition to avoid React #185 (setState during render)
|
|
570
|
+
const safeSetReference = React.useCallback(
|
|
571
|
+
(node: any) => {
|
|
572
|
+
startTransition(() => {
|
|
573
|
+
refs.setReference(node)
|
|
574
|
+
})
|
|
575
|
+
},
|
|
576
|
+
// it was refs.setRefernce but its stable and refs is undefined on server
|
|
577
|
+
[refs]
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
const shouldHandleInHover = isWeb && scope
|
|
581
|
+
const composedRefs = useComposedRefs(
|
|
582
|
+
forwardedRef,
|
|
583
|
+
ref,
|
|
584
|
+
// web handles this onMouseEnter below so it can support multiple targets + hovering
|
|
585
|
+
shouldHandleInHover ? undefined : safeSetReference
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<GuiView
|
|
590
|
+
{...rest}
|
|
591
|
+
{...refProps}
|
|
592
|
+
ref={composedRefs}
|
|
593
|
+
{...(shouldHandleInHover && {
|
|
594
|
+
// scoped poppers with multiple triggers: set the reference on
|
|
595
|
+
// mouseEnter so floating-ui positions relative to the hovered
|
|
596
|
+
// trigger, not the last one rendered.
|
|
597
|
+
//
|
|
598
|
+
// flushSync is critical here: without it, setReference batches
|
|
599
|
+
// with React's async state updates and the arrow/content position
|
|
600
|
+
// computes against the OLD reference element. this causes the
|
|
601
|
+
// arrow to flash at x=0 (top-left) during trigger switches.
|
|
602
|
+
// flushSync forces synchronous commit so update() below reads
|
|
603
|
+
// the correct reference element immediately.
|
|
604
|
+
onMouseEnter: (e) => {
|
|
605
|
+
const el = (e.currentTarget ?? ref.current) as HTMLElement | null
|
|
606
|
+
if (el instanceof HTMLElement) {
|
|
607
|
+
flushSync(() => refs.setReference(el))
|
|
608
|
+
update()
|
|
609
|
+
|
|
610
|
+
if (!refProps) return
|
|
611
|
+
|
|
612
|
+
refProps.onPointerEnter?.(e)
|
|
613
|
+
context.onHoverReference?.(e.nativeEvent)
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
onMouseLeave: (e) => {
|
|
617
|
+
context.onLeaveReference?.()
|
|
618
|
+
refProps?.onMouseLeave?.(e)
|
|
619
|
+
},
|
|
620
|
+
})}
|
|
621
|
+
/>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
/* -------------------------------------------------------------------------------------------------
|
|
627
|
+
* PopperContent
|
|
628
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
629
|
+
|
|
630
|
+
type PopperContentElement = GuiElement
|
|
631
|
+
|
|
632
|
+
export type PopperContentProps = SizableStackProps & {
|
|
633
|
+
scope?: string
|
|
634
|
+
/**
|
|
635
|
+
* Enable smooth animation when the content position changes (e.g., when flipping sides)
|
|
636
|
+
*/
|
|
637
|
+
animatePosition?: boolean | 'even-when-repositioning'
|
|
638
|
+
/** @deprecated Use `animatePosition` instead */
|
|
639
|
+
enableAnimationForPositionChange?: boolean | 'even-when-repositioning'
|
|
640
|
+
passThrough?: boolean
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export const PopperContentFrame = styled(YStack, {
|
|
644
|
+
name: 'PopperContent',
|
|
645
|
+
|
|
646
|
+
variants: {
|
|
647
|
+
unstyled: {
|
|
648
|
+
true: {},
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
size: {
|
|
652
|
+
'...size': (val, { tokens }) => {
|
|
653
|
+
return {
|
|
654
|
+
padding: tokens.space[val],
|
|
655
|
+
borderRadius: tokens.radius[val],
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
} as const,
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
export const PopperContent = React.forwardRef<PopperContentElement, PopperContentProps>(
|
|
663
|
+
function PopperContent(props, forwardedRef) {
|
|
664
|
+
const {
|
|
665
|
+
scope,
|
|
666
|
+
animatePosition,
|
|
667
|
+
enableAnimationForPositionChange,
|
|
668
|
+
children,
|
|
669
|
+
passThrough,
|
|
670
|
+
unstyled,
|
|
671
|
+
...rest
|
|
672
|
+
} = props
|
|
673
|
+
const animatePos = animatePosition ?? enableAnimationForPositionChange
|
|
674
|
+
const context = usePopperContext(scope)
|
|
675
|
+
|
|
676
|
+
const {
|
|
677
|
+
strategy,
|
|
678
|
+
placement,
|
|
679
|
+
refs,
|
|
680
|
+
x,
|
|
681
|
+
y,
|
|
682
|
+
getFloatingProps,
|
|
683
|
+
size,
|
|
684
|
+
isPositioned,
|
|
685
|
+
transformOrigin,
|
|
686
|
+
update,
|
|
687
|
+
} = context
|
|
688
|
+
|
|
689
|
+
// keep update() accessible inside safeSetFloating without adding it as a dep
|
|
690
|
+
const updateRef = React.useRef(update)
|
|
691
|
+
updateRef.current = update
|
|
692
|
+
|
|
693
|
+
// ref callback: call refs.setFloating directly (no startTransition) so floating-ui's
|
|
694
|
+
// state update runs synchronously and position is computed on mount.
|
|
695
|
+
// note: ref callbacks fire during the commit phase, not render, so calling setState
|
|
696
|
+
// here is safe - React batches it for the next commit.
|
|
697
|
+
//
|
|
698
|
+
// when animatePosition=true, disableAnimation state changes cycle the DOM node
|
|
699
|
+
// (null then re-mount). we block all null calls here to prevent floating-ui from
|
|
700
|
+
// losing its reference mid-cycle; genuine unmount is handled by the useEffect below.
|
|
701
|
+
// for same-node cycling (animateOnly prop change without remount), refs.setFloating
|
|
702
|
+
// is a no-op in floating-ui (same-node guard), so we call update() to force recompute.
|
|
703
|
+
const lastNodeRef = React.useRef<any>(null)
|
|
704
|
+
const safeSetFloating = React.useCallback(
|
|
705
|
+
(node: any) => {
|
|
706
|
+
const isNewNode = node !== lastNodeRef.current
|
|
707
|
+
if (node) {
|
|
708
|
+
lastNodeRef.current = node
|
|
709
|
+
refs.setFloating(node)
|
|
710
|
+
if (!isNewNode) {
|
|
711
|
+
// same node re-appeared (prop cycling without remount):
|
|
712
|
+
// refs.setFloating is a no-op, so force position recompute
|
|
713
|
+
updateRef.current?.()
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// null calls are blocked: cycling nulls are transient, genuine unmount
|
|
717
|
+
// is handled by the useEffect cleanup below
|
|
718
|
+
},
|
|
719
|
+
[refs.setFloating]
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
// clear floating-ui's reference when the component genuinely unmounts.
|
|
723
|
+
// IMPORTANT: useEffect cleanup is deferred — when PopperContent remounts
|
|
724
|
+
// (e.g. animation prop cycling), the new instance's ref callback fires
|
|
725
|
+
// BEFORE this cleanup runs. without the guard, we'd null out the ref that
|
|
726
|
+
// the new instance just set, causing all subsequent update() calls to
|
|
727
|
+
// early-return (the "stuck tooltip" bug).
|
|
728
|
+
React.useEffect(() => {
|
|
729
|
+
return () => {
|
|
730
|
+
const ourNode = lastNodeRef.current
|
|
731
|
+
// only clear if floating-ui still points to OUR node — if a new
|
|
732
|
+
// instance already set a different node, don't touch it
|
|
733
|
+
if (ourNode && refs.floating.current === ourNode) {
|
|
734
|
+
refs.setFloating(null)
|
|
735
|
+
}
|
|
736
|
+
lastNodeRef.current = null
|
|
737
|
+
}
|
|
738
|
+
}, [])
|
|
739
|
+
|
|
740
|
+
const contentRefs = useComposedRefs<any>(safeSetFloating, forwardedRef)
|
|
741
|
+
|
|
742
|
+
const [needsMeasure, setNeedsMeasure] = React.useState(animatePos)
|
|
743
|
+
|
|
744
|
+
useIsomorphicLayoutEffect(() => {
|
|
745
|
+
if (needsMeasure && x && y) {
|
|
746
|
+
setNeedsMeasure(false)
|
|
747
|
+
}
|
|
748
|
+
}, [needsMeasure, animatePos, x, y])
|
|
749
|
+
|
|
750
|
+
// track whether we've ever been positioned. floating-ui resets isPositioned
|
|
751
|
+
// to false when open changes to false (e.g. hoverable safePolygon briefly
|
|
752
|
+
// closing). without this, the brief close disables animation and causes
|
|
753
|
+
// position jumps when the popover reopens at the new trigger.
|
|
754
|
+
const hasBeenPositioned = React.useRef(false)
|
|
755
|
+
const lastGoodPosition = React.useRef({ x: 0, y: 0 })
|
|
756
|
+
if (x !== 0 || y !== 0) {
|
|
757
|
+
// always track the latest computed position so that when a new reference
|
|
758
|
+
// is set while closed (e.g. content → gap → different trigger), the
|
|
759
|
+
// effectiveX/Y fallback uses the fresh position, not the stale one
|
|
760
|
+
lastGoodPosition.current = { x, y }
|
|
761
|
+
if (isPositioned) {
|
|
762
|
+
hasBeenPositioned.current = true
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// use the last known good position when floating-ui provides 0,0.
|
|
767
|
+
// this happens in two cases:
|
|
768
|
+
// 1. close/reopen cycle: isPositioned resets to false
|
|
769
|
+
// 2. trigger switch: reference element changes, floating-ui briefly
|
|
770
|
+
// provides x=0,y=0 while isPositioned is still true, causing the
|
|
771
|
+
// animation driver to animate toward (0,0) for 2-3 frames
|
|
772
|
+
const brieflyZero = hasBeenPositioned.current && x === 0 && y === 0
|
|
773
|
+
const effectiveX = brieflyZero ? lastGoodPosition.current.x : x
|
|
774
|
+
const effectiveY = brieflyZero ? lastGoodPosition.current.y : y
|
|
775
|
+
|
|
776
|
+
// only hide before the very first positioning
|
|
777
|
+
const hide = !hasBeenPositioned.current && effectiveX === 0 && effectiveY === 0
|
|
778
|
+
|
|
779
|
+
const disableAnimationProp =
|
|
780
|
+
// if they want to animate also when re-positioning allow it
|
|
781
|
+
animatePos === 'even-when-repositioning'
|
|
782
|
+
? needsMeasure
|
|
783
|
+
: (!hasBeenPositioned.current && !isPositioned) || needsMeasure
|
|
784
|
+
|
|
785
|
+
const [disableAnimation, setDisableAnimation] = React.useState(disableAnimationProp)
|
|
786
|
+
|
|
787
|
+
// set in an effect so we apply the css transition only after the element is positioned,
|
|
788
|
+
// not on the first render (which would animate from y=0 to the actual position)
|
|
789
|
+
React.useEffect(() => {
|
|
790
|
+
setDisableAnimation(disableAnimationProp)
|
|
791
|
+
}, [disableAnimationProp])
|
|
792
|
+
|
|
793
|
+
const positionProps = hide
|
|
794
|
+
? {} // omit x/y when hiding - prevents motion driver from animating from origin
|
|
795
|
+
: { x: effectiveX || 0, y: effectiveY || 0 }
|
|
796
|
+
|
|
797
|
+
const frameProps = {
|
|
798
|
+
ref: contentRefs,
|
|
799
|
+
...positionProps,
|
|
800
|
+
top: 0,
|
|
801
|
+
left: 0,
|
|
802
|
+
position: strategy,
|
|
803
|
+
opacity: hide ? 0 : 1,
|
|
804
|
+
...(animatePos && {
|
|
805
|
+
transition: rest.transition,
|
|
806
|
+
// animateOnly: [] turns off transitions while keeping styles applied,
|
|
807
|
+
// letting the element move to its position silently before animations start
|
|
808
|
+
animateOnly: disableAnimation ? [] : rest.animateOnly,
|
|
809
|
+
animatePresence: false,
|
|
810
|
+
}),
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// outer frame because we explicitly don't want animation to apply to this
|
|
814
|
+
|
|
815
|
+
const { style, ...floatingProps } = getFloatingProps
|
|
816
|
+
? getFloatingProps(frameProps)
|
|
817
|
+
: frameProps
|
|
818
|
+
|
|
819
|
+
// Compute the CSS transform-origin value from middleware data
|
|
820
|
+
const transformOriginStyle =
|
|
821
|
+
isWeb && transformOrigin
|
|
822
|
+
? { transformOrigin: `${transformOrigin.x} ${transformOrigin.y}` }
|
|
823
|
+
: undefined
|
|
824
|
+
|
|
825
|
+
return (
|
|
826
|
+
<LayoutMeasurementController disable={!context.open}>
|
|
827
|
+
<GuiView
|
|
828
|
+
passThrough={passThrough}
|
|
829
|
+
ref={contentRefs}
|
|
830
|
+
direction={rest.direction}
|
|
831
|
+
{...(passThrough ? null : floatingProps)}
|
|
832
|
+
{...(!passThrough &&
|
|
833
|
+
animatePos && {
|
|
834
|
+
'data-popper-animate-position': 'true',
|
|
835
|
+
})}
|
|
836
|
+
>
|
|
837
|
+
<PopperContentFrame
|
|
838
|
+
key="popper-content-frame"
|
|
839
|
+
passThrough={passThrough}
|
|
840
|
+
unstyled={unstyled}
|
|
841
|
+
{...(!passThrough && {
|
|
842
|
+
'data-placement': placement,
|
|
843
|
+
'data-strategy': strategy,
|
|
844
|
+
size,
|
|
845
|
+
...style,
|
|
846
|
+
...transformOriginStyle,
|
|
847
|
+
...rest,
|
|
848
|
+
})}
|
|
849
|
+
>
|
|
850
|
+
{children}
|
|
851
|
+
</PopperContentFrame>
|
|
852
|
+
</GuiView>
|
|
853
|
+
</LayoutMeasurementController>
|
|
854
|
+
)
|
|
855
|
+
}
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
/* -------------------------------------------------------------------------------------------------
|
|
859
|
+
* PopperArrow
|
|
860
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
861
|
+
|
|
862
|
+
export type PopperArrowExtraProps = {
|
|
863
|
+
offset?: number
|
|
864
|
+
size?: SizeTokens
|
|
865
|
+
scope?: string
|
|
866
|
+
/**
|
|
867
|
+
* Enable smooth animation when the arrow position changes
|
|
868
|
+
*/
|
|
869
|
+
animatePosition?: boolean
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export type PopperArrowProps = YStackProps & PopperArrowExtraProps
|
|
873
|
+
|
|
874
|
+
export const PopperArrowFrame = styled(YStack, {
|
|
875
|
+
name: 'PopperArrow',
|
|
876
|
+
|
|
877
|
+
variants: {
|
|
878
|
+
unstyled: {
|
|
879
|
+
false: {
|
|
880
|
+
borderColor: '$borderColor',
|
|
881
|
+
backgroundColor: '$background',
|
|
882
|
+
position: 'relative',
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
} as const,
|
|
886
|
+
|
|
887
|
+
defaultVariants: {
|
|
888
|
+
unstyled: process.env.HANZO_GUI_HEADLESS === '1',
|
|
889
|
+
},
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
const PopperArrowOuterFrame = styled(YStack, {
|
|
893
|
+
name: 'PopperArrowOuter',
|
|
894
|
+
|
|
895
|
+
variants: {
|
|
896
|
+
unstyled: {
|
|
897
|
+
false: {
|
|
898
|
+
position: 'absolute',
|
|
899
|
+
zIndex: 1_000_000,
|
|
900
|
+
pointerEvents: 'none',
|
|
901
|
+
overflow: 'hidden',
|
|
902
|
+
alignItems: 'center',
|
|
903
|
+
justifyContent: 'center',
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
} as const,
|
|
907
|
+
|
|
908
|
+
defaultVariants: {
|
|
909
|
+
unstyled: process.env.HANZO_GUI_HEADLESS === '1',
|
|
910
|
+
},
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
const opposites = {
|
|
914
|
+
top: 'bottom',
|
|
915
|
+
right: 'left',
|
|
916
|
+
bottom: 'top',
|
|
917
|
+
left: 'right',
|
|
918
|
+
} as const
|
|
919
|
+
|
|
920
|
+
type Sides = keyof typeof opposites
|
|
921
|
+
|
|
922
|
+
export const PopperArrow = React.forwardRef<GuiElement, PopperArrowProps>(
|
|
923
|
+
function PopperArrow(propsIn, forwardedRef) {
|
|
924
|
+
const { scope, animatePosition, transition, ...rest } = propsIn
|
|
925
|
+
const { offset, size: sizeProp, borderWidth = 0, ...arrowProps } = rest
|
|
926
|
+
|
|
927
|
+
const context = usePopperContext(scope)
|
|
928
|
+
|
|
929
|
+
// TODO: get rid! at the very least move up to Popover and simplify
|
|
930
|
+
const sizeVal =
|
|
931
|
+
typeof sizeProp === 'number'
|
|
932
|
+
? sizeProp
|
|
933
|
+
: getVariableValue(
|
|
934
|
+
getSpace(sizeProp ?? context.size, {
|
|
935
|
+
shift: -2,
|
|
936
|
+
bounds: [2],
|
|
937
|
+
})
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
const size = Math.max(0, +sizeVal)
|
|
941
|
+
|
|
942
|
+
const { placement } = context
|
|
943
|
+
const refs = useComposedRefs(context.arrowRef, forwardedRef)
|
|
944
|
+
|
|
945
|
+
// Sometimes floating-ui can return NaN during orientation or screen size changes on native
|
|
946
|
+
// so we explicitly force the x,y position types as a number
|
|
947
|
+
const x = (context.arrowStyle?.x as number) || 0
|
|
948
|
+
const y = (context.arrowStyle?.y as number) || 0
|
|
949
|
+
|
|
950
|
+
// hide arrow until floating-ui has computed its position to prevent
|
|
951
|
+
// flash at x=0 during initial render or trigger switches in hydration
|
|
952
|
+
const arrowPositioned = context.arrowStyle != null
|
|
953
|
+
|
|
954
|
+
const primaryPlacement = (placement ? placement.split('-')[0] : 'top') as Sides
|
|
955
|
+
|
|
956
|
+
const arrowStyle: ViewProps = { x, y, width: size, height: size }
|
|
957
|
+
|
|
958
|
+
const innerArrowStyle: ViewProps = {}
|
|
959
|
+
const isVertical = primaryPlacement === 'bottom' || primaryPlacement === 'top'
|
|
960
|
+
|
|
961
|
+
if (primaryPlacement) {
|
|
962
|
+
// allows for extra diagonal size
|
|
963
|
+
arrowStyle[isVertical ? 'width' : 'height'] = size * 2
|
|
964
|
+
const oppSide = opposites[primaryPlacement]
|
|
965
|
+
if (oppSide) {
|
|
966
|
+
arrowStyle[oppSide] = -size
|
|
967
|
+
innerArrowStyle[oppSide] = size / 2
|
|
968
|
+
}
|
|
969
|
+
if (oppSide === 'top' || oppSide === 'bottom') {
|
|
970
|
+
arrowStyle.left = 0
|
|
971
|
+
}
|
|
972
|
+
if (oppSide === 'left' || oppSide === 'right') {
|
|
973
|
+
arrowStyle.top = 0
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// send the Arrow's offset up to Popper
|
|
977
|
+
useIsomorphicLayoutEffect(() => {
|
|
978
|
+
context.onArrowSize?.(size)
|
|
979
|
+
}, [size, context.onArrowSize])
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// outer frame to cut off for ability to have nicer shadows/borders
|
|
983
|
+
return (
|
|
984
|
+
<PopperArrowOuterFrame
|
|
985
|
+
ref={refs}
|
|
986
|
+
{...arrowStyle}
|
|
987
|
+
{...(!arrowPositioned && { opacity: 0 })}
|
|
988
|
+
{...(animatePosition && {
|
|
989
|
+
transition,
|
|
990
|
+
animateOnly: ['transform'],
|
|
991
|
+
animatePresence: false,
|
|
992
|
+
})}
|
|
993
|
+
>
|
|
994
|
+
<PopperArrowFrame
|
|
995
|
+
width={size}
|
|
996
|
+
height={size}
|
|
997
|
+
{...arrowProps}
|
|
998
|
+
{...innerArrowStyle}
|
|
999
|
+
rotate="45deg"
|
|
1000
|
+
{...(primaryPlacement === 'bottom' && {
|
|
1001
|
+
borderLeftWidth: borderWidth,
|
|
1002
|
+
borderTopWidth: borderWidth,
|
|
1003
|
+
})}
|
|
1004
|
+
{...(primaryPlacement === 'top' && {
|
|
1005
|
+
borderBottomWidth: borderWidth,
|
|
1006
|
+
borderRightWidth: borderWidth,
|
|
1007
|
+
})}
|
|
1008
|
+
{...(primaryPlacement === 'right' && {
|
|
1009
|
+
borderLeftWidth: borderWidth,
|
|
1010
|
+
borderBottomWidth: borderWidth,
|
|
1011
|
+
})}
|
|
1012
|
+
{...(primaryPlacement === 'left' && {
|
|
1013
|
+
borderTopWidth: borderWidth,
|
|
1014
|
+
borderRightWidth: borderWidth,
|
|
1015
|
+
})}
|
|
1016
|
+
/>
|
|
1017
|
+
</PopperArrowOuterFrame>
|
|
1018
|
+
)
|
|
1019
|
+
}
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
/* -----------------------------------------------------------------------------------------------*/
|