@unrulysystems/native-motion 0.1.0-alpha.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/android/build.gradle +24 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
- package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
- package/package.json +57 -0
- package/react-native.config.js +14 -0
- package/src/native/driver/instantWindow.ts +31 -0
- package/src/native/driver/strictModeReplay.ts +20 -0
- package/src/native/driver/uiLayoutEngine.ts +845 -0
- package/src/native/driver/uiLayoutGraph.ts +338 -0
- package/src/native/driver/uiValueChannel.ts +4640 -0
- package/src/native/driver/workletDriver.ts +3860 -0
- package/src/native/motion/AnimatePresence.tsx +1605 -0
- package/src/native/motion/LayoutGroup.tsx +163 -0
- package/src/native/motion/MotionConfig.tsx +400 -0
- package/src/native/motion/MotionRoot.tsx +250 -0
- package/src/native/motion/MotionView.tsx +6761 -0
- package/src/native/motion/addScaleCorrector.ts +97 -0
- package/src/native/motion/colorEndpointUiFeed.ts +32 -0
- package/src/native/motion/colorProjection.ts +39 -0
- package/src/native/motion/composeTransform.ts +271 -0
- package/src/native/motion/constraintMeasure.ts +157 -0
- package/src/native/motion/deferredPendingSnapshots.ts +25 -0
- package/src/native/motion/discreteProjection.ts +36 -0
- package/src/native/motion/dragAncestorPanContext.ts +28 -0
- package/src/native/motion/dragControls.ts +152 -0
- package/src/native/motion/dragGestureWiring.ts +1168 -0
- package/src/native/motion/dragHandoffBinding.ts +179 -0
- package/src/native/motion/dragHubStream.ts +756 -0
- package/src/native/motion/dragPropagationLock.ts +57 -0
- package/src/native/motion/driverValueChannel.ts +3288 -0
- package/src/native/motion/externalDragDriver.ts +214 -0
- package/src/native/motion/frameData.ts +31 -0
- package/src/native/motion/gestureBinding.ts +107 -0
- package/src/native/motion/gestureStateGate.ts +563 -0
- package/src/native/motion/gestureStateResolver.ts +286 -0
- package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
- package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
- package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
- package/src/native/motion/imperativeAnimate.ts +816 -0
- package/src/native/motion/keyframeTiming.ts +7 -0
- package/src/native/motion/layoutIdBinding.ts +261 -0
- package/src/native/motion/layoutIdFlightConfig.ts +51 -0
- package/src/native/motion/layoutProjection.ts +89 -0
- package/src/native/motion/layoutScroll.ts +204 -0
- package/src/native/motion/layoutTransition.ts +1302 -0
- package/src/native/motion/lengthLayoutContext.tsx +100 -0
- package/src/native/motion/lengthLayoutHost.ts +44 -0
- package/src/native/motion/mappedKeys.ts +184 -0
- package/src/native/motion/motionViewController.ts +3180 -0
- package/src/native/motion/nativeHostMarker.ts +22 -0
- package/src/native/motion/panSessionWiring.ts +127 -0
- package/src/native/motion/pathTransition.ts +101 -0
- package/src/native/motion/popLayout.ts +176 -0
- package/src/native/motion/presenceBinding.ts +382 -0
- package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
- package/src/native/motion/scrollValues.ts +260 -0
- package/src/native/motion/serializablePayload.ts +151 -0
- package/src/native/motion/severity.ts +16 -0
- package/src/native/motion/shippedSurface.ts +843 -0
- package/src/native/motion/staticLengthGate.ts +86 -0
- package/src/native/motion/styleBaseGate.ts +72 -0
- package/src/native/motion/styleValueBinding.ts +657 -0
- package/src/native/motion/systemGestureExclusion.ts +61 -0
- package/src/native/motion/tapGestureWiring.ts +215 -0
- package/src/native/motion/transformOrder.ts +38 -0
- package/src/native/motion/transformStringBinding.ts +200 -0
- package/src/native/motion/transformTemplateGate.ts +45 -0
- package/src/native/motion/transitionGate.ts +435 -0
- package/src/native/motion/useAnimate.ts +52 -0
- package/src/native/motion/useCycle.ts +44 -0
- package/src/native/motion/useInstantTransition.ts +68 -0
- package/src/native/motion/useReducedMotion.ts +58 -0
- package/src/native/motion/useScroll.ts +162 -0
- package/src/native/motion/useViewportScroll.ts +29 -0
- package/src/native/motion/valueChannel.ts +7121 -0
- package/src/native/motion/valueHooks.ts +901 -0
- package/src/native/motion/variantChildRegistry.ts +67 -0
- package/src/native/motion/variantContext.tsx +172 -0
- package/src/native/motion/variantProps.ts +598 -0
- package/src/native.ts +159 -0
- package/src/verification/harnessMetrics.ts +78 -0
- package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
- package/src/verification/probe/WorkletParityProbe.tsx +162 -0
- package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
- package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
- package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
- package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
- package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
- package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
- package/src/verification/screens/ConformanceScreen.tsx +573 -0
- package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
- package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
- package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
- package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
- package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
- package/src/verification/screens/GestureChecksScreen.tsx +685 -0
- package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
- package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
- package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
- package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
- package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
- package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
- package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
- package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
- package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
- package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
- package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
- package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
- package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
- package/src/verification/screens/conformanceBanner.ts +21 -0
- package/src/verification/screens/proofConsoleTap.ts +18 -0
- package/src/verification.ts +47 -0
- package/src/web.ts +98 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const NATIVE_MOTION_HOST = Symbol.for('unrulysystems.native-motion.host')
|
|
2
|
+
|
|
3
|
+
type MarkedHost = {
|
|
4
|
+
readonly [NATIVE_MOTION_HOST]?: true
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function markNativeMotionHost<T extends object>(component: T): T {
|
|
8
|
+
Object.defineProperty(component, NATIVE_MOTION_HOST, {
|
|
9
|
+
configurable: false,
|
|
10
|
+
enumerable: false,
|
|
11
|
+
value: true,
|
|
12
|
+
writable: false,
|
|
13
|
+
})
|
|
14
|
+
return component
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isNativeMotionHost(component: unknown): boolean {
|
|
18
|
+
if ((typeof component !== 'function' && typeof component !== 'object') || component === null) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
return (component as MarkedHost)[NATIVE_MOTION_HOST] === true
|
|
22
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// R15 D6 (REQ-API-047 / REQ-GESTURE-029): observation-only pan session callbacks.
|
|
2
|
+
// Mounted when any onPan* is present without a drag surface. Never writes the motion graph.
|
|
3
|
+
|
|
4
|
+
import { describeValue, type DragInfo } from '@unrulysystems/native-motion-core'
|
|
5
|
+
import type { DragPanEventShape } from './dragHandoffBinding'
|
|
6
|
+
|
|
7
|
+
/** RNGH pan event fields the session reads (translation + absolute for point). */
|
|
8
|
+
type PanEventShape = DragPanEventShape & {
|
|
9
|
+
readonly absoluteX?: unknown
|
|
10
|
+
readonly absoluteY?: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PanEventCallback = (event: unknown, info: DragInfo) => void
|
|
14
|
+
// Pin's onPanSessionStart carries EventInfo (point only); we pass the same shape.
|
|
15
|
+
export type PanSessionStartCallback = (
|
|
16
|
+
event: unknown,
|
|
17
|
+
info: { point: { x: number; y: number } },
|
|
18
|
+
) => void
|
|
19
|
+
|
|
20
|
+
export interface PanSessionCallbacks {
|
|
21
|
+
readonly onPanSessionStart?: PanSessionStartCallback | undefined
|
|
22
|
+
readonly onPanStart?: PanEventCallback | undefined
|
|
23
|
+
readonly onPan?: PanEventCallback | undefined
|
|
24
|
+
readonly onPanEnd?: PanEventCallback | undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Pin PanSession activates after ~3px movement (distanceThreshold default).
|
|
28
|
+
export const PAN_ACTIVATION_THRESHOLD_PX = 3
|
|
29
|
+
|
|
30
|
+
function requireNumber(value: unknown, field: string): number {
|
|
31
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`pan session: field '${field}' is ${describeValue(value)} — a finite number is required (REQ-API-047).`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PanSessionWiring {
|
|
40
|
+
handlePanStart(event: PanEventShape): void
|
|
41
|
+
handlePanUpdate(event: PanEventShape): void
|
|
42
|
+
handlePanEnd(event: PanEventShape, success: boolean): void
|
|
43
|
+
handlePanFinalize(event: PanEventShape, success: boolean): void
|
|
44
|
+
dispose(): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPanSessionWiring(callbacks: PanSessionCallbacks): PanSessionWiring {
|
|
48
|
+
let disposed = false
|
|
49
|
+
let sessionStarted = false
|
|
50
|
+
let panActivated = false
|
|
51
|
+
let endFired = false
|
|
52
|
+
let lastTranslation: { x: number; y: number } | null = null
|
|
53
|
+
|
|
54
|
+
function buildInfo(event: PanEventShape): DragInfo {
|
|
55
|
+
const tx = requireNumber(event.translationX, 'translationX')
|
|
56
|
+
const ty = requireNumber(event.translationY, 'translationY')
|
|
57
|
+
const vx = requireNumber(event.velocityX, 'velocityX')
|
|
58
|
+
const vy = requireNumber(event.velocityY, 'velocityY')
|
|
59
|
+
const ax = requireNumber(event.absoluteX, 'absoluteX')
|
|
60
|
+
const ay = requireNumber(event.absoluteY, 'absoluteY')
|
|
61
|
+
const prev = lastTranslation
|
|
62
|
+
return {
|
|
63
|
+
point: { x: ax, y: ay },
|
|
64
|
+
delta: { x: tx - (prev?.x ?? 0), y: ty - (prev?.y ?? 0) },
|
|
65
|
+
offset: { x: tx, y: ty },
|
|
66
|
+
velocity: { x: vx, y: vy },
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reset(): void {
|
|
71
|
+
sessionStarted = false
|
|
72
|
+
panActivated = false
|
|
73
|
+
endFired = false
|
|
74
|
+
lastTranslation = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
handlePanStart(event) {
|
|
79
|
+
if (disposed) return
|
|
80
|
+
reset()
|
|
81
|
+
sessionStarted = true
|
|
82
|
+
const ax = requireNumber(event.absoluteX, 'absoluteX')
|
|
83
|
+
const ay = requireNumber(event.absoluteY, 'absoluteY')
|
|
84
|
+
callbacks.onPanSessionStart?.(event, { point: { x: ax, y: ay } })
|
|
85
|
+
},
|
|
86
|
+
handlePanUpdate(event) {
|
|
87
|
+
if (disposed || !sessionStarted) return
|
|
88
|
+
const info = buildInfo(event)
|
|
89
|
+
lastTranslation = { x: info.offset.x, y: info.offset.y }
|
|
90
|
+
if (!panActivated) {
|
|
91
|
+
const dist = Math.hypot(info.offset.x, info.offset.y)
|
|
92
|
+
if (dist < PAN_ACTIVATION_THRESHOLD_PX) return
|
|
93
|
+
panActivated = true
|
|
94
|
+
callbacks.onPanStart?.(event, info)
|
|
95
|
+
}
|
|
96
|
+
callbacks.onPan?.(event, info)
|
|
97
|
+
},
|
|
98
|
+
handlePanEnd(event, _success) {
|
|
99
|
+
// Pin PanSession: onEnd only when startEvent is set (past distanceThreshold).
|
|
100
|
+
// Pure taps under threshold get session start only — never onPanEnd.
|
|
101
|
+
if (disposed || !sessionStarted || endFired || !panActivated) return
|
|
102
|
+
endFired = true
|
|
103
|
+
try {
|
|
104
|
+
const info = buildInfo(event)
|
|
105
|
+
callbacks.onPanEnd?.(event, info)
|
|
106
|
+
} catch {
|
|
107
|
+
// Malformed end fields — once-gate still holds; finalize will not re-fire.
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
handlePanFinalize(event, _success) {
|
|
111
|
+
if (disposed) return
|
|
112
|
+
if (sessionStarted && panActivated && !endFired) {
|
|
113
|
+
endFired = true
|
|
114
|
+
try {
|
|
115
|
+
callbacks.onPanEnd?.(event, buildInfo(event))
|
|
116
|
+
} catch {
|
|
117
|
+
// Malformed finalize after abort — session is over.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
reset()
|
|
121
|
+
},
|
|
122
|
+
dispose() {
|
|
123
|
+
disposed = true
|
|
124
|
+
reset()
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// U8 (REQ-API-059) — claim x/y from a start/retarget when `transition.path` is `arc()`.
|
|
2
|
+
import { type DriverCommand, type Transition } from '@unrulysystems/native-motion-core'
|
|
3
|
+
import {
|
|
4
|
+
isNativeArcPath,
|
|
5
|
+
resolveTransitionForKey,
|
|
6
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
7
|
+
|
|
8
|
+
function stripPath(transition: Transition): Transition {
|
|
9
|
+
const { path: _path, ...rest } = transition as Transition & { path?: unknown }
|
|
10
|
+
return rest
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function lastNumeric(value: unknown, fallback: number): number {
|
|
14
|
+
if (typeof value === 'number') return value
|
|
15
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
16
|
+
const last = value[value.length - 1]
|
|
17
|
+
if (typeof last === 'number') return last
|
|
18
|
+
}
|
|
19
|
+
return fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function firstNumeric(value: unknown, fallback: number): number {
|
|
23
|
+
if (Array.isArray(value) && typeof value[0] === 'number') return value[0]
|
|
24
|
+
return fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function seedPathRotationKey(
|
|
28
|
+
registered: Record<string, number>,
|
|
29
|
+
transition: Transition | undefined,
|
|
30
|
+
): void {
|
|
31
|
+
const path = (transition as { path?: unknown } | undefined)?.path
|
|
32
|
+
if (!isNativeArcPath(path)) return
|
|
33
|
+
const geometry = path.resolve({ x: 0, y: 0 }, { x: 0, y: 0 })
|
|
34
|
+
if (geometry.rotationScale) registered.pathRotation = 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function rewritePathCommands(
|
|
38
|
+
command: DriverCommand,
|
|
39
|
+
committed: () => Readonly<Record<string, number>>,
|
|
40
|
+
): DriverCommand[] {
|
|
41
|
+
if (command.kind !== 'start' && command.kind !== 'retarget') return [command]
|
|
42
|
+
const rawPath = (command.transition as { path?: unknown } | undefined)?.path
|
|
43
|
+
if (!isNativeArcPath(rawPath)) return [command]
|
|
44
|
+
const hasX = Object.prototype.hasOwnProperty.call(command.targets, 'x')
|
|
45
|
+
const hasY = Object.prototype.hasOwnProperty.call(command.targets, 'y')
|
|
46
|
+
if (!hasX && !hasY) return [command]
|
|
47
|
+
|
|
48
|
+
// Lazy: the committed snapshot is read only once a path command actually claims x/y — an
|
|
49
|
+
// unconditional read here added a second removal-boundary snapshot to every presence exit
|
|
50
|
+
// (REQ-PRESENCE-020's one-read floor).
|
|
51
|
+
const live = committed()
|
|
52
|
+
const liveX = live.x ?? 0
|
|
53
|
+
const liveY = live.y ?? 0
|
|
54
|
+
let fromX = liveX
|
|
55
|
+
let fromY = liveY
|
|
56
|
+
let toX = liveX
|
|
57
|
+
let toY = liveY
|
|
58
|
+
if (command.kind === 'start') {
|
|
59
|
+
const xt = command.targets.x
|
|
60
|
+
const yt = command.targets.y
|
|
61
|
+
if (xt !== undefined) {
|
|
62
|
+
fromX = firstNumeric(xt.to, xt.from)
|
|
63
|
+
toX = lastNumeric(xt.to, fromX)
|
|
64
|
+
}
|
|
65
|
+
if (yt !== undefined) {
|
|
66
|
+
fromY = firstNumeric(yt.to, yt.from)
|
|
67
|
+
toY = lastNumeric(yt.to, fromY)
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
if (hasX) toX = command.targets.x as number
|
|
71
|
+
if (hasY) toY = command.targets.y as number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const geometry = rawPath.resolve({ x: fromX, y: fromY }, { x: toX, y: toY })
|
|
75
|
+
const progressTransition = resolveTransitionForKey(stripPath(command.transition), 'x')
|
|
76
|
+
const restTargets = { ...command.targets } as Record<string, never>
|
|
77
|
+
delete restTargets.x
|
|
78
|
+
delete restTargets.y
|
|
79
|
+
const pathStart: DriverCommand = {
|
|
80
|
+
kind: 'start',
|
|
81
|
+
targets: command.kind === 'start' ? restTargets : {},
|
|
82
|
+
transition: stripPath(command.transition),
|
|
83
|
+
path: { ...geometry, transition: progressTransition },
|
|
84
|
+
...(command.kind === 'start' && command.releasesGesture !== undefined
|
|
85
|
+
? { releasesGesture: command.releasesGesture }
|
|
86
|
+
: {}),
|
|
87
|
+
}
|
|
88
|
+
if (command.kind === 'start') return [pathStart]
|
|
89
|
+
if (Object.keys(restTargets).length === 0) return [pathStart]
|
|
90
|
+
return [
|
|
91
|
+
pathStart,
|
|
92
|
+
{
|
|
93
|
+
kind: 'retarget',
|
|
94
|
+
targets: restTargets as unknown as Record<string, number>,
|
|
95
|
+
transition: stripPath(command.transition),
|
|
96
|
+
...(command.targetTransitions === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { targetTransitions: command.targetTransitions }),
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// T24 P — the native PopChild geometry seam. React Native reports parent-relative layout boxes;
|
|
2
|
+
// the presence renderer captures the last committed one and composes the temporary absolute
|
|
3
|
+
// override on the retained exit clone. No layout math or driver crossing belongs here.
|
|
4
|
+
|
|
5
|
+
export interface PopLayoutRect {
|
|
6
|
+
readonly x: number
|
|
7
|
+
readonly y: number
|
|
8
|
+
readonly width: number
|
|
9
|
+
readonly height: number
|
|
10
|
+
readonly parentWidth: number
|
|
11
|
+
readonly parentHeight: number
|
|
12
|
+
readonly direction: 'ltr' | 'rtl'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type PopLayoutStyle = Readonly<{
|
|
16
|
+
position: 'absolute'
|
|
17
|
+
width: number
|
|
18
|
+
height: number
|
|
19
|
+
left?: number | undefined
|
|
20
|
+
right?: number | undefined
|
|
21
|
+
top?: number | undefined
|
|
22
|
+
bottom?: number | undefined
|
|
23
|
+
}>
|
|
24
|
+
|
|
25
|
+
export interface PopLayoutHostHandle {
|
|
26
|
+
readonly parentElement?: {
|
|
27
|
+
readonly clientWidth?: unknown
|
|
28
|
+
readonly clientHeight?: unknown
|
|
29
|
+
} | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let defaultDirection: 'ltr' | 'rtl' = 'ltr'
|
|
33
|
+
|
|
34
|
+
/** The native public entry binds the platform's inherited writing direction once at startup. */
|
|
35
|
+
export function configurePopLayoutDefaultDirection(direction: 'ltr' | 'rtl'): void {
|
|
36
|
+
defaultDirection = direction
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function authoredDirection(style: unknown): 'ltr' | 'rtl' | undefined {
|
|
40
|
+
if (Array.isArray(style)) {
|
|
41
|
+
for (let index = style.length - 1; index >= 0; index--) {
|
|
42
|
+
const resolved = authoredDirection(style[index])
|
|
43
|
+
if (resolved !== undefined) return resolved
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
if (typeof style !== 'object' || style === null) return undefined
|
|
48
|
+
const direction = (style as { readonly direction?: unknown }).direction
|
|
49
|
+
return direction === 'ltr' || direction === 'rtl' ? direction : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolvePopLayoutDirection(style: unknown): 'ltr' | 'rtl' {
|
|
53
|
+
return authoredDirection(style) ?? defaultDirection
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function describeLayoutField(value: unknown): string {
|
|
57
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
58
|
+
try {
|
|
59
|
+
const serialized = JSON.stringify(value)
|
|
60
|
+
return serialized === undefined ? String(value) : serialized
|
|
61
|
+
} catch {
|
|
62
|
+
return Object.prototype.toString.call(value)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function finiteLayoutField(value: unknown, field: string): number {
|
|
67
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`popLayout onLayout field '${field}' is ${describeLayoutField(value)}; expected a finite number`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parentDimension(value: unknown): number {
|
|
76
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readParentBox(host: PopLayoutHostHandle | null | undefined): {
|
|
80
|
+
readonly parentWidth: number
|
|
81
|
+
readonly parentHeight: number
|
|
82
|
+
} {
|
|
83
|
+
const parent = host?.parentElement
|
|
84
|
+
return {
|
|
85
|
+
parentWidth: parentDimension(parent?.clientWidth),
|
|
86
|
+
parentHeight: parentDimension(parent?.clientHeight),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read one native layout event; absent layout is the pin's unmeasured-child no-op. */
|
|
91
|
+
export function readPopLayoutRect(
|
|
92
|
+
event: unknown,
|
|
93
|
+
host?: PopLayoutHostHandle | null,
|
|
94
|
+
direction: 'ltr' | 'rtl' = 'ltr',
|
|
95
|
+
): PopLayoutRect | undefined {
|
|
96
|
+
if (typeof event !== 'object' || event === null) return undefined
|
|
97
|
+
const nativeEvent = (event as { readonly nativeEvent?: unknown }).nativeEvent
|
|
98
|
+
if (typeof nativeEvent !== 'object' || nativeEvent === null) return undefined
|
|
99
|
+
const layout = (nativeEvent as { readonly layout?: unknown }).layout
|
|
100
|
+
if (layout === undefined) return undefined
|
|
101
|
+
if (typeof layout !== 'object' || layout === null) {
|
|
102
|
+
throw new Error('popLayout onLayout layout must be an object with x, y, width, and height')
|
|
103
|
+
}
|
|
104
|
+
const shape = layout as Record<string, unknown>
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
x: finiteLayoutField(shape['x'], 'x'),
|
|
107
|
+
y: finiteLayoutField(shape['y'], 'y'),
|
|
108
|
+
width: finiteLayoutField(shape['width'], 'width'),
|
|
109
|
+
height: finiteLayoutField(shape['height'], 'height'),
|
|
110
|
+
...readParentBox(host),
|
|
111
|
+
direction,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Refresh the parent box at the removal boundary, matching the pin's snapshot timing. */
|
|
116
|
+
export function refreshPopLayoutRect(
|
|
117
|
+
rect: PopLayoutRect,
|
|
118
|
+
host: PopLayoutHostHandle | null | undefined,
|
|
119
|
+
direction: 'ltr' | 'rtl' = rect.direction,
|
|
120
|
+
): PopLayoutRect {
|
|
121
|
+
return Object.freeze({ ...rect, ...readParentBox(host), direction })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function popLayoutOverride(
|
|
125
|
+
rect: PopLayoutRect | undefined,
|
|
126
|
+
anchorX: 'left' | 'right' = 'left',
|
|
127
|
+
anchorY: 'top' | 'bottom' = 'top',
|
|
128
|
+
): PopLayoutStyle | undefined {
|
|
129
|
+
if (rect === undefined || rect.width === 0 || rect.height === 0) return undefined
|
|
130
|
+
const isRTL = rect.direction === 'rtl'
|
|
131
|
+
const horizontal =
|
|
132
|
+
anchorX === 'left'
|
|
133
|
+
? isRTL
|
|
134
|
+
? { left: undefined, right: rect.parentWidth - rect.width - rect.x }
|
|
135
|
+
: { left: rect.x, right: undefined }
|
|
136
|
+
: isRTL
|
|
137
|
+
? { left: rect.x, right: undefined }
|
|
138
|
+
: { left: undefined, right: rect.parentWidth - rect.width - rect.x }
|
|
139
|
+
const vertical =
|
|
140
|
+
anchorY === 'bottom'
|
|
141
|
+
? { top: undefined, bottom: rect.parentHeight - rect.height - rect.y }
|
|
142
|
+
: { top: rect.y, bottom: undefined }
|
|
143
|
+
return {
|
|
144
|
+
position: 'absolute',
|
|
145
|
+
...horizontal,
|
|
146
|
+
...vertical,
|
|
147
|
+
width: rect.width,
|
|
148
|
+
height: rect.height,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Compose after authored style so the temporary pop box has override precedence. */
|
|
153
|
+
export function composePopLayoutStyle(
|
|
154
|
+
authoredStyle: unknown,
|
|
155
|
+
rect: PopLayoutRect | undefined,
|
|
156
|
+
anchorX: 'left' | 'right' = 'left',
|
|
157
|
+
anchorY: 'top' | 'bottom' = 'top',
|
|
158
|
+
): unknown {
|
|
159
|
+
const override = popLayoutOverride(rect, anchorX, anchorY)
|
|
160
|
+
if (override === undefined) return authoredStyle
|
|
161
|
+
if (authoredStyle === undefined || authoredStyle === null) return override
|
|
162
|
+
return [authoredStyle, override]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function composePopLayoutOnLayout(
|
|
166
|
+
authoredOnLayout: unknown,
|
|
167
|
+
capture: (event: unknown) => void,
|
|
168
|
+
): ((event: unknown) => void) | undefined {
|
|
169
|
+
if (authoredOnLayout !== undefined && typeof authoredOnLayout !== 'function') {
|
|
170
|
+
throw new Error('popLayout onLayout must be a function when supplied')
|
|
171
|
+
}
|
|
172
|
+
return (event: unknown): void => {
|
|
173
|
+
capture(event)
|
|
174
|
+
;(authoredOnLayout as ((event: unknown) => void) | undefined)?.(event)
|
|
175
|
+
}
|
|
176
|
+
}
|