@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +55 -0
  4. package/android/build.gradle +24 -0
  5. package/android/src/main/AndroidManifest.xml +1 -0
  6. package/android/src/main/java/com/unrulysystems/nativemotion/GestureExclusionModule.kt +55 -0
  7. package/android/src/main/java/com/unrulysystems/nativemotion/NativeMotionPackage.kt +27 -0
  8. package/package.json +57 -0
  9. package/react-native.config.js +14 -0
  10. package/src/native/driver/instantWindow.ts +31 -0
  11. package/src/native/driver/strictModeReplay.ts +20 -0
  12. package/src/native/driver/uiLayoutEngine.ts +845 -0
  13. package/src/native/driver/uiLayoutGraph.ts +338 -0
  14. package/src/native/driver/uiValueChannel.ts +4640 -0
  15. package/src/native/driver/workletDriver.ts +3860 -0
  16. package/src/native/motion/AnimatePresence.tsx +1605 -0
  17. package/src/native/motion/LayoutGroup.tsx +163 -0
  18. package/src/native/motion/MotionConfig.tsx +400 -0
  19. package/src/native/motion/MotionRoot.tsx +250 -0
  20. package/src/native/motion/MotionView.tsx +6761 -0
  21. package/src/native/motion/addScaleCorrector.ts +97 -0
  22. package/src/native/motion/colorEndpointUiFeed.ts +32 -0
  23. package/src/native/motion/colorProjection.ts +39 -0
  24. package/src/native/motion/composeTransform.ts +271 -0
  25. package/src/native/motion/constraintMeasure.ts +157 -0
  26. package/src/native/motion/deferredPendingSnapshots.ts +25 -0
  27. package/src/native/motion/discreteProjection.ts +36 -0
  28. package/src/native/motion/dragAncestorPanContext.ts +28 -0
  29. package/src/native/motion/dragControls.ts +152 -0
  30. package/src/native/motion/dragGestureWiring.ts +1168 -0
  31. package/src/native/motion/dragHandoffBinding.ts +179 -0
  32. package/src/native/motion/dragHubStream.ts +756 -0
  33. package/src/native/motion/dragPropagationLock.ts +57 -0
  34. package/src/native/motion/driverValueChannel.ts +3288 -0
  35. package/src/native/motion/externalDragDriver.ts +214 -0
  36. package/src/native/motion/frameData.ts +31 -0
  37. package/src/native/motion/gestureBinding.ts +107 -0
  38. package/src/native/motion/gestureStateGate.ts +563 -0
  39. package/src/native/motion/gestureStateResolver.ts +286 -0
  40. package/src/native/motion/identityValueChannelControllerAdapter.ts +2043 -0
  41. package/src/native/motion/identityValueChannelControllerReconciliation.ts +176 -0
  42. package/src/native/motion/identityValueChannelLaneMarker.ts +14 -0
  43. package/src/native/motion/imperativeAnimate.ts +816 -0
  44. package/src/native/motion/keyframeTiming.ts +7 -0
  45. package/src/native/motion/layoutIdBinding.ts +261 -0
  46. package/src/native/motion/layoutIdFlightConfig.ts +51 -0
  47. package/src/native/motion/layoutProjection.ts +89 -0
  48. package/src/native/motion/layoutScroll.ts +204 -0
  49. package/src/native/motion/layoutTransition.ts +1302 -0
  50. package/src/native/motion/lengthLayoutContext.tsx +100 -0
  51. package/src/native/motion/lengthLayoutHost.ts +44 -0
  52. package/src/native/motion/mappedKeys.ts +184 -0
  53. package/src/native/motion/motionViewController.ts +3180 -0
  54. package/src/native/motion/nativeHostMarker.ts +22 -0
  55. package/src/native/motion/panSessionWiring.ts +127 -0
  56. package/src/native/motion/pathTransition.ts +101 -0
  57. package/src/native/motion/popLayout.ts +176 -0
  58. package/src/native/motion/presenceBinding.ts +382 -0
  59. package/src/native/motion/scaleCorrectorRegistry.ts +123 -0
  60. package/src/native/motion/scrollValues.ts +260 -0
  61. package/src/native/motion/serializablePayload.ts +151 -0
  62. package/src/native/motion/severity.ts +16 -0
  63. package/src/native/motion/shippedSurface.ts +843 -0
  64. package/src/native/motion/staticLengthGate.ts +86 -0
  65. package/src/native/motion/styleBaseGate.ts +72 -0
  66. package/src/native/motion/styleValueBinding.ts +657 -0
  67. package/src/native/motion/systemGestureExclusion.ts +61 -0
  68. package/src/native/motion/tapGestureWiring.ts +215 -0
  69. package/src/native/motion/transformOrder.ts +38 -0
  70. package/src/native/motion/transformStringBinding.ts +200 -0
  71. package/src/native/motion/transformTemplateGate.ts +45 -0
  72. package/src/native/motion/transitionGate.ts +435 -0
  73. package/src/native/motion/useAnimate.ts +52 -0
  74. package/src/native/motion/useCycle.ts +44 -0
  75. package/src/native/motion/useInstantTransition.ts +68 -0
  76. package/src/native/motion/useReducedMotion.ts +58 -0
  77. package/src/native/motion/useScroll.ts +162 -0
  78. package/src/native/motion/useViewportScroll.ts +29 -0
  79. package/src/native/motion/valueChannel.ts +7121 -0
  80. package/src/native/motion/valueHooks.ts +901 -0
  81. package/src/native/motion/variantChildRegistry.ts +67 -0
  82. package/src/native/motion/variantContext.tsx +172 -0
  83. package/src/native/motion/variantProps.ts +598 -0
  84. package/src/native.ts +159 -0
  85. package/src/verification/harnessMetrics.ts +78 -0
  86. package/src/verification/probe/LayoutIdentityWorkletProbe.tsx +251 -0
  87. package/src/verification/probe/WorkletParityProbe.tsx +162 -0
  88. package/src/verification/probe/layoutIdentityProbeEngine.ts +429 -0
  89. package/src/verification/screens/ArcPathChecksScreen.tsx +273 -0
  90. package/src/verification/screens/BooleanAnimateChecksScreen.tsx +358 -0
  91. package/src/verification/screens/ChoreographyGalleryScreen.tsx +1010 -0
  92. package/src/verification/screens/ColorBindingChecksScreen.tsx +568 -0
  93. package/src/verification/screens/CompletionChecksScreen.tsx +294 -0
  94. package/src/verification/screens/ConformanceScreen.tsx +573 -0
  95. package/src/verification/screens/ContentionProbeScreen.tsx +224 -0
  96. package/src/verification/screens/DriverSmokeScreen.tsx +99 -0
  97. package/src/verification/screens/DurationOnlyTweenChecksScreen.tsx +294 -0
  98. package/src/verification/screens/DynamicDragConfigChecksScreen.tsx +237 -0
  99. package/src/verification/screens/FrameDataChecksScreen.tsx +252 -0
  100. package/src/verification/screens/GestureChecksScreen.tsx +685 -0
  101. package/src/verification/screens/InstantTransitionChecksScreen.tsx +839 -0
  102. package/src/verification/screens/LayoutAnimationStartChecksScreen.tsx +648 -0
  103. package/src/verification/screens/LayoutChecksScreen.tsx +822 -0
  104. package/src/verification/screens/LayoutCommitSpikeScreen.tsx +142 -0
  105. package/src/verification/screens/MotionViewChecksScreen.tsx +930 -0
  106. package/src/verification/screens/PresenceChecksScreen.tsx +614 -0
  107. package/src/verification/screens/ReducedMotionChecksScreen.tsx +614 -0
  108. package/src/verification/screens/RestThresholdChecksScreen.tsx +634 -0
  109. package/src/verification/screens/ScaleCorrectorChecksScreen.tsx +425 -0
  110. package/src/verification/screens/SharedLayoutContinuityChecksScreen.tsx +338 -0
  111. package/src/verification/screens/SharedLayoutCrossfadeChecksScreen.tsx +425 -0
  112. package/src/verification/screens/TransitionDefaultSelectionChecksScreen.tsx +514 -0
  113. package/src/verification/screens/ViewportScrollAliasChecksScreen.tsx +303 -0
  114. package/src/verification/screens/conformanceBanner.ts +21 -0
  115. package/src/verification/screens/proofConsoleTap.ts +18 -0
  116. package/src/verification.ts +47 -0
  117. 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
+ }