@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/normalize.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// The component-prop normalization pipeline (REQ-WEB-012/013/015): pure, total, side-effect-
|
|
2
|
+
// free. Severity is a PARAMETER (ratified 2026-07-06: dev throws; production reports through the
|
|
3
|
+
// error channel and refuses the offending property) — the React layer resolves the ambient mode;
|
|
4
|
+
// this module never reads the environment.
|
|
5
|
+
import {
|
|
6
|
+
describeValue,
|
|
7
|
+
captureTransition,
|
|
8
|
+
hostCapabilities,
|
|
9
|
+
validateTargetSnapshot,
|
|
10
|
+
UNIVERSAL_SUBSET,
|
|
11
|
+
type Target,
|
|
12
|
+
type Transition,
|
|
13
|
+
} from '@unrulysystems/native-motion-core'
|
|
14
|
+
import {
|
|
15
|
+
captureBoundedArray,
|
|
16
|
+
capturedArrayDescription,
|
|
17
|
+
} from '@unrulysystems/native-motion-core/internal-driver'
|
|
18
|
+
import { dispositionFor } from './disposition'
|
|
19
|
+
import {
|
|
20
|
+
MotionWebIncompatibleError,
|
|
21
|
+
MotionWebRejectionError,
|
|
22
|
+
type MotionWebError,
|
|
23
|
+
type WebErrorMode,
|
|
24
|
+
type WebErrorReporter,
|
|
25
|
+
} from './errors'
|
|
26
|
+
import { mapWebTransitionAliases, type WebTransition } from './webTransition'
|
|
27
|
+
|
|
28
|
+
export interface NormalizeOptions {
|
|
29
|
+
readonly mode: WebErrorMode
|
|
30
|
+
readonly report: WebErrorReporter
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MotionComponentId = '<View>' | '<Text>' | '<Image>'
|
|
34
|
+
|
|
35
|
+
export interface NormalizedComponentProps {
|
|
36
|
+
// The motion/react-shaped prop bag: refused props removed, extensions mapped, style pinned.
|
|
37
|
+
readonly props: Record<string, unknown>
|
|
38
|
+
// Prop paths refused in production mode (dev throws instead) — reported, never silent.
|
|
39
|
+
readonly refused: readonly string[]
|
|
40
|
+
/** Gesture members that reached an element fallback before severity filtering. */
|
|
41
|
+
readonly attemptedElementTransitionOwners: readonly string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The host boundary must validate exactly the record it hands to motion/react. Plain transitions are
|
|
45
|
+
// captured through CORE's bounded timing snapshot; malformed outer shapes stay raw so core can issue its
|
|
46
|
+
// existing typed refusal. Getter faults deliberately escape untouched.
|
|
47
|
+
export function captureTransitionSnapshot(value: unknown): unknown {
|
|
48
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
49
|
+
const prototype = Object.getPrototypeOf(value)
|
|
50
|
+
if (prototype === Object.prototype || prototype === null)
|
|
51
|
+
return captureTransition(value as Transition)
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const BOX_SIZING_PIN = 'border-box'
|
|
57
|
+
|
|
58
|
+
function rejectionMessage(key: string, reason: string): string {
|
|
59
|
+
switch (reason) {
|
|
60
|
+
case 'deferred-spike-scope':
|
|
61
|
+
return (
|
|
62
|
+
`"${key}" is deferred from the first-spike universal subset (ratified 2026-07-06) — ` +
|
|
63
|
+
'it fails loudly on every engine until its own rung lands'
|
|
64
|
+
)
|
|
65
|
+
case 'outside-universal-target':
|
|
66
|
+
return (
|
|
67
|
+
`"${key}" is a web-only registry member outside the universal Target — the universal ` +
|
|
68
|
+
'surface refuses it on every engine (core validateTarget parity)'
|
|
69
|
+
)
|
|
70
|
+
default:
|
|
71
|
+
return `"${key}" requires the native platform and has no faithful web mapping (REQ-WEB-015)`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const WEB_HOST = hostCapabilities('web', ['universal'])
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* R7 (REQ-API-032 law f): the label-form validity gate the web boundary shares across every
|
|
79
|
+
* label-carrying prop (initial/animate/exit at the View, whileTap/whileDrag here). A string
|
|
80
|
+
* is a label; an array must be string-only and refuses naming the offending INDEX — the
|
|
81
|
+
* same depth the native gate enforces. Resolution and propagation are motion/react's
|
|
82
|
+
* (REQ-WEB-011); validity is this boundary's. Returns whether the definition is accepted
|
|
83
|
+
* (development throws instead of returning false).
|
|
84
|
+
*/
|
|
85
|
+
export function gateLabelFormWithSeverity(
|
|
86
|
+
propName: string,
|
|
87
|
+
definition: string | readonly unknown[],
|
|
88
|
+
options: NormalizeOptions,
|
|
89
|
+
componentId: MotionComponentId = '<View>',
|
|
90
|
+
): boolean {
|
|
91
|
+
return captureLabelFormWithSeverity(propName, definition, options, componentId) !== undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Capture a label form exactly once and return the immutable value Motion receives. */
|
|
95
|
+
export function captureLabelFormWithSeverity(
|
|
96
|
+
propName: string,
|
|
97
|
+
definition: string | readonly unknown[],
|
|
98
|
+
options: NormalizeOptions,
|
|
99
|
+
componentId: MotionComponentId = '<View>',
|
|
100
|
+
): string | readonly string[] | undefined {
|
|
101
|
+
if (typeof definition === 'string') return definition
|
|
102
|
+
const capturedDefinition = captureBoundedArray(definition)
|
|
103
|
+
if (capturedDefinition.kind !== 'captured') {
|
|
104
|
+
const error = new MotionWebRejectionError(
|
|
105
|
+
`${componentId}: "${propName}" label array must have a safe length — received ${capturedArrayDescription(capturedDefinition)} ` +
|
|
106
|
+
'(REQ-API-032 law f)',
|
|
107
|
+
)
|
|
108
|
+
if (options.mode === 'development') throw error
|
|
109
|
+
options.report(error)
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
const length = capturedDefinition.values.length
|
|
113
|
+
const captured: string[] = []
|
|
114
|
+
captured.length = length
|
|
115
|
+
for (let index = 0; index < length; index++) {
|
|
116
|
+
const member = Object.hasOwn(capturedDefinition.values, index)
|
|
117
|
+
? capturedDefinition.values[index]
|
|
118
|
+
: undefined
|
|
119
|
+
if (typeof member === 'string') {
|
|
120
|
+
captured[index] = member
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
// describeValue, never JSON.stringify (M3 r1 major cdc42f01c118): a BigInt member
|
|
124
|
+
// would crash the FORMATTER itself — a raw serialize TypeError replacing the typed
|
|
125
|
+
// law-(f) refusal in development and escaping unreported in production.
|
|
126
|
+
const error = new MotionWebRejectionError(
|
|
127
|
+
`${componentId}: "${propName}" label array member at index ${index} must be a string — received ` +
|
|
128
|
+
`${describeValue(member)} (REQ-API-032 law f; the label array is ` +
|
|
129
|
+
'string-only)',
|
|
130
|
+
)
|
|
131
|
+
if (options.mode === 'development') throw error
|
|
132
|
+
options.report(error)
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
return Object.freeze(captured)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function normalizeComponentProps(
|
|
139
|
+
input: Readonly<Record<string, unknown>>,
|
|
140
|
+
options: NormalizeOptions,
|
|
141
|
+
_fallbackTransition: WebTransition | undefined = undefined,
|
|
142
|
+
componentId: MotionComponentId = '<View>',
|
|
143
|
+
): NormalizedComponentProps {
|
|
144
|
+
const refused: string[] = []
|
|
145
|
+
const attemptedElementTransitionOwners: string[] = []
|
|
146
|
+
// Dev throws; production reports and records the refusal. Returns true when the prop must be
|
|
147
|
+
// dropped from the output (production path).
|
|
148
|
+
const refuse = (error: MotionWebError, propPath: string): void => {
|
|
149
|
+
if (options.mode === 'development') throw error
|
|
150
|
+
options.report(error)
|
|
151
|
+
refused.push(propPath)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const props: Record<string, unknown> = {}
|
|
155
|
+
|
|
156
|
+
// The drag family (drag/dragConstraints/dragElastic/dragSnapPoints + callbacks) is NOT routed
|
|
157
|
+
// here (FLAG 5a): the View owns it — core resolveDragConfig is the single cross-engine verdict,
|
|
158
|
+
// and the View maps dragSnapPoints to dragTransition via nearestSnap over core-validated points.
|
|
159
|
+
// This lane handles the animatable-target + gesture-callback props that pass straight to motion.
|
|
160
|
+
for (const [key, value] of Object.entries(input)) {
|
|
161
|
+
if (key === 'style') continue // handled below: the pin composes with user style
|
|
162
|
+
const disposition = dispositionFor(key)
|
|
163
|
+
switch (disposition.kind) {
|
|
164
|
+
case 'pass-through':
|
|
165
|
+
// The tap trio's callbacks are gated for SHAPE before motion sees them (R5 review r6
|
|
166
|
+
// major 36 — the shared severity law; native's boundary gates the same way): a
|
|
167
|
+
// present-but-non-function member is a typed refusal, never a raw crash inside
|
|
168
|
+
// motion's recognizer dispatch.
|
|
169
|
+
// The gesture-state pair (R6 object form; R7 adds the LABEL form, REQ-API-032):
|
|
170
|
+
// labels validate string-only (naming the index) and PASS to motion — motion
|
|
171
|
+
// resolves them against its own variants context (law b's web leg; REQ-WEB-011).
|
|
172
|
+
// Object-form depth mirrors the NATIVE gate (r1 major 6) — null/non-object shapes
|
|
173
|
+
// refuse typed, the embedded transition splits, and the remaining target validates
|
|
174
|
+
// through core exactly like animate.
|
|
175
|
+
if ((key === 'whileTap' || key === 'whileDrag') && value !== undefined) {
|
|
176
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
177
|
+
const labels = captureLabelFormWithSeverity(key, value, options, componentId)
|
|
178
|
+
if (labels !== undefined) props[key] = labels
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
const proto =
|
|
182
|
+
typeof value === 'object' && value !== null ? Object.getPrototypeOf(value) : undefined
|
|
183
|
+
if (
|
|
184
|
+
typeof value !== 'object' ||
|
|
185
|
+
value === null ||
|
|
186
|
+
(proto !== Object.prototype && proto !== null)
|
|
187
|
+
) {
|
|
188
|
+
// PLAIN targets only (r2 major 13): a prototype-carrying object (Date, class
|
|
189
|
+
// instance) destructures to an empty target and would forward silently.
|
|
190
|
+
refuse(
|
|
191
|
+
new MotionWebRejectionError(
|
|
192
|
+
`${componentId}: "${key}" must be a plain target object, received ${value === null ? 'null' : typeof value} (REQ-API-031).`,
|
|
193
|
+
),
|
|
194
|
+
key,
|
|
195
|
+
)
|
|
196
|
+
break
|
|
197
|
+
}
|
|
198
|
+
// Read the embedded transition exactly once, then build the target from the remaining own
|
|
199
|
+
// keys. Destructuring plus Object.entries would invoke an accessor twice and could validate
|
|
200
|
+
// one transition while motion receives another.
|
|
201
|
+
const stateDefinition = value as Record<string, unknown>
|
|
202
|
+
const rawStateTransition = stateDefinition['transition']
|
|
203
|
+
const stateTarget: Record<string, unknown> = Object.create(null)
|
|
204
|
+
for (const memberKey of Object.keys(stateDefinition)) {
|
|
205
|
+
if (memberKey !== 'transition') stateTarget[memberKey] = stateDefinition[memberKey]
|
|
206
|
+
}
|
|
207
|
+
let stateTransition: unknown = rawStateTransition
|
|
208
|
+
if (rawStateTransition !== undefined) {
|
|
209
|
+
try {
|
|
210
|
+
stateTransition = mapWebTransitionAliases(
|
|
211
|
+
rawStateTransition as WebTransition,
|
|
212
|
+
`${componentId}.${key}.transition`,
|
|
213
|
+
)
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!(error instanceof MotionWebRejectionError)) throw error
|
|
216
|
+
refuse(error, `${key}.transition`)
|
|
217
|
+
stateTransition = undefined
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// PER-KEY severity (the shared law, r2 major 13): each member validates alone —
|
|
221
|
+
// development throws on the first offender; production reports it and keeps the
|
|
222
|
+
// valid members.
|
|
223
|
+
const acceptedTarget: Record<string, unknown> = {}
|
|
224
|
+
for (const [memberKey, memberValue] of Object.entries(stateTarget)) {
|
|
225
|
+
// REQ-WEB-019: Motion owns intrinsic-web value grammar and timing. Native Motion's
|
|
226
|
+
// portable complex-value validator deliberately refuses these keys, so applying it
|
|
227
|
+
// here would project the native gap onto the web engine.
|
|
228
|
+
if (UNIVERSAL_SUBSET.get(memberKey)?.universality === 'web-only') {
|
|
229
|
+
acceptedTarget[memberKey] = memberValue
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
// validateTargetSnapshot materializes the member's keyframe ARRAY ONCE before validation
|
|
233
|
+
// and returns that snapshot; the ACCEPTED value is the snapshot, never the caller's raw
|
|
234
|
+
// array (one read, one truth — R8, review major 23), so motion never re-reads an accessor.
|
|
235
|
+
const { refusal: memberRefusal, target: memberSnapshot } = validateTargetSnapshot(
|
|
236
|
+
{ [memberKey]: memberValue } as Target,
|
|
237
|
+
WEB_HOST,
|
|
238
|
+
{
|
|
239
|
+
componentId,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
if (memberRefusal !== null) {
|
|
243
|
+
refuse(
|
|
244
|
+
new MotionWebRejectionError(memberRefusal.message, { cause: memberRefusal }),
|
|
245
|
+
`${key}.${memberKey}`,
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const acceptedValue = (memberSnapshot as Record<string, unknown>)[memberKey]
|
|
250
|
+
acceptedTarget[memberKey] = acceptedValue
|
|
251
|
+
}
|
|
252
|
+
props[key] =
|
|
253
|
+
stateTransition === undefined
|
|
254
|
+
? acceptedTarget
|
|
255
|
+
: { ...acceptedTarget, transition: stateTransition }
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
(key === 'onTap' || key === 'onTapStart' || key === 'onTapCancel') &&
|
|
260
|
+
value !== undefined &&
|
|
261
|
+
typeof value !== 'function'
|
|
262
|
+
) {
|
|
263
|
+
refuse(
|
|
264
|
+
new MotionWebRejectionError(
|
|
265
|
+
`${componentId}: "${key}" must be a function, received ${typeof value} — the tap callbacks are ` +
|
|
266
|
+
'the pinned (event, info) handlers (REQ-API-030).',
|
|
267
|
+
),
|
|
268
|
+
key,
|
|
269
|
+
)
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
props[key] = value
|
|
273
|
+
break
|
|
274
|
+
case 'rejected':
|
|
275
|
+
refuse(
|
|
276
|
+
new MotionWebRejectionError(
|
|
277
|
+
`${componentId}: ${rejectionMessage(key, disposition.reason)}`,
|
|
278
|
+
),
|
|
279
|
+
key,
|
|
280
|
+
)
|
|
281
|
+
break
|
|
282
|
+
case 'normalized':
|
|
283
|
+
// No prop declares 'normalized' yet (REQ-WEB-012 reserves the kind); reaching it means
|
|
284
|
+
// the table gained a row without a pipeline branch — schema drift, fail loud.
|
|
285
|
+
throw new MotionWebIncompatibleError(
|
|
286
|
+
`prop "${key}" declares the 'normalized' disposition but no normalizer is wired`,
|
|
287
|
+
)
|
|
288
|
+
case 'extension-mapped':
|
|
289
|
+
// The View owns the runtime extension mapping (dragSnapPoints → dragTransition, REQ-WEB-013):
|
|
290
|
+
// it destructures the extension family out BEFORE this lane runs. Reaching here means routing
|
|
291
|
+
// dropped it — schema/routing drift, fail loud (never forward an unmapped extension to motion).
|
|
292
|
+
throw new MotionWebIncompatibleError(
|
|
293
|
+
`prop "${key}" is extension-mapped (View-owned, REQ-WEB-013) and must not reach the ` +
|
|
294
|
+
'normalize lane — routing drift',
|
|
295
|
+
)
|
|
296
|
+
default: {
|
|
297
|
+
const exhaustive: never = disposition
|
|
298
|
+
throw new MotionWebIncompatibleError(`unreachable disposition: ${String(exhaustive)}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The ratified border-box pin: RN computes layout as border-box, so the shim pins the same on
|
|
304
|
+
// web or a width animation means a different rendered box per engine. A conflicting user value
|
|
305
|
+
// is refused loudly — the pin always wins, never silently.
|
|
306
|
+
const userStyle = (input['style'] ?? {}) as Readonly<Record<string, unknown>>
|
|
307
|
+
const { boxSizing: userBoxSizing, ...restStyle } = userStyle
|
|
308
|
+
if (userBoxSizing !== undefined && userBoxSizing !== BOX_SIZING_PIN) {
|
|
309
|
+
refuse(
|
|
310
|
+
new MotionWebRejectionError(
|
|
311
|
+
`${componentId}: style.boxSizing: ${describeValue(userBoxSizing)} conflicts with the shim's border-box pin ` +
|
|
312
|
+
'(ratified 2026-07-06): cross-engine layout parity requires border-box',
|
|
313
|
+
),
|
|
314
|
+
'style.boxSizing',
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
props['style'] = { ...restStyle, boxSizing: BOX_SIZING_PIN }
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
props,
|
|
321
|
+
refused,
|
|
322
|
+
attemptedElementTransitionOwners: Object.freeze(attemptedElementTransitionOwners),
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/snap.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// dragSnapPoints → dragTransition.modifyTarget MAPPING (REQ-WEB-013). The public shape is ratified
|
|
2
|
+
// (2026-07-06): ABSOLUTE positions in the drag value's own coordinate space, one array per axis.
|
|
3
|
+
// motion/react's modifyTarget is `(v: number) => number` with NO axis identity (verified against
|
|
4
|
+
// the pinned motion-dom@12.42.2 types).
|
|
5
|
+
//
|
|
6
|
+
// VALIDATION of the drag family (shape verdicts, axis consistency, both-axes/free-drag deferrals)
|
|
7
|
+
// is single-sourced in core (`@unrulysystems/native-motion-core` resolveDragConfig, FLAG 5a) so
|
|
8
|
+
// both engines resolve identical accepted sets. This module is now the web-specific MAPPING only:
|
|
9
|
+
// nearestSnap turns core-validated points into the modifyTarget projection the web View forwards.
|
|
10
|
+
import { MotionWebIncompatibleError } from './errors'
|
|
11
|
+
|
|
12
|
+
export type DragAxis = 'x' | 'y' | true
|
|
13
|
+
|
|
14
|
+
export interface DragSnapPoints {
|
|
15
|
+
readonly x?: readonly number[]
|
|
16
|
+
readonly y?: readonly number[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WebDragTransition {
|
|
20
|
+
readonly modifyTarget: (target: number) => number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function describeShape(value: unknown): string {
|
|
24
|
+
if (value === null) return 'null'
|
|
25
|
+
if (Array.isArray(value)) return 'an array'
|
|
26
|
+
return typeof value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A defensive guard on nearestSnap's own public input (it is independently callable): a non-array
|
|
30
|
+
// or non-finite member is a TYPED failure, never a raw TypeError. Core has already validated the
|
|
31
|
+
// points that reach the web View, so this is defense-in-depth on the standalone mapping.
|
|
32
|
+
function invalidSnapSet(
|
|
33
|
+
points: readonly number[],
|
|
34
|
+
label: string,
|
|
35
|
+
): MotionWebIncompatibleError | null {
|
|
36
|
+
if (!Array.isArray(points)) {
|
|
37
|
+
return new MotionWebIncompatibleError(
|
|
38
|
+
`${label} must be an array of finite numbers, got ${describeShape(points)}`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
if (points.length === 0) {
|
|
42
|
+
return new MotionWebIncompatibleError(`${label} must declare at least one snap point`)
|
|
43
|
+
}
|
|
44
|
+
for (const point of points) {
|
|
45
|
+
if (typeof point !== 'number' || !Number.isFinite(point)) {
|
|
46
|
+
return new MotionWebIncompatibleError(
|
|
47
|
+
`${label} must contain only finite numbers, got ${String(point)}`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function nearestSnap(snapPoints: readonly number[]): (target: number) => number {
|
|
55
|
+
const invalid = invalidSnapSet(snapPoints, 'snap points')
|
|
56
|
+
if (invalid) throw invalid
|
|
57
|
+
// Ascending order + strict improvement makes exact-midpoint ties resolve toward the SMALLER
|
|
58
|
+
// snap value, so projection is deterministic and independent of declaration order.
|
|
59
|
+
const points = [...snapPoints].sort((a, b) => a - b)
|
|
60
|
+
return (target: number) => {
|
|
61
|
+
if (!Number.isFinite(target)) {
|
|
62
|
+
throw new MotionWebIncompatibleError(
|
|
63
|
+
`snap projection requires a finite target, got ${String(target)}`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
let best = Number.NaN
|
|
67
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
68
|
+
for (const point of points) {
|
|
69
|
+
const distance = Math.abs(target - point)
|
|
70
|
+
if (distance < bestDistance) {
|
|
71
|
+
best = point
|
|
72
|
+
bestDistance = distance
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return best
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The web mapping: core-validated points → the dragTransition motion.div receives (REQ-WEB-013).
|
|
80
|
+
export function snapPointsToDragTransition(points: readonly number[]): WebDragTransition {
|
|
81
|
+
return { modifyTarget: nearestSnap(points) }
|
|
82
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// REQ-API-039 — web useAnimate entry: thin re-export of the pin's useAnimate from motion/react
|
|
3
|
+
// (REQ-WEB-011). No custom web animation engine; scope + playback are the pin's.
|
|
4
|
+
//
|
|
5
|
+
// Public return type is a SHALLOW pair (not pin generics) so dual-entry type-identity does not
|
|
6
|
+
// hang tsgo on motion's scoped-animate overload surface.
|
|
7
|
+
|
|
8
|
+
import { useAnimate as pinUseAnimate } from 'motion/react'
|
|
9
|
+
import type { AnimationPlaybackControls } from './animate'
|
|
10
|
+
|
|
11
|
+
export interface AnimationScope<T = unknown> {
|
|
12
|
+
current: T | null
|
|
13
|
+
animations: AnimationPlaybackControls[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type UseAnimateReturn = ReturnType<typeof pinUseAnimate>
|
|
17
|
+
|
|
18
|
+
export const useAnimate = pinUseAnimate
|
package/src/useCycle.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// REQ-API-040 — useCycle web entry: thin re-export of the pin's useCycle from motion/react
|
|
3
|
+
// (REQ-WEB-011). Zero new semantics; native owns the React-state twin for RN.
|
|
4
|
+
// Cycle/CycleState type names are declared here for dual-entry type-export parity (native
|
|
5
|
+
// exports the same names); runtime is solely the pin hook.
|
|
6
|
+
|
|
7
|
+
import { useCycle as pinUseCycle } from 'motion/react'
|
|
8
|
+
|
|
9
|
+
export type Cycle = (i?: number) => void
|
|
10
|
+
export type CycleState<T> = [T, Cycle]
|
|
11
|
+
|
|
12
|
+
export const useCycle: typeof pinUseCycle = pinUseCycle
|
package/src/useScroll.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// REQ-API-037 — raw pin useScroll on web. The named option/return aliases remain the finite
|
|
3
|
+
// Native Motion compatibility mapping; the exported hook value itself retains the pin's exact
|
|
4
|
+
// signature, lifecycle, option ownership, and fault identity.
|
|
5
|
+
|
|
6
|
+
import { useScroll as pinUseScroll } from 'motion/react'
|
|
7
|
+
import type { RefObject } from 'react'
|
|
8
|
+
|
|
9
|
+
export type UseScrollOptions = {
|
|
10
|
+
readonly container?: RefObject<HTMLElement | null> | undefined
|
|
11
|
+
readonly target?: RefObject<HTMLElement | null> | undefined
|
|
12
|
+
readonly axis?: 'x' | 'y' | undefined
|
|
13
|
+
readonly offset?: readonly unknown[] | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Pin return shape — engine MotionValues (motion/react), name-parity with the native entry. */
|
|
17
|
+
export type ScrollMotionValues = ReturnType<typeof pinUseScroll>
|
|
18
|
+
|
|
19
|
+
export const useScroll = pinUseScroll
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Transition as CoreTransition } from '@unrulysystems/native-motion-core'
|
|
2
|
+
import type { Transition as PinTransition } from 'motion/react'
|
|
3
|
+
import { MotionWebRejectionError } from './errors'
|
|
4
|
+
|
|
5
|
+
/** Motion's full web transition surface plus Native Motion's portable easing aliases. */
|
|
6
|
+
export type WebTransition = PinTransition | CoreTransition
|
|
7
|
+
|
|
8
|
+
function mapFlatAliases(value: unknown, owner: string): unknown {
|
|
9
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
|
|
10
|
+
const source = value as Record<string, unknown>
|
|
11
|
+
const aliases = (['easing', 'easings'] as const)
|
|
12
|
+
.filter((key) => Object.hasOwn(source, key))
|
|
13
|
+
.map((key) => ({ key, value: source[key] }))
|
|
14
|
+
.filter((entry) => entry.value !== undefined)
|
|
15
|
+
if (aliases.length === 0) return value
|
|
16
|
+
const ease = Object.hasOwn(source, 'ease') ? source['ease'] : undefined
|
|
17
|
+
if (aliases.length > 1 || ease !== undefined) {
|
|
18
|
+
const active = [...(ease === undefined ? [] : ['ease']), ...aliases.map(({ key }) => key)]
|
|
19
|
+
throw new MotionWebRejectionError(
|
|
20
|
+
`${owner}: transition may declare only one of ease, easing, or easings; received ${active.join(', ')}`,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
const mapped: Record<PropertyKey, unknown> = Object.create(Object.getPrototypeOf(value))
|
|
24
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
25
|
+
if (key === 'easing' || key === 'easings') continue
|
|
26
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
27
|
+
if (descriptor !== undefined) Object.defineProperty(mapped, key, descriptor)
|
|
28
|
+
}
|
|
29
|
+
Object.defineProperty(mapped, 'ease', {
|
|
30
|
+
value: aliases[0]!.value,
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
})
|
|
35
|
+
return mapped
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Map only Native Motion's easing aliases. Identity is preserved when no alias occurs. Property,
|
|
40
|
+
* default, and layout transition bags are mapped one level beneath the root, matching Motion's
|
|
41
|
+
* transition-with-value-overrides shape without inspecting option payloads recursively.
|
|
42
|
+
*/
|
|
43
|
+
export function mapWebTransitionAliases(
|
|
44
|
+
transition: WebTransition,
|
|
45
|
+
owner = '<View>',
|
|
46
|
+
): WebTransition {
|
|
47
|
+
const root = mapFlatAliases(transition, owner)
|
|
48
|
+
if (typeof root !== 'object' || root === null || Array.isArray(root)) return root as WebTransition
|
|
49
|
+
const source = root as Record<string, unknown>
|
|
50
|
+
const replacements = new Map<string, unknown>()
|
|
51
|
+
for (const key of Object.keys(source)) {
|
|
52
|
+
const original = source[key]
|
|
53
|
+
const nested = mapFlatAliases(original, `${owner}.${key}`)
|
|
54
|
+
if (nested === original) continue
|
|
55
|
+
replacements.set(key, nested)
|
|
56
|
+
}
|
|
57
|
+
if (replacements.size === 0) return root as WebTransition
|
|
58
|
+
const descriptors = Object.getOwnPropertyDescriptors(root)
|
|
59
|
+
for (const [key, nested] of replacements) {
|
|
60
|
+
const original = descriptors[key]
|
|
61
|
+
descriptors[key] = {
|
|
62
|
+
value: nested,
|
|
63
|
+
enumerable: original?.enumerable === true,
|
|
64
|
+
configurable: original?.configurable ?? true,
|
|
65
|
+
writable: original !== undefined && 'writable' in original ? original.writable : true,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.create(Object.getPrototypeOf(root), descriptors) as WebTransition
|
|
69
|
+
}
|