@unrulysystems/native-motion-web 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 +14 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/package.json +40 -0
- package/src/AnimatePresence.tsx +284 -0
- package/src/LayoutGroup.tsx +94 -0
- package/src/LayoutScrollOffsetProvider.tsx +30 -0
- package/src/MotionConfig.tsx +136 -0
- package/src/MotionLengthLayoutProvider.tsx +18 -0
- package/src/MotionRoot.tsx +13 -0
- package/src/View.tsx +1356 -0
- package/src/animate.ts +39 -0
- package/src/disposition.ts +167 -0
- package/src/dragControls.ts +3 -0
- package/src/errors.ts +30 -0
- package/src/index.ts +157 -0
- package/src/mode.ts +12 -0
- package/src/normalize.ts +324 -0
- package/src/snap.ts +82 -0
- package/src/useAnimate.ts +18 -0
- package/src/useCycle.ts +12 -0
- package/src/useScroll.ts +19 -0
- package/src/webTransition.ts +69 -0
package/src/View.tsx
ADDED
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// The shim's host wrappers: View, Text, and Image are shipped (REQ-API-034 closed set).
|
|
3
|
+
// Thin bindings of the canonical public surface onto motion.div, Text's selected motion.p/span
|
|
4
|
+
// hosts, and Image's motion.img (REQ-WEB-011: the
|
|
5
|
+
// web engine IS motion/react; this file adds normalization and validation, never animation
|
|
6
|
+
// semantics). Targets are validated with core's validateTarget so an invalid target behaves
|
|
7
|
+
// IDENTICALLY on web and native (one API, one behavior); motion props route through the
|
|
8
|
+
// disposition pipeline; unrecognized keys flow to the DOM as plain attributes (they are React
|
|
9
|
+
// infra, not motion props — the typed surface rejects motion-prop typos at compile time).
|
|
10
|
+
import {
|
|
11
|
+
describeValue,
|
|
12
|
+
hostCapabilities,
|
|
13
|
+
resolveInitialOverlay,
|
|
14
|
+
resolveDragConfig,
|
|
15
|
+
resolvedDragAxes,
|
|
16
|
+
targetShapeRefusal,
|
|
17
|
+
validateTargetSnapshot,
|
|
18
|
+
validateVariantEntrySnapshot,
|
|
19
|
+
variantsShapeRefusal,
|
|
20
|
+
type ResolvedDragConfig,
|
|
21
|
+
type Target,
|
|
22
|
+
type TransformTemplate,
|
|
23
|
+
type Transition,
|
|
24
|
+
type TapCallback,
|
|
25
|
+
type VariantEntry,
|
|
26
|
+
type VariantResolver,
|
|
27
|
+
type VariantLabels,
|
|
28
|
+
type VariantsDictionary,
|
|
29
|
+
} from '@unrulysystems/native-motion-core'
|
|
30
|
+
import {
|
|
31
|
+
initialTargetRefusal,
|
|
32
|
+
keyframeTransitionRefusal,
|
|
33
|
+
resolveTransitionForKey,
|
|
34
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
35
|
+
import {
|
|
36
|
+
isValidMotionProp,
|
|
37
|
+
motion,
|
|
38
|
+
type DOMMotionComponents,
|
|
39
|
+
type MotionProps,
|
|
40
|
+
type MotionStyle,
|
|
41
|
+
} from 'motion/react'
|
|
42
|
+
import type {
|
|
43
|
+
MotionNodeFocusHandlers,
|
|
44
|
+
MotionNodeHoverHandlers,
|
|
45
|
+
Target as PinTarget,
|
|
46
|
+
} from 'motion-dom'
|
|
47
|
+
import {
|
|
48
|
+
createContext,
|
|
49
|
+
createElement,
|
|
50
|
+
forwardRef,
|
|
51
|
+
useContext,
|
|
52
|
+
useRef,
|
|
53
|
+
type ComponentRef,
|
|
54
|
+
type ForwardRefExoticComponent,
|
|
55
|
+
type HTMLAttributes,
|
|
56
|
+
type PropsWithoutRef,
|
|
57
|
+
type ReactNode,
|
|
58
|
+
type Ref,
|
|
59
|
+
type RefAttributes,
|
|
60
|
+
} from 'react'
|
|
61
|
+
import { DISPOSITIONS } from './disposition'
|
|
62
|
+
import { MotionWebRejectionError } from './errors'
|
|
63
|
+
import { ambientMode } from './mode'
|
|
64
|
+
import {
|
|
65
|
+
captureLabelFormWithSeverity,
|
|
66
|
+
normalizeComponentProps,
|
|
67
|
+
type MotionComponentId,
|
|
68
|
+
type NormalizeOptions,
|
|
69
|
+
} from './normalize'
|
|
70
|
+
import { snapPointsToDragTransition } from './snap'
|
|
71
|
+
import { mapWebTransitionAliases, type WebTransition } from './webTransition'
|
|
72
|
+
|
|
73
|
+
function transitionForProperty(
|
|
74
|
+
transition: Transition | undefined,
|
|
75
|
+
property: string,
|
|
76
|
+
): Transition | undefined {
|
|
77
|
+
return transition === undefined ? undefined : resolveTransitionForKey(transition, property)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The DOM drag-and-drop handlers (onDrag/onDragStart/onDragEnd) are Omitted so the drag family
|
|
81
|
+
// carries Motion's (event, info) callback shape identically to the native entry — motion/react
|
|
82
|
+
// shadows the same DOM handlers on motion.div for exactly this reason (the type-identity gate
|
|
83
|
+
// requires the drag callbacks to be structurally equal cross-engine).
|
|
84
|
+
type IntrinsicWebTargetMembers = Pick<
|
|
85
|
+
PinTarget,
|
|
86
|
+
'filter' | 'clipPath' | 'boxShadow' | 'backgroundImage'
|
|
87
|
+
>
|
|
88
|
+
type WebTarget = Target & IntrinsicWebTargetMembers
|
|
89
|
+
type WebVariantEntry = WebTarget & { readonly transition?: WebTransition }
|
|
90
|
+
type WebVariantsDictionary = Readonly<Record<string, WebVariantEntry | VariantResolver>>
|
|
91
|
+
|
|
92
|
+
interface ViewBaseProps extends Omit<
|
|
93
|
+
HTMLAttributes<HTMLDivElement>,
|
|
94
|
+
'style' | 'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart'
|
|
95
|
+
> {
|
|
96
|
+
// The universal motion props carry explicit `| undefined` so the cross-entry assignability
|
|
97
|
+
// gate (type-identity.assert.ts) holds under exactOptionalPropertyTypes: the native entry's
|
|
98
|
+
// props tolerate explicit undefined (React pass-through), so the web props must too.
|
|
99
|
+
// R7 (REQ-API-032): the LABEL form is part of the public type on both entries (the identity
|
|
100
|
+
// gate binds the widening) and EXECUTES here — motion/react owns resolution and propagation;
|
|
101
|
+
// the boundary below gates validity at core depth (M3 flipped the deferred posture).
|
|
102
|
+
readonly initial?: WebTarget | VariantLabels | false | undefined
|
|
103
|
+
readonly animate?: WebTarget | VariantLabels | boolean | undefined
|
|
104
|
+
readonly exit?: WebTarget | VariantLabels | undefined
|
|
105
|
+
readonly variants?: VariantsDictionary | WebVariantsDictionary | undefined
|
|
106
|
+
readonly inherit?: boolean | undefined
|
|
107
|
+
readonly transition?: WebTransition | undefined
|
|
108
|
+
// L4 (specs/SPEC-LAYOUT.md): layout projection, DECLARED and forwarded to real
|
|
109
|
+
// motion/react — the web engine does the projection. Entry shapes are gated below under the
|
|
110
|
+
// severity law; malformed values never reach motion as raw TypeErrors.
|
|
111
|
+
readonly layout?: boolean | 'position' | 'size' | 'preserve-aspect'
|
|
112
|
+
readonly layoutId?: string
|
|
113
|
+
// R11 M2 (REQ-LAYOUT-022 / REQ-API-038): scroll-offset measurement root — forwarded to the
|
|
114
|
+
// pin's projection node; web never re-implements projection.
|
|
115
|
+
readonly layoutScroll?: boolean
|
|
116
|
+
// motion's own style law (r15 d32a7c1e94b6): per-key MotionValue bindings are part of the
|
|
117
|
+
// supported public surface — CSSProperties rejected them and made universal code web-fail.
|
|
118
|
+
readonly style?: MotionStyle
|
|
119
|
+
// The tap family (R5, REQ-API-030): core's shared TapCallback — identical on both entries
|
|
120
|
+
// (the DragInfo precedent); PASSed through to motion's own recognizers (disposition table).
|
|
121
|
+
readonly onTap?: TapCallback
|
|
122
|
+
readonly onTapStart?: TapCallback
|
|
123
|
+
readonly onTapCancel?: TapCallback
|
|
124
|
+
// The gesture-state pair (R6, REQ-API-031; R7 REQ-API-032 adds the label form) —
|
|
125
|
+
// structurally identical to the native entry (the type-identity gate binds it). Labels
|
|
126
|
+
// validate string-only at the normalize boundary and execute through motion's own
|
|
127
|
+
// variants machinery.
|
|
128
|
+
readonly whileTap?:
|
|
129
|
+
| (WebTarget & { readonly transition?: WebTransition })
|
|
130
|
+
| VariantLabels
|
|
131
|
+
| undefined
|
|
132
|
+
readonly whileDrag?:
|
|
133
|
+
| (WebTarget & { readonly transition?: WebTransition })
|
|
134
|
+
| VariantLabels
|
|
135
|
+
| undefined
|
|
136
|
+
readonly whileHover?: MotionNodeHoverHandlers['whileHover'] | undefined
|
|
137
|
+
readonly whileFocus?: MotionNodeFocusHandlers['whileFocus'] | undefined
|
|
138
|
+
readonly onHoverStart?: (...args: unknown[]) => void
|
|
139
|
+
readonly onHoverEnd?: (...args: unknown[]) => void
|
|
140
|
+
readonly onAnimationComplete?: ((definition: unknown) => void) | undefined
|
|
141
|
+
readonly onLayoutAnimationComplete?: (() => void) | undefined
|
|
142
|
+
// T23 C (specs/T23-MIX-DISCRETE-BUILD-PACKET.md): per-frame latest-values callback — DECLARED
|
|
143
|
+
// and forwarded to real motion/react, which owns the frame loop and payload on web. The payload
|
|
144
|
+
// type is the shared cross-entry shape (string | number leaves keyed by public prop name); the
|
|
145
|
+
// type-identity gate binds it to the native entry's projection-lane member.
|
|
146
|
+
readonly onUpdate?: ((latest: Readonly<Record<string, string | number>>) => void) | undefined
|
|
147
|
+
// T17 (specs/T17-TRANSFORM-TEMPLATE-BUILD-PACKET.md): the pin's per-frame transform template.
|
|
148
|
+
// Core's shared TransformTemplate type — identical on both entries by construction (the
|
|
149
|
+
// TapCallback precedent); forwarded to motion/react, which owns the per-frame call. The
|
|
150
|
+
// 'worklet' directive the native mount gate requires is inert on web.
|
|
151
|
+
readonly transformTemplate?: TransformTemplate | undefined
|
|
152
|
+
// T24 B1: the dynamic-variants custom payload — forwarded to motion/react, which owns
|
|
153
|
+
// resolver invocation on this engine (`unknown` on both entries by construction).
|
|
154
|
+
readonly custom?: unknown
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Standard drag props remain the pin's exact web surface. Native Motion's `dragSnapPoints`
|
|
158
|
+
// extension is the only overlay: it remains axis-discriminated because the pin's single
|
|
159
|
+
// `modifyTarget` callback cannot represent independent two-axis snap arrays.
|
|
160
|
+
type PinnedWebDragPropKeys =
|
|
161
|
+
| 'drag'
|
|
162
|
+
| 'dragConstraints'
|
|
163
|
+
| 'dragElastic'
|
|
164
|
+
| 'dragPropagation'
|
|
165
|
+
| 'dragControls'
|
|
166
|
+
| 'dragListener'
|
|
167
|
+
| 'onMeasureDragConstraints'
|
|
168
|
+
| 'onDragStart'
|
|
169
|
+
| 'onDrag'
|
|
170
|
+
| 'onDragEnd'
|
|
171
|
+
| 'dragDirectionLock'
|
|
172
|
+
| 'onDirectionLock'
|
|
173
|
+
| 'dragMomentum'
|
|
174
|
+
| 'dragTransition'
|
|
175
|
+
| 'dragSnapToOrigin'
|
|
176
|
+
| '_dragX'
|
|
177
|
+
| '_dragY'
|
|
178
|
+
|
|
179
|
+
type PinnedWebDragProps = Pick<MotionProps, PinnedWebDragPropKeys>
|
|
180
|
+
type NonEmptyNumberArray = readonly [number, ...number[]]
|
|
181
|
+
type WebDragSnapExtension =
|
|
182
|
+
| { readonly dragSnapPoints?: undefined }
|
|
183
|
+
| {
|
|
184
|
+
readonly drag: 'x'
|
|
185
|
+
readonly dragSnapPoints: {
|
|
186
|
+
readonly x: NonEmptyNumberArray
|
|
187
|
+
readonly y?: never
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
| {
|
|
191
|
+
readonly drag: 'y'
|
|
192
|
+
readonly dragSnapPoints: {
|
|
193
|
+
readonly y: NonEmptyNumberArray
|
|
194
|
+
readonly x?: never
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type PinnedWebViewRemainderKeys =
|
|
199
|
+
| 'onAnimationStart'
|
|
200
|
+
| 'onBeforeLayoutMeasure'
|
|
201
|
+
| 'onLayoutMeasure'
|
|
202
|
+
| 'onLayoutAnimationStart'
|
|
203
|
+
| 'onDragTransitionEnd'
|
|
204
|
+
| 'onPanSessionStart'
|
|
205
|
+
| 'onPanStart'
|
|
206
|
+
| 'onPan'
|
|
207
|
+
| 'onPanEnd'
|
|
208
|
+
| 'globalTapTarget'
|
|
209
|
+
| 'whileInView'
|
|
210
|
+
| 'onViewportEnter'
|
|
211
|
+
| 'onViewportLeave'
|
|
212
|
+
| 'viewport'
|
|
213
|
+
| 'layoutDependency'
|
|
214
|
+
| 'layoutRoot'
|
|
215
|
+
| 'layoutAnchor'
|
|
216
|
+
| 'layoutCrossfade'
|
|
217
|
+
| 'ignoreStrict'
|
|
218
|
+
| 'values'
|
|
219
|
+
| 'propagate'
|
|
220
|
+
| 'data-framer-portal-id'
|
|
221
|
+
| 'data-framer-appear-id'
|
|
222
|
+
|
|
223
|
+
type PinnedWebViewRemainder = Pick<MotionProps, PinnedWebViewRemainderKeys>
|
|
224
|
+
|
|
225
|
+
export type ViewProps = ViewBaseProps &
|
|
226
|
+
PinnedWebDragProps &
|
|
227
|
+
WebDragSnapExtension &
|
|
228
|
+
PinnedWebViewRemainder
|
|
229
|
+
|
|
230
|
+
// The web host declares every registry class the pinned DOM engine actually supports. Portable
|
|
231
|
+
// members still ride core validation; intrinsic-web complex effects bypass Native Motion's value
|
|
232
|
+
// normalizer and remain owned by motion/react (REQ-WEB-019).
|
|
233
|
+
const WEB_HOST = hostCapabilities('web', ['universal', 'web-only', 'pointer-gated'])
|
|
234
|
+
const INTRINSIC_WEB_TARGET_KEYS = new Set<string>([
|
|
235
|
+
'filter',
|
|
236
|
+
'clipPath',
|
|
237
|
+
'boxShadow',
|
|
238
|
+
'backgroundImage',
|
|
239
|
+
])
|
|
240
|
+
|
|
241
|
+
function isIntrinsicWebTargetKey(key: string): boolean {
|
|
242
|
+
return INTRINSIC_WEB_TARGET_KEYS.has(key)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateWebVariantEntrySnapshot(
|
|
246
|
+
label: string,
|
|
247
|
+
entry: unknown,
|
|
248
|
+
componentId: MotionComponentId,
|
|
249
|
+
): ReturnType<typeof validateVariantEntrySnapshot> {
|
|
250
|
+
if (typeof entry === 'function' || typeof entry !== 'object' || entry === null) {
|
|
251
|
+
return validateVariantEntrySnapshot(label, entry, WEB_HOST, { componentId })
|
|
252
|
+
}
|
|
253
|
+
const prototype = Object.getPrototypeOf(entry)
|
|
254
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
255
|
+
return validateVariantEntrySnapshot(label, entry, WEB_HOST, { componentId })
|
|
256
|
+
}
|
|
257
|
+
const portable: Record<string, unknown> = Object.create(null)
|
|
258
|
+
const intrinsic: Record<string, unknown> = Object.create(null)
|
|
259
|
+
let hasTransition = false
|
|
260
|
+
let transition: unknown
|
|
261
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
262
|
+
if (key === 'transition') {
|
|
263
|
+
hasTransition = true
|
|
264
|
+
transition = value
|
|
265
|
+
} else if (isIntrinsicWebTargetKey(key)) intrinsic[key] = value
|
|
266
|
+
else portable[key] = value
|
|
267
|
+
}
|
|
268
|
+
const verdict = validateVariantEntrySnapshot(label, portable, WEB_HOST, { componentId })
|
|
269
|
+
if (verdict.refusal !== null || typeof verdict.entry === 'function' || verdict.entry === null) {
|
|
270
|
+
return verdict
|
|
271
|
+
}
|
|
272
|
+
const accepted = Object.assign(Object.create(null), verdict.entry, intrinsic) as Record<
|
|
273
|
+
string,
|
|
274
|
+
unknown
|
|
275
|
+
>
|
|
276
|
+
if (hasTransition) accepted['transition'] = transition
|
|
277
|
+
return {
|
|
278
|
+
refusal: null,
|
|
279
|
+
entry: accepted as VariantEntry,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Component-level motion props DECLARED on ViewProps that live outside the disposition table's
|
|
284
|
+
// registry domain (they are lifecycle plumbing, not animatable capabilities). Anything motion
|
|
285
|
+
// would interpret that is neither here nor in the table is an undeclared capability and fails
|
|
286
|
+
// loud below.
|
|
287
|
+
const DECLARED_COMPONENT_PROPS = new Set([
|
|
288
|
+
'onAnimationComplete',
|
|
289
|
+
'onLayoutAnimationComplete',
|
|
290
|
+
// T23 C: motion's own per-frame latest callback — same lifecycle-plumbing lane.
|
|
291
|
+
'onUpdate',
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
// Motion owns actual label resolution and propagation. The shim carries only the accepted label
|
|
295
|
+
// selections so each descendant can validate its OWN dictionary against its OWN transition before
|
|
296
|
+
// handing the original (or production-sanitized) label to Motion. This prevents a parent-internal
|
|
297
|
+
// sanitized alias from becoming an observable propagated label at the next dictionary boundary.
|
|
298
|
+
interface VariantTimingSelections {
|
|
299
|
+
readonly initial: readonly string[] | false | undefined
|
|
300
|
+
readonly animate: readonly string[] | undefined
|
|
301
|
+
readonly exit: readonly string[] | undefined
|
|
302
|
+
readonly whileTap: readonly string[] | undefined
|
|
303
|
+
readonly whileDrag: readonly string[] | undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const EMPTY_VARIANT_TIMING_SELECTIONS: VariantTimingSelections = {
|
|
307
|
+
initial: undefined,
|
|
308
|
+
animate: undefined,
|
|
309
|
+
exit: undefined,
|
|
310
|
+
whileTap: undefined,
|
|
311
|
+
whileDrag: undefined,
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const VariantTimingContext = createContext<VariantTimingSelections>(EMPTY_VARIANT_TIMING_SELECTIONS)
|
|
315
|
+
|
|
316
|
+
// A Text host marks its rendered descendant tree. Public View consumes that marker at its own
|
|
317
|
+
// render boundary so wrappers are judged by what they render, never by predictive invocation.
|
|
318
|
+
const TextNestingContext = createContext<NormalizeOptions | null>(null)
|
|
319
|
+
|
|
320
|
+
function componentDragError(
|
|
321
|
+
componentId: MotionComponentId,
|
|
322
|
+
error: unknown,
|
|
323
|
+
): MotionWebRejectionError {
|
|
324
|
+
const message = error instanceof Error ? error.message : describeValue(error)
|
|
325
|
+
return new MotionWebRejectionError(`${componentId}: ${message}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// CORE owns the drag verdict, while each public host owns the public diagnostic identity. Keeping
|
|
329
|
+
// that identity mandatory at this adapter prevents a new Text lane from reporting anonymously.
|
|
330
|
+
function resolveDragConfigForComponent(
|
|
331
|
+
input: Parameters<typeof resolveDragConfig>[0],
|
|
332
|
+
options: NormalizeOptions,
|
|
333
|
+
componentId: MotionComponentId,
|
|
334
|
+
): ResolvedDragConfig | null {
|
|
335
|
+
try {
|
|
336
|
+
return resolveDragConfig(input, options.mode, (error) =>
|
|
337
|
+
options.report(componentDragError(componentId, error)),
|
|
338
|
+
)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const rejection = componentDragError(componentId, error)
|
|
341
|
+
if (options.mode === 'development') throw rejection
|
|
342
|
+
options.report(rejection)
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Validate a Target with the ratified severity law. Development: core's validateTarget throws
|
|
348
|
+
// through untouched (its InvalidTargetError names component, key, value, reason). Production:
|
|
349
|
+
// validated PER KEY so only the offending property is refused — the catch below IS the ratified
|
|
350
|
+
// production error channel ("report through the error channel and refuse the property", BRIEF
|
|
351
|
+
// Decisions 2026-07-06), not suppression: every failure is reported, the property never applies.
|
|
352
|
+
// Exported for the presence container (round 12 major 38): the container gates its children's
|
|
353
|
+
// targets through the SAME lane the View uses, so failure categories and ordering mirror the
|
|
354
|
+
// native container exactly (REQ-PRESENCE-019).
|
|
355
|
+
// The transition counterpart of validateWithSeverity (r7 major 41b089c8c0af): core
|
|
356
|
+
// validateTransition is the single authority (shape, vocabulary, ranges, the derived-config
|
|
357
|
+
// probe); the severity split mirrors the target lane exactly.
|
|
358
|
+
export function gateTransitionWithSeverity(
|
|
359
|
+
value: WebTransition,
|
|
360
|
+
options: NormalizeOptions,
|
|
361
|
+
componentId: string = '<View>',
|
|
362
|
+
): WebTransition | undefined {
|
|
363
|
+
try {
|
|
364
|
+
return mapWebTransitionAliases(value, componentId)
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!(error instanceof MotionWebRejectionError)) throw error
|
|
367
|
+
if (options.mode === 'development') throw error
|
|
368
|
+
options.report(error)
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function validateWithSeverity(
|
|
374
|
+
target: Target,
|
|
375
|
+
propName: string,
|
|
376
|
+
options: NormalizeOptions,
|
|
377
|
+
transition: Transition | undefined = undefined,
|
|
378
|
+
componentId: MotionComponentId = '<View>',
|
|
379
|
+
): Target {
|
|
380
|
+
return validateWithSeverityResult(target, propName, options, transition, false, componentId)
|
|
381
|
+
.target
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface SeverityTargetResult {
|
|
385
|
+
readonly target: Target
|
|
386
|
+
/** Keys that reached the effective-transition predicate from the captured target snapshot. */
|
|
387
|
+
readonly attemptedKeys: readonly string[]
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Keep direct-target ownership on the same one-read snapshot that crosses into motion/react. A
|
|
391
|
+
// property that passed target validation but was then refused by its effective timing transition is
|
|
392
|
+
// an attempted fallback owner; an absent or malformed target is not. This distinction prevents an
|
|
393
|
+
// inert element transition from surviving a reported per-property refusal without turning a truly
|
|
394
|
+
// ownerless transition into a silent no-op.
|
|
395
|
+
export function validateWithSeverityResult(
|
|
396
|
+
target: WebTarget,
|
|
397
|
+
propName: string,
|
|
398
|
+
options: NormalizeOptions,
|
|
399
|
+
transition: Transition | undefined = undefined,
|
|
400
|
+
initialOnly = false,
|
|
401
|
+
componentId: MotionComponentId = '<View>',
|
|
402
|
+
): SeverityTargetResult {
|
|
403
|
+
const shapeRefusal = targetShapeRefusal(target, { componentId })
|
|
404
|
+
if (shapeRefusal !== null) {
|
|
405
|
+
if (options.mode === 'development') throw shapeRefusal
|
|
406
|
+
options.report(
|
|
407
|
+
new MotionWebRejectionError(`${propName} refused: ${shapeRefusal.message}`, {
|
|
408
|
+
cause: shapeRefusal,
|
|
409
|
+
}),
|
|
410
|
+
)
|
|
411
|
+
return { target: {} as Target, attemptedKeys: [] }
|
|
412
|
+
}
|
|
413
|
+
if (options.mode === 'development') {
|
|
414
|
+
// validateTargetSnapshot shape-gates first (round 9): a malformed non-object target throws the
|
|
415
|
+
// typed InvalidTargetError here, never a raw TypeError out of Object.keys. It also materializes
|
|
416
|
+
// each keyframe ARRAY member ONCE and RETURNS that snapshot (R8, review major 23): dev forwards
|
|
417
|
+
// the snapshot, not the caller's array, so motion/react's later read never re-fires an accessor.
|
|
418
|
+
const accepted: Record<string, unknown> = Object.create(null)
|
|
419
|
+
const attemptedKeys: string[] = []
|
|
420
|
+
for (const [key, rawValue] of Object.entries(target)) {
|
|
421
|
+
if (isIntrinsicWebTargetKey(key)) {
|
|
422
|
+
accepted[key] = rawValue
|
|
423
|
+
attemptedKeys.push(key)
|
|
424
|
+
continue
|
|
425
|
+
}
|
|
426
|
+
const { refusal, target: snapshot } = validateTargetSnapshot({ [key]: rawValue }, WEB_HOST, {
|
|
427
|
+
componentId,
|
|
428
|
+
})
|
|
429
|
+
if (refusal !== null) throw refusal
|
|
430
|
+
const value = (snapshot as Record<string, unknown>)[key]
|
|
431
|
+
const initialRefusal = initialOnly ? initialTargetRefusal(componentId, key, value) : null
|
|
432
|
+
if (initialRefusal !== null) throw initialRefusal
|
|
433
|
+
const timingRefusal = keyframeTransitionRefusal(
|
|
434
|
+
componentId,
|
|
435
|
+
key,
|
|
436
|
+
value,
|
|
437
|
+
transitionForProperty(transition, key),
|
|
438
|
+
)
|
|
439
|
+
if (timingRefusal !== null) throw timingRefusal
|
|
440
|
+
accepted[key] = value
|
|
441
|
+
attemptedKeys.push(key)
|
|
442
|
+
}
|
|
443
|
+
return { target: accepted as Target, attemptedKeys }
|
|
444
|
+
}
|
|
445
|
+
// Whole-target shape gate for the production lane (round 9 major 31): Object.entries below
|
|
446
|
+
// coerced numbers/booleans to the empty target with NO report and raw-TypeErrored on null —
|
|
447
|
+
// a malformed shape is refused AS A UNIT through the ratified error channel. RETURN-shaped,
|
|
448
|
+
// no catch (M3 r8 major b9eb82c0d429): only core's returned refusals are reported and
|
|
449
|
+
// dropped; a dependency fault thrown through validation keeps its exact identity.
|
|
450
|
+
const kept: Record<string, unknown> = {}
|
|
451
|
+
const attemptedKeys: string[] = []
|
|
452
|
+
for (const [key, value] of Object.entries(target)) {
|
|
453
|
+
if (isIntrinsicWebTargetKey(key)) {
|
|
454
|
+
kept[key] = value
|
|
455
|
+
attemptedKeys.push(key)
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
// validateTargetSnapshot materializes the member's array ONCE before validation and returns it;
|
|
459
|
+
// the KEPT value is that snapshot, never the caller's raw array (one read, one truth — R8,
|
|
460
|
+
// review major 23), so motion/react never re-reads an accessor and lands an unvalidated value.
|
|
461
|
+
const { refusal, target: snapshot } = validateTargetSnapshot({ [key]: value }, WEB_HOST, {
|
|
462
|
+
componentId,
|
|
463
|
+
})
|
|
464
|
+
if (refusal !== null) {
|
|
465
|
+
options.report(
|
|
466
|
+
new MotionWebRejectionError(`${propName}.${key} refused: ${refusal.message}`, {
|
|
467
|
+
cause: refusal,
|
|
468
|
+
}),
|
|
469
|
+
)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
const valueSnapshot = (snapshot as Record<string, unknown>)[key]
|
|
473
|
+
attemptedKeys.push(key)
|
|
474
|
+
const initialRefusal = initialOnly
|
|
475
|
+
? initialTargetRefusal(componentId, key, valueSnapshot)
|
|
476
|
+
: null
|
|
477
|
+
if (initialRefusal !== null) {
|
|
478
|
+
options.report(
|
|
479
|
+
new MotionWebRejectionError(`${propName}.${key} refused: ${initialRefusal.message}`, {
|
|
480
|
+
cause: initialRefusal,
|
|
481
|
+
}),
|
|
482
|
+
)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
const timingRefusal = keyframeTransitionRefusal(
|
|
486
|
+
componentId,
|
|
487
|
+
key,
|
|
488
|
+
valueSnapshot,
|
|
489
|
+
transitionForProperty(transition, key),
|
|
490
|
+
)
|
|
491
|
+
if (timingRefusal !== null) {
|
|
492
|
+
options.report(
|
|
493
|
+
new MotionWebRejectionError(`${propName}.${key} refused: ${timingRefusal.message}`, {
|
|
494
|
+
cause: timingRefusal,
|
|
495
|
+
}),
|
|
496
|
+
)
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
kept[key] = valueSnapshot
|
|
500
|
+
}
|
|
501
|
+
return { target: kept as Target, attemptedKeys }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// R7 (REQ-API-032 law f): the web dictionary gate — outer shape AS A UNIT, every entry
|
|
505
|
+
// eagerly through core's snapshot validator over the WEB host (core validity + the full
|
|
506
|
+
// transition vocabulary; the native-only drivability/option laws stay native — this engine
|
|
507
|
+
// drives units and executes delay/velocity). Development throws typed; production reports
|
|
508
|
+
// and refuses the offending UNIT: the whole dictionary for an outer-shape offense, the
|
|
509
|
+
// single ENTRY inside it. RETURN-shaped, no catch (M3 r3 majors 1b53e9337aec/13af48a64530):
|
|
510
|
+
// the shape probe and the entry READ phase run foreign traps unguarded — faults, including
|
|
511
|
+
// authentic same-class sentinels and replayed refusals, propagate untouched by
|
|
512
|
+
// construction. The accumulator is null-prototype so an own '__proto__' label can never
|
|
513
|
+
// pollute; the accepted value is the SNAPSHOT (one read, one truth) — resolution against
|
|
514
|
+
// it is motion/react's (REQ-WEB-011).
|
|
515
|
+
function gateVariantsWithSeverity(
|
|
516
|
+
value: unknown,
|
|
517
|
+
options: NormalizeOptions,
|
|
518
|
+
componentId: MotionComponentId = '<View>',
|
|
519
|
+
): VariantsDictionary | undefined {
|
|
520
|
+
const shapeRefusal = variantsShapeRefusal(value)
|
|
521
|
+
if (shapeRefusal !== null) {
|
|
522
|
+
const ownedRefusal = new MotionWebRejectionError(`${componentId}: ${shapeRefusal.message}`, {
|
|
523
|
+
cause: shapeRefusal,
|
|
524
|
+
})
|
|
525
|
+
if (options.mode === 'development') throw ownedRefusal
|
|
526
|
+
options.report(ownedRefusal)
|
|
527
|
+
return undefined
|
|
528
|
+
}
|
|
529
|
+
const accepted = Object.create(null) as Record<string, VariantEntry | VariantResolver>
|
|
530
|
+
for (const [label, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
531
|
+
const { refusal, entry: snapshot } = validateWebVariantEntrySnapshot(label, entry, componentId)
|
|
532
|
+
if (refusal !== null) {
|
|
533
|
+
if (options.mode === 'development') throw refusal
|
|
534
|
+
options.report(new MotionWebRejectionError(refusal.message, { cause: refusal }))
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
// T24 B2: a resolver entry is accepted by identity. Static entries retain pin-owned nested
|
|
538
|
+
// transitions; only Native Motion's easing aliases are mapped before the pin receives them.
|
|
539
|
+
if (typeof snapshot === 'function' || snapshot === null) {
|
|
540
|
+
accepted[label] = snapshot!
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
const rawTransition = (snapshot as Record<string, unknown>)['transition']
|
|
544
|
+
if (rawTransition === undefined) {
|
|
545
|
+
accepted[label] = snapshot
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
const mappedTransition = gateTransitionWithSeverity(
|
|
549
|
+
rawTransition as WebTransition,
|
|
550
|
+
options,
|
|
551
|
+
`${componentId}.variants.${label}`,
|
|
552
|
+
)
|
|
553
|
+
if (mappedTransition === undefined) continue
|
|
554
|
+
accepted[label] =
|
|
555
|
+
mappedTransition === rawTransition
|
|
556
|
+
? snapshot
|
|
557
|
+
: (Object.assign(Object.create(null), snapshot, {
|
|
558
|
+
transition: mappedTransition,
|
|
559
|
+
}) as VariantEntry)
|
|
560
|
+
}
|
|
561
|
+
return accepted as VariantsDictionary
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function labelNames(value: unknown): readonly string[] | undefined {
|
|
565
|
+
if (typeof value === 'string') return [value]
|
|
566
|
+
if (Array.isArray(value) && value.every((member) => typeof member === 'string')) {
|
|
567
|
+
return value as readonly string[]
|
|
568
|
+
}
|
|
569
|
+
return undefined
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Only labels selected by an animation-time supplier consume the element transition. `initial`
|
|
573
|
+
// resolves synchronously and deliberately does not appear here: its scalar first-paint target is
|
|
574
|
+
// legal beside a keyframed animate label using the same transition.
|
|
575
|
+
function gateVariantTimingConsumers(
|
|
576
|
+
dictionary: VariantsDictionary | undefined,
|
|
577
|
+
labels: readonly string[],
|
|
578
|
+
initialLabels: readonly string[] | undefined,
|
|
579
|
+
elementTransition: Transition | undefined,
|
|
580
|
+
options: NormalizeOptions,
|
|
581
|
+
componentId: MotionComponentId = '<View>',
|
|
582
|
+
): {
|
|
583
|
+
readonly dictionary: VariantsDictionary | undefined
|
|
584
|
+
readonly filteredLabels: ReadonlySet<string>
|
|
585
|
+
readonly initialProjection: VariantsDictionary | undefined
|
|
586
|
+
readonly initialFilteredLabels: ReadonlySet<string>
|
|
587
|
+
readonly hasElementTransitionOwner: boolean
|
|
588
|
+
readonly attemptedElementTransitionOwner: boolean
|
|
589
|
+
} {
|
|
590
|
+
if (
|
|
591
|
+
dictionary === undefined ||
|
|
592
|
+
(elementTransition === undefined && initialLabels === undefined) ||
|
|
593
|
+
(labels.length === 0 && initialLabels === undefined)
|
|
594
|
+
)
|
|
595
|
+
return {
|
|
596
|
+
dictionary,
|
|
597
|
+
filteredLabels: new Set(),
|
|
598
|
+
initialProjection: dictionary,
|
|
599
|
+
initialFilteredLabels: new Set(),
|
|
600
|
+
hasElementTransitionOwner: false,
|
|
601
|
+
attemptedElementTransitionOwner: false,
|
|
602
|
+
}
|
|
603
|
+
const selected = new Set(labels)
|
|
604
|
+
const selectedInitial = new Set(initialLabels)
|
|
605
|
+
const accepted = Object.create(null) as Record<string, VariantEntry | VariantResolver>
|
|
606
|
+
const filteredLabels = new Set<string>()
|
|
607
|
+
const initialProjection = Object.create(null) as Record<string, VariantEntry>
|
|
608
|
+
const initialFilteredLabels = new Set<string>()
|
|
609
|
+
let hasElementTransitionOwner = false
|
|
610
|
+
let attemptedElementTransitionOwner = false
|
|
611
|
+
for (const [label, entry] of Object.entries(dictionary)) {
|
|
612
|
+
if (typeof entry === 'function') {
|
|
613
|
+
// T24 B2: a RESOLVER entry's target keys are unknowable before Motion invokes it at
|
|
614
|
+
// activation, so the per-key initial/timing sanitization below cannot apply — forward
|
|
615
|
+
// it untouched (the underlying motion/react owns dynamic resolution on this engine).
|
|
616
|
+
// Unreachable until the entry boundary accepts the form (validateVariantEntrySnapshot).
|
|
617
|
+
accepted[label] = entry
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
const consumesInitial = selectedInitial.has(label)
|
|
621
|
+
const consumesTiming = selected.has(label) && entry.transition === undefined
|
|
622
|
+
if (!consumesInitial && !consumesTiming) {
|
|
623
|
+
accepted[label] = entry
|
|
624
|
+
continue
|
|
625
|
+
}
|
|
626
|
+
const targetEntries = Object.entries(entry).filter(([key]) => key !== 'transition')
|
|
627
|
+
const initial = Object.create(null) as Record<string, unknown>
|
|
628
|
+
let initialChanged = false
|
|
629
|
+
const filtered = Object.create(null) as Record<string, unknown>
|
|
630
|
+
let timingChanged = false
|
|
631
|
+
// The selected entry has handed these properties to the element transition. Preserve the
|
|
632
|
+
// attempted-vs-accepted distinction after filtering: a rejected scalar owns its one report,
|
|
633
|
+
// while only a retained property can keep the transition executable.
|
|
634
|
+
if (consumesTiming && targetEntries.length > 0) attemptedElementTransitionOwner = true
|
|
635
|
+
for (const [key, target] of targetEntries) {
|
|
636
|
+
if (isIntrinsicWebTargetKey(key)) {
|
|
637
|
+
if (consumesInitial) initial[key] = target
|
|
638
|
+
if (consumesTiming) filtered[key] = target
|
|
639
|
+
continue
|
|
640
|
+
}
|
|
641
|
+
const initialRefusal = consumesInitial ? initialTargetRefusal(componentId, key, target) : null
|
|
642
|
+
const timingRefusal = consumesTiming
|
|
643
|
+
? keyframeTransitionRefusal(
|
|
644
|
+
componentId,
|
|
645
|
+
key,
|
|
646
|
+
target,
|
|
647
|
+
transitionForProperty(elementTransition, key),
|
|
648
|
+
)
|
|
649
|
+
: null
|
|
650
|
+
if (initialRefusal !== null) {
|
|
651
|
+
const error = new MotionWebRejectionError(initialRefusal.message, { cause: initialRefusal })
|
|
652
|
+
if (options.mode === 'development') throw error
|
|
653
|
+
options.report(error)
|
|
654
|
+
initialChanged = true
|
|
655
|
+
} else if (consumesInitial) {
|
|
656
|
+
initial[key] = target
|
|
657
|
+
}
|
|
658
|
+
if (timingRefusal !== null) {
|
|
659
|
+
const error = new MotionWebRejectionError(timingRefusal.message, { cause: timingRefusal })
|
|
660
|
+
if (options.mode === 'development') throw error
|
|
661
|
+
options.report(error)
|
|
662
|
+
timingChanged = true
|
|
663
|
+
} else if (consumesTiming) {
|
|
664
|
+
filtered[key] = target
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (consumesInitial) {
|
|
668
|
+
initialProjection[label] = initial as VariantEntry
|
|
669
|
+
if (initialChanged) initialFilteredLabels.add(label)
|
|
670
|
+
}
|
|
671
|
+
if (!consumesTiming) {
|
|
672
|
+
accepted[label] = entry
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
if (timingChanged) {
|
|
676
|
+
// Motion already resolves this exact label (including inherited selections). Sanitise its
|
|
677
|
+
// original dictionary entry instead of forwarding a synthetic alias as a local prop: aliases
|
|
678
|
+
// change Motion's controlling/propagation semantics. Initial overlays use the separately
|
|
679
|
+
// scalar-gated projection, so this timing filter can never reintroduce an initial array.
|
|
680
|
+
filteredLabels.add(label)
|
|
681
|
+
accepted[label] = filtered as VariantEntry
|
|
682
|
+
if (Object.keys(filtered).length > 0) hasElementTransitionOwner = true
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
accepted[label] = entry
|
|
686
|
+
if (targetEntries.length > 0) hasElementTransitionOwner = true
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
dictionary: accepted as VariantsDictionary,
|
|
690
|
+
filteredLabels,
|
|
691
|
+
initialProjection: initialProjection as VariantsDictionary,
|
|
692
|
+
initialFilteredLabels,
|
|
693
|
+
hasElementTransitionOwner,
|
|
694
|
+
attemptedElementTransitionOwner,
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function mappedLabels(
|
|
699
|
+
labels: readonly string[] | undefined,
|
|
700
|
+
aliases: ReadonlyMap<string, string>,
|
|
701
|
+
): string | readonly string[] | undefined {
|
|
702
|
+
if (labels === undefined) return undefined
|
|
703
|
+
const mapped = labels.map((label) => aliases.get(label) ?? label)
|
|
704
|
+
return mapped.length === 1 ? mapped[0] : mapped
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// R7 (REQ-API-032 law g): the ACCEPTED dictionary target keys for the presence container's
|
|
708
|
+
// mounted-key union (animate keys ∪ dictionary keys). REPORT-silent, never severity-silent
|
|
709
|
+
// (M3 r2 major 94ad45a8210d, the native silent-gate pattern): development still THROWS the
|
|
710
|
+
// typed law-(f) refusal, so dictionary validation always outruns the exit-subset rule and
|
|
711
|
+
// the error category matches native exactly; production skips the refused unit without a
|
|
712
|
+
// report — the child View owns the one report in this same render pass. RETURN-shaped, no
|
|
713
|
+
// catch (M3 r3 major 1b53e9337aec; the r2 ownership mechanism is superseded): a returned
|
|
714
|
+
// refusal is boundary-made by construction, and anything thrown — a trap fault, a forged
|
|
715
|
+
// or replayed sentinel — propagates untouched because nothing here catches it.
|
|
716
|
+
export function silentDictionaryTargetKeys(
|
|
717
|
+
value: unknown,
|
|
718
|
+
options: NormalizeOptions,
|
|
719
|
+
componentId: MotionComponentId = '<View>',
|
|
720
|
+
): ReadonlySet<string> {
|
|
721
|
+
const keys = new Set<string>()
|
|
722
|
+
if (value === undefined) return keys
|
|
723
|
+
const shapeRefusal = variantsShapeRefusal(value)
|
|
724
|
+
if (shapeRefusal !== null) {
|
|
725
|
+
if (options.mode === 'development')
|
|
726
|
+
throw new MotionWebRejectionError(`${componentId}: ${shapeRefusal.message}`, {
|
|
727
|
+
cause: shapeRefusal,
|
|
728
|
+
})
|
|
729
|
+
return keys
|
|
730
|
+
}
|
|
731
|
+
for (const [label, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
732
|
+
const { refusal, entry: snapshot } = validateWebVariantEntrySnapshot(label, entry, componentId)
|
|
733
|
+
if (refusal !== null) {
|
|
734
|
+
if (options.mode === 'development') throw refusal
|
|
735
|
+
continue
|
|
736
|
+
}
|
|
737
|
+
for (const key of Object.keys(snapshot!)) {
|
|
738
|
+
if (key !== 'transition') keys.add(key)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return keys
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// R7 (REQ-API-032 law f): inherit is a boolean switch — anything else refuses typed,
|
|
745
|
+
// naming the contract (the native gateInheritProp's depth).
|
|
746
|
+
function gateInheritWithSeverity(
|
|
747
|
+
value: unknown,
|
|
748
|
+
options: NormalizeOptions,
|
|
749
|
+
componentId: MotionComponentId = '<View>',
|
|
750
|
+
): boolean | undefined {
|
|
751
|
+
if (typeof value === 'boolean') return value
|
|
752
|
+
const error = new MotionWebRejectionError(
|
|
753
|
+
`${componentId}: "inherit" must be a boolean — received ${describeValue(value)} (REQ-API-032 law f)`,
|
|
754
|
+
)
|
|
755
|
+
if (options.mode === 'development') throw error
|
|
756
|
+
options.report(error)
|
|
757
|
+
return undefined
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const isLabelForm = (value: unknown): value is VariantLabels =>
|
|
761
|
+
typeof value === 'string' || Array.isArray(value)
|
|
762
|
+
|
|
763
|
+
type MotionHostElement = DOMMotionComponents[keyof DOMMotionComponents]
|
|
764
|
+
type MotionHostProps<Host extends MotionHostElement> = ViewProps &
|
|
765
|
+
Omit<React.ComponentPropsWithoutRef<Host>, keyof ViewProps | 'ref'>
|
|
766
|
+
type MotionHostView<Host extends MotionHostElement> = ForwardRefExoticComponent<
|
|
767
|
+
PropsWithoutRef<MotionHostProps<Host>> & RefAttributes<ComponentRef<Host>>
|
|
768
|
+
>
|
|
769
|
+
|
|
770
|
+
type HostRole = 'view' | 'text' | 'image'
|
|
771
|
+
|
|
772
|
+
function hasChildrenPayload(children: ReactNode | undefined): boolean {
|
|
773
|
+
return children !== undefined && children !== null && typeof children !== 'boolean'
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function createViewForHost<Host extends MotionHostElement>(
|
|
777
|
+
element: Host,
|
|
778
|
+
options: NormalizeOptions,
|
|
779
|
+
role: HostRole = 'view',
|
|
780
|
+
): MotionHostView<Host> {
|
|
781
|
+
const textHost = role === 'text'
|
|
782
|
+
const componentId: MotionComponentId =
|
|
783
|
+
role === 'text' ? '<Text>' : role === 'image' ? '<Image>' : '<View>'
|
|
784
|
+
const HostViewRuntime = forwardRef<ComponentRef<Host>, MotionHostProps<Host>>(function View(
|
|
785
|
+
props,
|
|
786
|
+
ref: Ref<ComponentRef<Host>>,
|
|
787
|
+
) {
|
|
788
|
+
const inheritedTimingSelections = useContext(VariantTimingContext)
|
|
789
|
+
// Validation-only config default (pin owns runtime inherit; REQ-API-033 cardinality parity).
|
|
790
|
+
// The selected host contributes only its host attributes to the public component type. The
|
|
791
|
+
// runtime normalization contract remains the shared View subset, so recover that stable
|
|
792
|
+
// internal shape before routing the universal props below.
|
|
793
|
+
const viewProps = props as unknown as ViewProps & Record<string, unknown>
|
|
794
|
+
const {
|
|
795
|
+
initial,
|
|
796
|
+
animate,
|
|
797
|
+
exit,
|
|
798
|
+
transition,
|
|
799
|
+
variants,
|
|
800
|
+
inherit,
|
|
801
|
+
layout,
|
|
802
|
+
layoutId,
|
|
803
|
+
layoutScroll,
|
|
804
|
+
// The drag family is destructured OUT of `rest` (FLAG 5a): core resolveDragConfig is the
|
|
805
|
+
// single cross-engine verdict, and the mapping to motion's props is built below — so a
|
|
806
|
+
// production refusal drops the whole family (never a lenient survivor) and dragSnapPoints
|
|
807
|
+
// maps to dragTransition via nearestSnap over the SAME core-validated points native uses.
|
|
808
|
+
drag,
|
|
809
|
+
dragConstraints,
|
|
810
|
+
dragElastic,
|
|
811
|
+
dragSnapPoints,
|
|
812
|
+
dragPropagation,
|
|
813
|
+
dragControls,
|
|
814
|
+
dragListener,
|
|
815
|
+
dragDirectionLock,
|
|
816
|
+
onDirectionLock,
|
|
817
|
+
dragMomentum,
|
|
818
|
+
dragTransition,
|
|
819
|
+
dragSnapToOrigin,
|
|
820
|
+
_dragX,
|
|
821
|
+
_dragY,
|
|
822
|
+
onDragStart,
|
|
823
|
+
onDrag,
|
|
824
|
+
onDragEnd,
|
|
825
|
+
onPanSessionStart,
|
|
826
|
+
onPanStart,
|
|
827
|
+
onPan,
|
|
828
|
+
onPanEnd,
|
|
829
|
+
onMeasureDragConstraints,
|
|
830
|
+
transformTemplate,
|
|
831
|
+
custom,
|
|
832
|
+
children: suppliedChildren,
|
|
833
|
+
...rest
|
|
834
|
+
} = viewProps
|
|
835
|
+
// Image is childless (REQ-API-034 law c/e): refuse any children payload at host render time.
|
|
836
|
+
let children = suppliedChildren
|
|
837
|
+
if (role === 'image' && hasChildrenPayload(suppliedChildren)) {
|
|
838
|
+
const error = new MotionWebRejectionError(
|
|
839
|
+
`${componentId}: Image is childless — a children payload is refused (REQ-API-034).`,
|
|
840
|
+
)
|
|
841
|
+
if (options.mode === 'development') throw error
|
|
842
|
+
options.report(error)
|
|
843
|
+
children = undefined
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Standard Motion callbacks remain pin-owned even beside the Native Motion snap extension.
|
|
847
|
+
// The extension validates only its own axis/points and never changes callback fault timing.
|
|
848
|
+
// T17: a present template must be a function — a malformed value is a typed failure under the
|
|
849
|
+
// severity law, never a raw TypeError inside motion's per-frame build-transform call. Web
|
|
850
|
+
// needs no worklet brand: motion/react calls the plain function on the main thread.
|
|
851
|
+
let gatedTransformTemplate = transformTemplate
|
|
852
|
+
if (transformTemplate !== undefined && typeof transformTemplate !== 'function') {
|
|
853
|
+
const error = new MotionWebRejectionError(
|
|
854
|
+
`${componentId}: "transformTemplate" must be a function, received ${typeof transformTemplate} — the ` +
|
|
855
|
+
'pin per-frame transform template (T17).',
|
|
856
|
+
)
|
|
857
|
+
if (options.mode === 'development') throw error
|
|
858
|
+
options.report(error)
|
|
859
|
+
gatedTransformTemplate = undefined
|
|
860
|
+
}
|
|
861
|
+
// Only the Native Motion snap extension enters the shared resolver. Standard pin drag props
|
|
862
|
+
// bypass this gate completely and remain owned by motion/react.
|
|
863
|
+
const anyDragProp = dragSnapPoints !== undefined
|
|
864
|
+
const currentDragConfig: ResolvedDragConfig | null = anyDragProp
|
|
865
|
+
? resolveDragConfigForComponent(
|
|
866
|
+
{
|
|
867
|
+
drag,
|
|
868
|
+
dragSnapPoints,
|
|
869
|
+
},
|
|
870
|
+
options,
|
|
871
|
+
componentId,
|
|
872
|
+
)
|
|
873
|
+
: null
|
|
874
|
+
|
|
875
|
+
const dragBag: Record<string, unknown> = {}
|
|
876
|
+
// REQ-WEB-021: standard Motion drag props are pin-owned. Forward the current render's raw
|
|
877
|
+
// values without Native Motion validation, cloning, ref reads, callback wrappers, or mount
|
|
878
|
+
// freezing. `dragSnapPoints` itself is stripped; when present its modifyTarget mapping is
|
|
879
|
+
// merged after the authored inertia options and therefore intentionally wins that one key.
|
|
880
|
+
const rawDragProps: Readonly<Record<string, unknown>> = {
|
|
881
|
+
drag,
|
|
882
|
+
dragConstraints,
|
|
883
|
+
dragElastic,
|
|
884
|
+
dragPropagation,
|
|
885
|
+
dragControls,
|
|
886
|
+
dragListener,
|
|
887
|
+
dragDirectionLock,
|
|
888
|
+
onDirectionLock,
|
|
889
|
+
dragMomentum,
|
|
890
|
+
dragTransition,
|
|
891
|
+
dragSnapToOrigin,
|
|
892
|
+
_dragX,
|
|
893
|
+
_dragY,
|
|
894
|
+
onDragStart,
|
|
895
|
+
onDrag,
|
|
896
|
+
onDragEnd,
|
|
897
|
+
onMeasureDragConstraints,
|
|
898
|
+
}
|
|
899
|
+
for (const [key, value] of Object.entries(rawDragProps)) {
|
|
900
|
+
if (value !== undefined) dragBag[key] = value
|
|
901
|
+
}
|
|
902
|
+
if (currentDragConfig !== null) {
|
|
903
|
+
const snapAxis = resolvedDragAxes(currentDragConfig).find(
|
|
904
|
+
(axis) => currentDragConfig[axis]?.snap !== undefined,
|
|
905
|
+
)
|
|
906
|
+
if (snapAxis !== undefined) {
|
|
907
|
+
const mapped = snapPointsToDragTransition(currentDragConfig[snapAxis]!.snap!.points)
|
|
908
|
+
dragBag['dragTransition'] = { ...dragTransition, ...mapped }
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// L4 entry shape gates (severity law; M2.6 lessons — the prop and its accepted shape are
|
|
913
|
+
// named, never a raw TypeError). Valid values forward to motion.div: real motion projects.
|
|
914
|
+
const layoutProps: Record<string, unknown> = {}
|
|
915
|
+
if (layout !== undefined) {
|
|
916
|
+
const validLayout =
|
|
917
|
+
typeof layout === 'boolean' ||
|
|
918
|
+
layout === 'position' ||
|
|
919
|
+
layout === 'size' ||
|
|
920
|
+
layout === 'preserve-aspect'
|
|
921
|
+
if (validLayout) {
|
|
922
|
+
layoutProps['layout'] = layout
|
|
923
|
+
} else {
|
|
924
|
+
const error = new MotionWebRejectionError(
|
|
925
|
+
`${componentId}: "layout" must be a boolean or 'position' | 'size' | 'preserve-aspect' — got ` +
|
|
926
|
+
`${describeValue(layout)} (specs/SPEC-LAYOUT.md)`,
|
|
927
|
+
)
|
|
928
|
+
if (options.mode === 'development') throw error
|
|
929
|
+
options.report(error)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (layoutId !== undefined) {
|
|
933
|
+
if (typeof layoutId === 'string' && layoutId.length > 0) {
|
|
934
|
+
layoutProps['layoutId'] = layoutId
|
|
935
|
+
} else {
|
|
936
|
+
const error = new MotionWebRejectionError(
|
|
937
|
+
`${componentId}: "layoutId" must be a non-empty string — got ${describeValue(layoutId)} ` +
|
|
938
|
+
'(specs/SPEC-LAYOUT.md)',
|
|
939
|
+
)
|
|
940
|
+
if (options.mode === 'development') throw error
|
|
941
|
+
options.report(error)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
// REQ-LAYOUT-022: boolean layoutScroll forwards to the pin; invalid values refuse at the boundary.
|
|
945
|
+
if (layoutScroll !== undefined) {
|
|
946
|
+
if (typeof layoutScroll === 'boolean') {
|
|
947
|
+
layoutProps['layoutScroll'] = layoutScroll
|
|
948
|
+
} else {
|
|
949
|
+
const error = new MotionWebRejectionError(
|
|
950
|
+
`${componentId}: "layoutScroll" must be a boolean — got ${describeValue(layoutScroll)} ` +
|
|
951
|
+
'(REQ-LAYOUT-022; specs/SPEC-LAYOUT.md)',
|
|
952
|
+
)
|
|
953
|
+
if (options.mode === 'development') throw error
|
|
954
|
+
options.report(error)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Prop routing, fail-closed (review cycle 1): motion-union keys (the disposition table's
|
|
959
|
+
// domain + style) go through normalization; a key OUR table does not declare but motion.div
|
|
960
|
+
// WOULD interpret (`isValidMotionProp`: variants, layout, whileInView, …) is an undeclared
|
|
961
|
+
// capability and fails loud under the severity law — forwarding it would ship an unratified
|
|
962
|
+
// Motion surface through the shim (the exact leak Codex reproduced). Only genuine DOM
|
|
963
|
+
// attributes (id, data-*, aria-*, DOM handlers) flow to motion.div untouched.
|
|
964
|
+
const motionBag: Record<string, unknown> = {}
|
|
965
|
+
const domAttrs: Record<string, unknown> = {}
|
|
966
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
967
|
+
if (value === undefined) continue
|
|
968
|
+
if (key === 'style' || Object.hasOwn(DISPOSITIONS, key)) {
|
|
969
|
+
// The drag family (incl. dragSnapPoints) is destructured out of `rest` above and mapped via
|
|
970
|
+
// dragBag over core's verdict — it never reaches this loop; its disposition row exists purely
|
|
971
|
+
// for the compile-time exhaustiveness guard (REQ-WEB-012/013), not runtime routing.
|
|
972
|
+
motionBag[key] = value
|
|
973
|
+
} else if (DECLARED_COMPONENT_PROPS.has(key)) {
|
|
974
|
+
domAttrs[key] = value // declared lifecycle plumbing — forwarded to motion as-is
|
|
975
|
+
} else if (isValidMotionProp(key)) {
|
|
976
|
+
// REQ-WEB-020: pin-owned additions bypass the universal/native validation lanes and reach
|
|
977
|
+
// motion/react unchanged. The pin's deliberately prefix-wide predicate remains normative.
|
|
978
|
+
domAttrs[key] = value
|
|
979
|
+
} else {
|
|
980
|
+
domAttrs[key] = value
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// Image element-specific `src` (REQ-API-034(e)): required non-empty string; alt is optional
|
|
984
|
+
// and passes through as a DOM attribute. Missing/invalid refuses only the prop.
|
|
985
|
+
if (role === 'image') {
|
|
986
|
+
const src = domAttrs['src'] ?? (rest as Record<string, unknown>)['src']
|
|
987
|
+
if (typeof src !== 'string' || src.length === 0) {
|
|
988
|
+
const error = new MotionWebRejectionError(
|
|
989
|
+
`${componentId}: "src" is required and must be a non-empty string — got ` +
|
|
990
|
+
`${describeValue(src)} (REQ-API-034).`,
|
|
991
|
+
)
|
|
992
|
+
if (options.mode === 'development') throw error
|
|
993
|
+
options.report(error)
|
|
994
|
+
delete domAttrs['src']
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// R7 (REQ-API-032): variants/inherit and the label forms EXECUTE on this engine —
|
|
998
|
+
// motion/react owns resolution, propagation, and the inherit switch (REQ-WEB-011: the
|
|
999
|
+
// shim adds validation, never animation semantics). This boundary gates VALIDITY at
|
|
1000
|
+
// core depth; a refused unit never reaches motion (cross-engine sanitization parity:
|
|
1001
|
+
// the native entry's controller never sees a refused unit either).
|
|
1002
|
+
// Capture and validate the element transition before its target consumers. The resulting
|
|
1003
|
+
// accepted local transition is what motion/react receives on the element. Keyframe
|
|
1004
|
+
// cardinality validation also sees the nearest MotionConfig default when local is omitted
|
|
1005
|
+
// (native MotionView effectiveElementTransition; REQ-API-033 same-shape law).
|
|
1006
|
+
// tww0y1: distinguish "local omitted" from "local present but refused" — only omission
|
|
1007
|
+
// inherits config for validation; a refused local must not fall back to the parent config
|
|
1008
|
+
// (native gates the chosen effective transition, which is raw local when present).
|
|
1009
|
+
const gatedTransition =
|
|
1010
|
+
transition !== undefined
|
|
1011
|
+
? gateTransitionWithSeverity(transition, options, componentId)
|
|
1012
|
+
: undefined
|
|
1013
|
+
// Motion owns transition selection and keyframe timing on web. The universal target validator
|
|
1014
|
+
// therefore receives no native timing policy.
|
|
1015
|
+
const validationTransition = undefined
|
|
1016
|
+
const targetProps: Record<string, unknown> = {}
|
|
1017
|
+
const capturedInitialLabels = isLabelForm(initial)
|
|
1018
|
+
? captureLabelFormWithSeverity('initial', initial, options, componentId)
|
|
1019
|
+
: undefined
|
|
1020
|
+
const capturedAnimateLabels = isLabelForm(animate)
|
|
1021
|
+
? captureLabelFormWithSeverity('animate', animate, options, componentId)
|
|
1022
|
+
: undefined
|
|
1023
|
+
const capturedExitLabels = isLabelForm(exit)
|
|
1024
|
+
? captureLabelFormWithSeverity('exit', exit, options, componentId)
|
|
1025
|
+
: undefined
|
|
1026
|
+
const rawTapLabels = motionBag['whileTap']
|
|
1027
|
+
const rawDragLabels = motionBag['whileDrag']
|
|
1028
|
+
const capturedTapLabels = isLabelForm(rawTapLabels)
|
|
1029
|
+
? captureLabelFormWithSeverity('whileTap', rawTapLabels, options, componentId)
|
|
1030
|
+
: undefined
|
|
1031
|
+
const capturedDragLabels = isLabelForm(rawDragLabels)
|
|
1032
|
+
? captureLabelFormWithSeverity('whileDrag', rawDragLabels, options, componentId)
|
|
1033
|
+
: undefined
|
|
1034
|
+
if (isLabelForm(rawTapLabels)) {
|
|
1035
|
+
if (capturedTapLabels === undefined) delete motionBag['whileTap']
|
|
1036
|
+
else motionBag['whileTap'] = capturedTapLabels
|
|
1037
|
+
}
|
|
1038
|
+
if (isLabelForm(rawDragLabels)) {
|
|
1039
|
+
if (capturedDragLabels === undefined) delete motionBag['whileDrag']
|
|
1040
|
+
else motionBag['whileDrag'] = capturedDragLabels
|
|
1041
|
+
}
|
|
1042
|
+
const localInitialLabels = labelNames(capturedInitialLabels)
|
|
1043
|
+
const localAnimateLabels = labelNames(capturedAnimateLabels)
|
|
1044
|
+
const localExitLabels = labelNames(capturedExitLabels)
|
|
1045
|
+
const localTapLabels = labelNames(capturedTapLabels)
|
|
1046
|
+
const localDragLabels = labelNames(capturedDragLabels)
|
|
1047
|
+
// Match Motion's pinned isControllingVariants law: ANY local label supplier owns the
|
|
1048
|
+
// variant selection context as a unit. A child that supplies whileTap must not also validate
|
|
1049
|
+
// a parent's animate label against its own transition, and inherit={false} suppresses the
|
|
1050
|
+
// entire inherited mount selection.
|
|
1051
|
+
const controlsVariants =
|
|
1052
|
+
localInitialLabels !== undefined ||
|
|
1053
|
+
localAnimateLabels !== undefined ||
|
|
1054
|
+
localExitLabels !== undefined ||
|
|
1055
|
+
localTapLabels !== undefined ||
|
|
1056
|
+
localDragLabels !== undefined
|
|
1057
|
+
const acceptsInheritedSelections = inherit !== false && !controlsVariants
|
|
1058
|
+
// `initial` has one additional supplier rule: an object or `false` is a local first-paint
|
|
1059
|
+
// supplier for THIS node, so an inherited initial label is not selected here. Neither form is a
|
|
1060
|
+
// controlling label host, however, so the ancestor selection still propagates to descendants.
|
|
1061
|
+
const hasLocalNonLabelInitialSupplier =
|
|
1062
|
+
initial === false || (initial !== undefined && !isLabelForm(initial))
|
|
1063
|
+
// Motion's controlling rule is label-form only. A local object target executes alongside
|
|
1064
|
+
// inherited label selections, so raw prop presence cannot erase those selections from this
|
|
1065
|
+
// validation-only context. An accepted local label remains the one controlling replacement.
|
|
1066
|
+
const effectiveInitialLabels =
|
|
1067
|
+
localInitialLabels ??
|
|
1068
|
+
(hasLocalNonLabelInitialSupplier || !acceptsInheritedSelections
|
|
1069
|
+
? undefined
|
|
1070
|
+
: inheritedTimingSelections.initial)
|
|
1071
|
+
const effectiveAnimateLabels =
|
|
1072
|
+
localAnimateLabels ??
|
|
1073
|
+
(!acceptsInheritedSelections ? undefined : inheritedTimingSelections.animate)
|
|
1074
|
+
const effectiveExitLabels =
|
|
1075
|
+
localExitLabels ?? (!acceptsInheritedSelections ? undefined : inheritedTimingSelections.exit)
|
|
1076
|
+
const effectiveTapLabels =
|
|
1077
|
+
localTapLabels ??
|
|
1078
|
+
(!acceptsInheritedSelections ? undefined : inheritedTimingSelections.whileTap)
|
|
1079
|
+
const effectiveDragLabels =
|
|
1080
|
+
localDragLabels ??
|
|
1081
|
+
(!acceptsInheritedSelections ? undefined : inheritedTimingSelections.whileDrag)
|
|
1082
|
+
const selectedTimingLabels = [
|
|
1083
|
+
...(effectiveAnimateLabels ?? []),
|
|
1084
|
+
...(effectiveExitLabels ?? []),
|
|
1085
|
+
...(effectiveTapLabels ?? []),
|
|
1086
|
+
...(effectiveDragLabels ?? []),
|
|
1087
|
+
]
|
|
1088
|
+
let timingFilteredLabels: ReadonlySet<string> = new Set()
|
|
1089
|
+
let initialFilteredLabels: ReadonlySet<string> = new Set()
|
|
1090
|
+
let initialProjectionVariants: VariantsDictionary | undefined
|
|
1091
|
+
if (variants !== undefined) {
|
|
1092
|
+
const originalGatedVariants = gateVariantsWithSeverity(variants, options, componentId)
|
|
1093
|
+
const timingGatedVariants = gateVariantTimingConsumers(
|
|
1094
|
+
originalGatedVariants,
|
|
1095
|
+
selectedTimingLabels,
|
|
1096
|
+
effectiveInitialLabels === false ? undefined : effectiveInitialLabels,
|
|
1097
|
+
validationTransition,
|
|
1098
|
+
options,
|
|
1099
|
+
componentId,
|
|
1100
|
+
)
|
|
1101
|
+
timingFilteredLabels = timingGatedVariants.filteredLabels
|
|
1102
|
+
initialFilteredLabels = timingGatedVariants.initialFilteredLabels
|
|
1103
|
+
const gatedVariants = timingGatedVariants.dictionary
|
|
1104
|
+
initialProjectionVariants = timingGatedVariants.initialProjection
|
|
1105
|
+
if (gatedVariants !== undefined) targetProps['variants'] = gatedVariants
|
|
1106
|
+
}
|
|
1107
|
+
const normalized = normalizeComponentProps(
|
|
1108
|
+
motionBag,
|
|
1109
|
+
options,
|
|
1110
|
+
validationTransition,
|
|
1111
|
+
componentId,
|
|
1112
|
+
)
|
|
1113
|
+
const mappedTap =
|
|
1114
|
+
motionBag['whileTap'] === undefined ? undefined : mappedLabels(localTapLabels, new Map())
|
|
1115
|
+
const mappedDrag =
|
|
1116
|
+
motionBag['whileDrag'] === undefined ? undefined : mappedLabels(localDragLabels, new Map())
|
|
1117
|
+
if (mappedTap !== undefined) normalized.props['whileTap'] = mappedTap
|
|
1118
|
+
if (mappedDrag !== undefined) normalized.props['whileDrag'] = mappedDrag
|
|
1119
|
+
if (inherit !== undefined) {
|
|
1120
|
+
const gatedInherit = gateInheritWithSeverity(inherit, options, componentId)
|
|
1121
|
+
if (gatedInherit !== undefined) targetProps['inherit'] = gatedInherit
|
|
1122
|
+
}
|
|
1123
|
+
if (initial !== undefined) {
|
|
1124
|
+
// The pin's public MotionProps types do not advertise direct resolver functions, but its
|
|
1125
|
+
// runtime resolveVariantFromProps path executes them when supplied through an untyped/cast
|
|
1126
|
+
// boundary. Preserve that exact type/runtime asymmetry: a function reaches motion/react by
|
|
1127
|
+
// identity and never enters Native Motion's object-target validator (REQ-API-056).
|
|
1128
|
+
if (typeof (initial as unknown) === 'function') {
|
|
1129
|
+
targetProps['initial'] = initial
|
|
1130
|
+
} else if (initial === false) {
|
|
1131
|
+
targetProps['initial'] = false
|
|
1132
|
+
} else if (isLabelForm(initial)) {
|
|
1133
|
+
if (capturedInitialLabels !== undefined) {
|
|
1134
|
+
// `initial` is a first-paint supplier, not a timing consumer. It deliberately
|
|
1135
|
+
// uses its own scalar-gated projection when animate needs a sanitized sibling.
|
|
1136
|
+
targetProps['initial'] =
|
|
1137
|
+
localInitialLabels?.some(
|
|
1138
|
+
(label) => timingFilteredLabels.has(label) || initialFilteredLabels.has(label),
|
|
1139
|
+
) === true
|
|
1140
|
+
? resolveInitialOverlay(localInitialLabels, initialProjectionVariants)
|
|
1141
|
+
: mappedLabels(localInitialLabels, new Map())
|
|
1142
|
+
}
|
|
1143
|
+
} else {
|
|
1144
|
+
// `initial` paints a starting state. It does not execute the element's keyframe tween, so a
|
|
1145
|
+
// scalar initial value must not be rejected merely because animate owns timing arrays.
|
|
1146
|
+
targetProps['initial'] = validateWithSeverityResult(
|
|
1147
|
+
initial,
|
|
1148
|
+
'initial',
|
|
1149
|
+
options,
|
|
1150
|
+
undefined,
|
|
1151
|
+
true,
|
|
1152
|
+
componentId,
|
|
1153
|
+
).target
|
|
1154
|
+
}
|
|
1155
|
+
} else if (
|
|
1156
|
+
effectiveInitialLabels !== undefined &&
|
|
1157
|
+
effectiveInitialLabels !== false &&
|
|
1158
|
+
effectiveInitialLabels.some(
|
|
1159
|
+
(label) => timingFilteredLabels.has(label) || initialFilteredLabels.has(label),
|
|
1160
|
+
)
|
|
1161
|
+
) {
|
|
1162
|
+
// An inherited initial label remains Motion's propagation concern. This object overlay is
|
|
1163
|
+
// the narrow production repair for a timing-sanitised shared label: it preserves the
|
|
1164
|
+
// legal first paint from the immutable scalar-gated dictionary without making the child a
|
|
1165
|
+
// local label controller.
|
|
1166
|
+
targetProps['initial'] = resolveInitialOverlay(
|
|
1167
|
+
effectiveInitialLabels,
|
|
1168
|
+
initialProjectionVariants,
|
|
1169
|
+
)
|
|
1170
|
+
}
|
|
1171
|
+
if (animate !== undefined) {
|
|
1172
|
+
if (typeof animate === 'boolean') {
|
|
1173
|
+
// U7h (REQ-API-057): forward the pin's boolean skip; do not validate as a target.
|
|
1174
|
+
targetProps['animate'] = animate
|
|
1175
|
+
} else if (typeof (animate as unknown) === 'function') {
|
|
1176
|
+
targetProps['animate'] = animate
|
|
1177
|
+
} else if (isLabelForm(animate)) {
|
|
1178
|
+
if (capturedAnimateLabels !== undefined) {
|
|
1179
|
+
targetProps['animate'] = mappedLabels(localAnimateLabels, new Map())
|
|
1180
|
+
}
|
|
1181
|
+
} else {
|
|
1182
|
+
const result = validateWithSeverityResult(
|
|
1183
|
+
animate,
|
|
1184
|
+
'animate',
|
|
1185
|
+
options,
|
|
1186
|
+
validationTransition,
|
|
1187
|
+
false,
|
|
1188
|
+
componentId,
|
|
1189
|
+
)
|
|
1190
|
+
targetProps['animate'] = result.target
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (exit !== undefined) {
|
|
1194
|
+
if (typeof (exit as unknown) === 'function') {
|
|
1195
|
+
targetProps['exit'] = exit
|
|
1196
|
+
} else if (isLabelForm(exit)) {
|
|
1197
|
+
if (capturedExitLabels !== undefined) {
|
|
1198
|
+
targetProps['exit'] = mappedLabels(localExitLabels, new Map())
|
|
1199
|
+
}
|
|
1200
|
+
} else {
|
|
1201
|
+
const result = validateWithSeverityResult(
|
|
1202
|
+
exit,
|
|
1203
|
+
'exit',
|
|
1204
|
+
options,
|
|
1205
|
+
validationTransition,
|
|
1206
|
+
false,
|
|
1207
|
+
componentId,
|
|
1208
|
+
)
|
|
1209
|
+
targetProps['exit'] = result.target
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (gatedTransition !== undefined) {
|
|
1213
|
+
targetProps['transition'] = gatedTransition
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// `inherit={false}` gates only this node's mount selection. It does not make the node a
|
|
1217
|
+
// controlling label host, so descendants must still receive the outer accepted labels. A
|
|
1218
|
+
// genuine local label host, by contrast, resets the propagated batch as one unit.
|
|
1219
|
+
const suppliedTimingSelections: VariantTimingSelections = {
|
|
1220
|
+
initial:
|
|
1221
|
+
localInitialLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.initial),
|
|
1222
|
+
animate:
|
|
1223
|
+
localAnimateLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.animate),
|
|
1224
|
+
exit: localExitLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.exit),
|
|
1225
|
+
whileTap:
|
|
1226
|
+
localTapLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.whileTap),
|
|
1227
|
+
whileDrag:
|
|
1228
|
+
localDragLabels ?? (controlsVariants ? undefined : inheritedTimingSelections.whileDrag),
|
|
1229
|
+
}
|
|
1230
|
+
// R15 D6 (REQ-API-047): pan observation callbacks — pin owns the session without drag.
|
|
1231
|
+
const panProps: Record<string, unknown> = {}
|
|
1232
|
+
if (onPanSessionStart !== undefined) panProps['onPanSessionStart'] = onPanSessionStart
|
|
1233
|
+
if (onPanStart !== undefined) panProps['onPanStart'] = onPanStart
|
|
1234
|
+
if (onPan !== undefined) panProps['onPan'] = onPan
|
|
1235
|
+
if (onPanEnd !== undefined) panProps['onPanEnd'] = onPanEnd
|
|
1236
|
+
// T17: the gated template forwards to motion/react, which owns the per-frame call.
|
|
1237
|
+
const templateProps: Record<string, unknown> = {}
|
|
1238
|
+
if (gatedTransformTemplate !== undefined)
|
|
1239
|
+
templateProps['transformTemplate'] = gatedTransformTemplate
|
|
1240
|
+
// T24 B1: custom forwards untouched — motion/react resolves dynamic variants with it.
|
|
1241
|
+
if (custom !== undefined) templateProps['custom'] = custom
|
|
1242
|
+
const host: ReactNode = createElement(
|
|
1243
|
+
element as never,
|
|
1244
|
+
{
|
|
1245
|
+
ref,
|
|
1246
|
+
...domAttrs,
|
|
1247
|
+
...targetProps,
|
|
1248
|
+
...layoutProps,
|
|
1249
|
+
...normalized.props,
|
|
1250
|
+
...dragBag,
|
|
1251
|
+
...panProps,
|
|
1252
|
+
...templateProps,
|
|
1253
|
+
} as never,
|
|
1254
|
+
children,
|
|
1255
|
+
)
|
|
1256
|
+
return createElement(VariantTimingContext.Provider, { value: suppliedTimingSelections }, host)
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
return forwardRef<ComponentRef<Host>, MotionHostProps<Host>>(function ViewBoundary(props, ref) {
|
|
1260
|
+
const textNestingBoundary = useContext(TextNestingContext)
|
|
1261
|
+
const nestedViewRefusalReportedRef = useRef(false)
|
|
1262
|
+
if (!textHost && textNestingBoundary !== null) {
|
|
1263
|
+
const error = new MotionWebRejectionError(
|
|
1264
|
+
'Motion.Text cannot contain a Motion.View child; text hosts accept only text-compatible ' +
|
|
1265
|
+
'ReactNode children (REQ-API-034).',
|
|
1266
|
+
)
|
|
1267
|
+
if (textNestingBoundary.mode === 'development') throw error
|
|
1268
|
+
if (!nestedViewRefusalReportedRef.current) {
|
|
1269
|
+
nestedViewRefusalReportedRef.current = true
|
|
1270
|
+
textNestingBoundary.report(error)
|
|
1271
|
+
}
|
|
1272
|
+
return null
|
|
1273
|
+
}
|
|
1274
|
+
const host = createElement(HostViewRuntime, { ...props, ref } as never)
|
|
1275
|
+
return textHost ? createElement(TextNestingContext.Provider, { value: options }, host) : host
|
|
1276
|
+
})
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// The one web host seam. The legacy one-argument test factory stays behaviorally identical;
|
|
1280
|
+
// alternate motion/react hosts must provide the normalization channel explicitly.
|
|
1281
|
+
export function createView(options: NormalizeOptions): MotionHostView<typeof motion.div>
|
|
1282
|
+
export function createView<Host extends MotionHostElement>(
|
|
1283
|
+
element: Host,
|
|
1284
|
+
options: NormalizeOptions,
|
|
1285
|
+
): MotionHostView<Host>
|
|
1286
|
+
export function createView<Host extends MotionHostElement>(
|
|
1287
|
+
elementOrOptions: Host | NormalizeOptions,
|
|
1288
|
+
maybeOptions?: NormalizeOptions,
|
|
1289
|
+
): MotionHostView<Host> | MotionHostView<typeof motion.div> {
|
|
1290
|
+
if (maybeOptions === undefined)
|
|
1291
|
+
return createViewForHost(motion.div, elementOrOptions as NormalizeOptions)
|
|
1292
|
+
return createViewForHost(elementOrOptions as Host, maybeOptions)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* The default error channel: loud in the console, never swallowed. */
|
|
1296
|
+
function consoleReporter(error: Error): void {
|
|
1297
|
+
console.error(error)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export const View = createView(motion.div, { mode: ambientMode(), report: consoleReporter })
|
|
1301
|
+
|
|
1302
|
+
const TEXT_OPTIONS: NormalizeOptions = {
|
|
1303
|
+
// Unlike the named test factory, the public Text component cannot receive explicit options.
|
|
1304
|
+
// Keep its severity decision render-fresh so the production refusal path remains testable and
|
|
1305
|
+
// matches the public View's ambient contract.
|
|
1306
|
+
get mode() {
|
|
1307
|
+
return ambientMode()
|
|
1308
|
+
},
|
|
1309
|
+
report: consoleReporter,
|
|
1310
|
+
}
|
|
1311
|
+
const ParagraphText = createViewForHost(motion.p, TEXT_OPTIONS, 'text')
|
|
1312
|
+
const InlineText = createViewForHost(motion.span, TEXT_OPTIONS, 'text')
|
|
1313
|
+
|
|
1314
|
+
// REQ-API-034 law (b): the catalog ports motion.p with the default block host and motion.span
|
|
1315
|
+
// with `as="span"`. Native accepts the selector as a semantic no-op because RN Text has no DOM
|
|
1316
|
+
// display-mode distinction; keeping it in the universal type makes a catalog port typecheck on
|
|
1317
|
+
// both entries.
|
|
1318
|
+
export type TextProps = Omit<MotionHostProps<typeof motion.p>, 'as'> & {
|
|
1319
|
+
readonly as?: 'p' | 'span'
|
|
1320
|
+
}
|
|
1321
|
+
export const Text = forwardRef<HTMLElement, TextProps>(function Text({ as, ...props }, ref) {
|
|
1322
|
+
const nestedInsideText = useContext(TextNestingContext) !== null
|
|
1323
|
+
let selectedAs: 'p' | 'span' = nestedInsideText ? 'span' : 'p'
|
|
1324
|
+
if (as === 'span' || (!nestedInsideText && as === 'p')) selectedAs = as
|
|
1325
|
+
// A paragraph nested in Text produces an invalid DOM tree. The nesting context is the rendered
|
|
1326
|
+
// ancestor, so explicit `p` is valid at the root but is an invalid selector below Text.
|
|
1327
|
+
if ((as !== undefined && as !== 'p' && as !== 'span') || (nestedInsideText && as === 'p')) {
|
|
1328
|
+
const error = new MotionWebRejectionError(
|
|
1329
|
+
`<Text> "as" must be "p" or "span" at the root and "span" inside another Text host — got ${describeValue(as)} (REQ-API-034).`,
|
|
1330
|
+
)
|
|
1331
|
+
if (ambientMode() === 'development') throw error
|
|
1332
|
+
consoleReporter(error)
|
|
1333
|
+
// Production refuses only the invalid selector; nested Text always falls back inline.
|
|
1334
|
+
}
|
|
1335
|
+
const Host = selectedAs === 'span' ? InlineText : ParagraphText
|
|
1336
|
+
return createElement(Host, { ...props, ref } as never)
|
|
1337
|
+
})
|
|
1338
|
+
|
|
1339
|
+
const IMAGE_OPTIONS: NormalizeOptions = {
|
|
1340
|
+
get mode() {
|
|
1341
|
+
return ambientMode()
|
|
1342
|
+
},
|
|
1343
|
+
report: consoleReporter,
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// REQ-API-034 law (c): Image covers motion.img. `src`/`alt` are ordinary DOM attributes; the
|
|
1347
|
+
// Motion props contract matches View; children are refused at host render.
|
|
1348
|
+
export type ImageProps = Omit<MotionHostProps<typeof motion.img>, 'children'> & {
|
|
1349
|
+
readonly children?: never
|
|
1350
|
+
}
|
|
1351
|
+
export const Image = createViewForHost(
|
|
1352
|
+
motion.img,
|
|
1353
|
+
IMAGE_OPTIONS,
|
|
1354
|
+
'image',
|
|
1355
|
+
) as ForwardRefExoticComponent<PropsWithoutRef<ImageProps> & RefAttributes<HTMLImageElement>>
|
|
1356
|
+
Image.displayName = 'Motion.Image'
|