@octane-xplat/motion 0.0.0 → 0.7.1

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.
@@ -0,0 +1,122 @@
1
+ import { act, createRoot, useEffect, useState } from 'octane'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { motion, Presence, MotionConfig } from './index.web'
4
+
5
+ let mounts = 0, cleanups = 0
6
+ function Child(props: any) @{
7
+ const [count, setCount] = useState(0)
8
+ useEffect(() => { mounts++; return () => { cleanups++ } }, [])
9
+
10
+ <motion.View id="exiting" initial={false} animate={{ x: 0 }} exit={{ x: 100 }} transition={{ duration: props.duration }}>
11
+ <button id="counter" onClick={() => setCount(count + 1)}>{count}</button>
12
+ </motion.View>
13
+ }
14
+
15
+ function Scene(props: any) @{
16
+ <MotionConfig reducedMotion={props.reduced ? 'always' : 'never'}>
17
+ <Presence present={props.present} onExitComplete={props.done} id="boundary">
18
+ <Child duration={props.duration ?? 0.1} />
19
+ </Presence>
20
+ </MotionConfig>
21
+ }
22
+
23
+ function mount(props: any) {
24
+ const container = document.createElement('div'); document.body.append(container)
25
+ const root = createRoot(container)
26
+ act(() => root.render(<Scene {...props} />))
27
+ return { container, root, update(next: any) { act(() => root.render(<Scene {...next} />)) }, close() { act(() => root.unmount()); container.remove() } }
28
+ }
29
+
30
+ const advance = async (ms: number) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms) }) }
31
+
32
+ describe('retained presence', () => {
33
+ it('retains component state until exit finishes, blocks input and relinquishes focus', async () => {
34
+ vi.useFakeTimers(); mounts = cleanups = 0
35
+ const done = vi.fn(); const r = mount({ present: true, done })
36
+ const node = r.container.querySelector('#exiting')
37
+ const button = r.container.querySelector('button') as HTMLButtonElement
38
+ act(() => button.click()); button.focus()
39
+ expect(button.textContent).toBe('1')
40
+ r.update({ present: false, done })
41
+ expect(r.container.querySelector('#exiting')).toBe(node)
42
+ expect(cleanups).toBe(0)
43
+ expect(r.container.querySelector('#boundary')?.hasAttribute('inert')).toBe(true)
44
+ expect(document.activeElement).not.toBe(button)
45
+ await advance(160)
46
+ expect(r.container.querySelector('#exiting')).toBeNull()
47
+ expect(cleanups).toBe(1); expect(done).toHaveBeenCalledTimes(1)
48
+ r.close(); vi.useRealTimers()
49
+ })
50
+
51
+ it('reverses without remounting or stale removal', async () => {
52
+ vi.useFakeTimers(); mounts = cleanups = 0
53
+ const done = vi.fn(); const r = mount({ present: true, done })
54
+ const node = r.container.querySelector('#exiting')
55
+ r.update({ present: false, done }); await advance(32)
56
+ r.update({ present: true, done }); await advance(200)
57
+ expect(r.container.querySelector('#exiting')).toBe(node)
58
+ expect(mounts).toBe(1); expect(cleanups).toBe(0)
59
+ expect(done).not.toHaveBeenCalled()
60
+ expect(r.container.querySelector('#boundary')?.hasAttribute('inert')).toBe(false)
61
+ r.close(); expect(cleanups).toBe(1); vi.useRealTimers()
62
+ })
63
+
64
+ it('disposes immediately when the boundary unmounts', async () => {
65
+ vi.useFakeTimers(); mounts = cleanups = 0
66
+ const done = vi.fn(); const r = mount({ present: true, done })
67
+ r.update({ present: false, done }); r.close()
68
+ expect(cleanups).toBe(1)
69
+ await advance(200); expect(done).not.toHaveBeenCalled()
70
+ vi.useRealTimers()
71
+ })
72
+
73
+ it('starts absent, supports repeated cycles and instant reduced exits', async () => {
74
+ vi.useFakeTimers(); mounts = cleanups = 0
75
+ const done = vi.fn(); const r = mount({ present: false, done, reduced: true })
76
+ expect(r.container.querySelector('#exiting')).toBeNull()
77
+ for (let i = 0; i < 2; i++) {
78
+ r.update({ present: true, done, reduced: true }); await advance(1)
79
+ expect(r.container.querySelector('#exiting')).not.toBeNull()
80
+ r.update({ present: false, done, reduced: true }); await advance(1)
81
+ expect(r.container.querySelector('#exiting')).toBeNull()
82
+ }
83
+
84
+ expect(done).toHaveBeenCalledTimes(2)
85
+ r.close(); vi.useRealTimers()
86
+ })
87
+ })
88
+
89
+ import { PresenceDemo } from '../examples/PresenceDemo.tsrx'
90
+ it('mounts the retained-presence example', () => {
91
+ const container = document.createElement('div'); document.body.append(container)
92
+ const root = createRoot(container)
93
+ act(() => root.render(<PresenceDemo />))
94
+ expect(container.textContent).toContain('Count: 0')
95
+ act(() => root.unmount()); container.remove()
96
+ })
97
+
98
+ import { useMotionValue } from './index.web'
99
+ function Independent(props: any) @{
100
+ const x = useMotionValue(0)
101
+ useEffect(() => { props.ready(x) }, [])
102
+
103
+ <Presence present={props.present}>
104
+ <motion.View id="independent" style={{ x }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.1 }} />
105
+ </Presence>
106
+ }
107
+
108
+ it('exit reversal does not take ownership of independent MotionValue channels', async () => {
109
+ vi.useFakeTimers()
110
+ let x: any
111
+ const ready = (value: any) => { x = value }
112
+ const container = document.createElement('div'); document.body.append(container)
113
+ const root = createRoot(container)
114
+ act(() => root.render(<Independent present={true} ready={ready} />))
115
+ act(() => root.render(<Independent present={false} ready={ready} />))
116
+ await advance(32)
117
+ x.set(50)
118
+ act(() => root.render(<Independent present={true} ready={ready} />))
119
+ await advance(200)
120
+ expect((container.querySelector('#independent') as HTMLElement).style.transform).toContain('translateX(50px)')
121
+ act(() => root.unmount()); container.remove(); vi.useRealTimers()
122
+ })
@@ -0,0 +1,37 @@
1
+ import { Application, Utils } from '@nativescript/core'
2
+
3
+ export function readReducedMotion(): boolean {
4
+ const platform = globalThis as any
5
+ if (Application.ios) {
6
+ return Boolean(platform.UIAccessibilityIsReduceMotionEnabled?.())
7
+ }
8
+
9
+ const context = Utils.android.getApplicationContext()
10
+ return (
11
+ platform.android.provider.Settings.Global.getFloat(
12
+ context.getContentResolver(),
13
+ 'animator_duration_scale',
14
+ 1,
15
+ ) === 0
16
+ )
17
+ }
18
+
19
+ // Poll only while subscribed; this also detects Android developer-setting changes
20
+ // without owning a Java ContentObserver class. Resume refreshes immediately.
21
+ export function observeReducedMotion(notify: () => void): () => void {
22
+ let previous = readReducedMotion()
23
+ const check = () => {
24
+ const next = readReducedMotion()
25
+ if (next !== previous) {
26
+ previous = next
27
+ notify()
28
+ }
29
+ }
30
+
31
+ const timer = setInterval(check, 500)
32
+ Application.on(Application.resumeEvent, check)
33
+ return () => {
34
+ clearInterval(timer)
35
+ Application.off(Application.resumeEvent, check)
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ export function readReducedMotion(): boolean {
2
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
3
+ }
4
+
5
+ export function observeReducedMotion(notify: () => void): () => void {
6
+ if (typeof matchMedia !== 'function') {
7
+ return () => {}
8
+ }
9
+
10
+ const media = matchMedia('(prefers-reduced-motion: reduce)')
11
+ media.addEventListener('change', notify)
12
+ return () => media.removeEventListener('change', notify)
13
+ }
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ViewProps, RowProps, PressableProps } from '@octane-xplat/ui'
2
+ import type { MotionValue } from './value'
3
+
4
+ /** Supported channels. Translation is CSS pixels/DIP; rotation is degrees. */
5
+ export type MotionKey = 'opacity' | 'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' | 'rotate'
6
+ /** Absolute numeric destinations; omitted channels retain their current values. */
7
+ export type Target = Partial<Record<MotionKey, number>>
8
+ /** Tween easing, including a CSS-compatible cubic Bezier tuple. */
9
+ export type Ease = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | [number, number, number, number]
10
+ /** Timing is seconds. Spring velocity is units per second. */
11
+ export interface Transition {
12
+ type?: 'tween' | 'spring'
13
+ duration?: number
14
+ delay?: number
15
+ ease?: Ease
16
+ stiffness?: number
17
+ damping?: number
18
+ mass?: number
19
+ velocity?: number
20
+ restSpeed?: number
21
+ restDelta?: number
22
+ }
23
+
24
+ /** Static platform styles plus numeric motion-value bindings. */
25
+ export type MotionStyle = Record<string, unknown> & Partial<Record<MotionKey, number | MotionValue>>
26
+ /** Supported declarative motion controls. */
27
+ export interface MotionProps {
28
+ initial?: Target | false
29
+ /** Destination while the nearest Presence boundary retains this host for exit. */
30
+ exit?: Target
31
+ animate?: Target
32
+ transition?: Transition
33
+ style?: MotionStyle
34
+ onAnimationComplete?: () => void
35
+ }
36
+
37
+ /** View layout and accessibility props with motion controls. */
38
+ export type MotionViewProps = Omit<ViewProps, 'style'> & MotionProps
39
+ /** Row layout and accessibility props with motion controls. */
40
+ export type MotionRowProps = Omit<RowProps, 'style'> & MotionProps
41
+ /** Pressable interaction props with motion controls. */
42
+ export type MotionPressableProps = Omit<PressableProps, 'style'> & MotionProps
43
+ /** Inherited defaults; `user` observes the live system preference. */
44
+ export interface MotionConfigProps {
45
+ transition?: Transition
46
+ reducedMotion?: 'always' | 'never' | 'user'
47
+ children?: any
48
+ }
49
+
50
+ /** Presence renders a View wrapper; use its layout props to size retained content. */
51
+ export interface PresenceProps extends ViewProps {
52
+ present: boolean
53
+ /** Called once after all registered exits finish, never after reversal/disposal. */
54
+ onExitComplete?: () => void
55
+ }
package/src/value.ts ADDED
@@ -0,0 +1,167 @@
1
+ import type { Clock } from './clock-types'
2
+ import type { Transition } from './types'
3
+ import { runAnimation, validateTransition, type AnimationControls } from './engine'
4
+
5
+ /** Events supported by numeric motion values. */
6
+ export interface MotionValueEvents {
7
+ change: (value: number) => void
8
+ animationStart: () => void
9
+ animationComplete: () => void
10
+ animationCancel: () => void
11
+ destroy: () => void
12
+ }
13
+
14
+ /** Numeric value with direct subscriptions; it does not subscribe Octane renders. */
15
+ export class MotionValue {
16
+ private current: number
17
+ private previous: number
18
+ private velocity = 0
19
+ private updated: number
20
+ private controls?: AnimationControls
21
+ private generation = 0
22
+ private listeners = new Map<keyof MotionValueEvents, Set<Function>>()
23
+ private passive?: (value: number) => void
24
+ private disposed = false
25
+ constructor(
26
+ initial: number,
27
+ private clock: Clock,
28
+ ) {
29
+ this.assert(initial)
30
+ this.current = this.previous = initial
31
+ this.updated = clock.now()
32
+ }
33
+ private assert(value: number) {
34
+ if (!Number.isFinite(value)) {
35
+ throw new Error('motion: values must be finite numbers')
36
+ }
37
+ }
38
+ /** Read the current sample. */
39
+ get() {
40
+ return this.current
41
+ }
42
+ /** Read the previous sample. */
43
+ getPrevious() {
44
+ return this.previous
45
+ }
46
+ /** Velocity in units per second; stale samples have zero velocity. */
47
+ getVelocity() {
48
+ return this.clock.now() - this.updated > 30 ? 0 : this.velocity
49
+ }
50
+ /** Set immediately, or animate when this is a useSpring value. */
51
+ set(value: number) {
52
+ this.assert(value)
53
+ if (this.disposed) {
54
+ return
55
+ }
56
+
57
+ if (this.passive) {
58
+ this.passive(value)
59
+ } else {
60
+ this.stop()
61
+ this.write(value)
62
+ }
63
+ }
64
+ /** Snap and stop current playback, resetting velocity. */
65
+ jump(value: number) {
66
+ this.assert(value)
67
+ this.stop()
68
+ this.write(value)
69
+ this.velocity = 0
70
+ }
71
+ /** Subscribe without causing a component render. Returns an unsubscribe function. */
72
+ on<K extends keyof MotionValueEvents>(event: K, callback: MotionValueEvents[K]): () => void {
73
+ let listeners = this.listeners.get(event)
74
+ if (!listeners) {
75
+ this.listeners.set(event, (listeners = new Set()))
76
+ }
77
+
78
+ listeners.add(callback)
79
+ return () => {
80
+ listeners.delete(callback)
81
+ }
82
+ }
83
+ private emit(event: keyof MotionValueEvents, value?: number) {
84
+ // Snapshot because subscribers may unsubscribe or add listeners while notified.
85
+ // eslint-disable-next-line unicorn/no-useless-spread
86
+ for (const callback of [...(this.listeners.get(event) ?? [])]) {
87
+ callback(value)
88
+ }
89
+ }
90
+ private write(value: number) {
91
+ if (this.disposed) {
92
+ return
93
+ }
94
+
95
+ const now = this.clock.now()
96
+ const elapsed = now - this.updated
97
+ if (elapsed > 0) {
98
+ this.velocity = elapsed > 30 ? 0 : ((value - this.current) * 1000) / elapsed
99
+ }
100
+
101
+ this.previous = this.current
102
+ this.current = value
103
+ this.updated = now
104
+ if (value !== this.previous) {
105
+ this.emit('change', value)
106
+ }
107
+ }
108
+ /** Animate to a destination. New playback replaces old playback. */
109
+ animate(target: number, transition: Transition = {}): AnimationControls {
110
+ this.assert(target)
111
+ validateTransition(transition)
112
+ if (this.disposed) {
113
+ return { finished: Promise.resolve('cancelled'), stop() {} }
114
+ }
115
+
116
+ const velocity = transition.velocity ?? this.getVelocity()
117
+ this.stop('replaced')
118
+ const generation = ++this.generation
119
+ this.emit('animationStart')
120
+ const controls = runAnimation(
121
+ this.clock,
122
+ this.current,
123
+ target,
124
+ { ...transition, velocity },
125
+ (value) => this.write(value),
126
+ )
127
+
128
+ this.controls = controls
129
+ void controls.finished.then((result) => {
130
+ if (generation !== this.generation || this.disposed) {
131
+ return
132
+ }
133
+
134
+ this.controls = undefined
135
+ if (result === 'finished') {
136
+ this.emit('animationComplete')
137
+ }
138
+ })
139
+
140
+ return controls
141
+ }
142
+ /** Stop playback at its current value. */
143
+ stop(reason: 'cancelled' | 'replaced' = 'cancelled') {
144
+ ++this.generation
145
+ if (this.controls) {
146
+ this.controls.stop(reason)
147
+ this.controls = undefined
148
+ this.emit('animationCancel')
149
+ }
150
+ }
151
+ /** Release playback and listeners when the owning component unmounts. */
152
+ destroy() {
153
+ this.stop()
154
+ this.emit('destroy')
155
+ this.listeners.clear()
156
+ this.passive = undefined
157
+ this.disposed = true
158
+ }
159
+ /** @internal Install the useSpring setter interception. */
160
+ attach(setter: (value: number) => void) {
161
+ this.passive = setter
162
+ return () => {
163
+ this.passive = undefined
164
+ this.stop()
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,6 @@
1
+ /** Frame scheduler; timestamps and elapsed time are milliseconds. */
2
+ export interface Clock {
3
+ now(): number;
4
+ request(callback: () => void): number;
5
+ cancel(id: number): void;
6
+ }
@@ -0,0 +1,11 @@
1
+ import type { Clock } from './clock-types';
2
+ import type { Transition } from './types';
3
+ /** Terminal status; cancellation never masquerades as completion. */
4
+ export type AnimationResult = 'finished' | 'cancelled' | 'replaced';
5
+ /** A cancellable animation whose finished promise always settles. */
6
+ export interface AnimationControls {
7
+ finished: Promise<AnimationResult>;
8
+ stop(reason?: Exclude<AnimationResult, 'finished'>): void;
9
+ }
10
+ export declare function validateTransition(t: Transition): void;
11
+ export declare function runAnimation(clock: Clock, from: number, to: number, transition: Transition, update: (value: number) => void, complete?: () => void): AnimationControls;
@@ -0,0 +1,53 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type {
3
+ MotionViewProps,
4
+ MotionRowProps,
5
+ MotionPressableProps,
6
+ MotionConfigProps,
7
+ Transition,
8
+ } from './types'
9
+ import type { MotionValue, MotionValueEvents } from './value'
10
+ /** Bounded motion hosts for shared xplat UI primitives. */
11
+ export declare const motion: {
12
+ View: UniversalComponent<MotionViewProps>
13
+ Row: UniversalComponent<MotionRowProps>
14
+ Pressable: UniversalComponent<MotionPressableProps>
15
+ }
16
+ /** Inherit transition and reduced-motion defaults within this root. */
17
+ export declare const MotionConfig: UniversalComponent<MotionConfigProps>
18
+ /** Observe the live system reduced-motion preference. */
19
+ export declare function useReducedMotion(): boolean
20
+ /** Create an owned numeric value; writes do not render components. */
21
+ export declare function useMotionValue(initial: number): MotionValue
22
+ /** Subscribe to an event until replacement or unmount. */
23
+ export declare function useMotionValueEvent<K extends keyof MotionValueEvents>(
24
+ value: MotionValue,
25
+ event: K,
26
+ callback: MotionValueEvents[K],
27
+ ): void
28
+ /** Derive a numeric value by mapping ranges or transforming source samples. */
29
+ export declare function useTransform(
30
+ source: MotionValue,
31
+ input: number[],
32
+ output: number[],
33
+ options?: { clamp?: boolean },
34
+ ): MotionValue
35
+ export declare function useTransform(
36
+ source: MotionValue,
37
+ transform: (value: number) => number,
38
+ ): MotionValue
39
+ export declare function useTransform(
40
+ source: MotionValue[],
41
+ transform: (values: number[]) => number,
42
+ ): MotionValue
43
+ /** Spring toward set targets or a source MotionValue; jump snaps. */
44
+ export declare function useSpring(
45
+ source: number | MotionValue,
46
+ options?: Omit<Transition, 'type' | 'duration' | 'ease' | 'delay'>,
47
+ ): MotionValue
48
+ export type { MotionValue, MotionValueEvents }
49
+ export type { AnimationControls, AnimationResult } from './engine'
50
+ export type * from './types'
51
+
52
+ /** Retain live children through exit; removing the boundary disposes immediately. */
53
+ export declare const Presence: UniversalComponent<import('./types').PresenceProps>
@@ -0,0 +1,51 @@
1
+ import type { ViewProps, RowProps, PressableProps } from '@octane-xplat/ui';
2
+ import type { MotionValue } from './value';
3
+ /** Supported channels. Translation is CSS pixels/DIP; rotation is degrees. */
4
+ export type MotionKey = 'opacity' | 'x' | 'y' | 'scale' | 'scaleX' | 'scaleY' | 'rotate';
5
+ /** Absolute numeric destinations; omitted channels retain their current values. */
6
+ export type Target = Partial<Record<MotionKey, number>>;
7
+ /** Tween easing, including a CSS-compatible cubic Bezier tuple. */
8
+ export type Ease = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' | [number, number, number, number];
9
+ /** Timing is seconds. Spring velocity is units per second. */
10
+ export interface Transition {
11
+ type?: 'tween' | 'spring';
12
+ duration?: number;
13
+ delay?: number;
14
+ ease?: Ease;
15
+ stiffness?: number;
16
+ damping?: number;
17
+ mass?: number;
18
+ velocity?: number;
19
+ restSpeed?: number;
20
+ restDelta?: number;
21
+ }
22
+ /** Static platform styles plus numeric motion-value bindings. */
23
+ export type MotionStyle = Record<string, unknown> & Partial<Record<MotionKey, number | MotionValue>>;
24
+ /** Supported declarative motion controls. */
25
+ export interface MotionProps {
26
+ initial?: Target | false;
27
+ /** Destination while the nearest Presence boundary retains this host for exit. */
28
+ exit?: Target;
29
+ animate?: Target;
30
+ transition?: Transition;
31
+ style?: MotionStyle;
32
+ onAnimationComplete?: () => void;
33
+ }
34
+ /** View layout and accessibility props with motion controls. */
35
+ export type MotionViewProps = Omit<ViewProps, 'style'> & MotionProps;
36
+ /** Row layout and accessibility props with motion controls. */
37
+ export type MotionRowProps = Omit<RowProps, 'style'> & MotionProps;
38
+ /** Pressable interaction props with motion controls. */
39
+ export type MotionPressableProps = Omit<PressableProps, 'style'> & MotionProps;
40
+ /** Inherited defaults; `user` observes the live system preference. */
41
+ export interface MotionConfigProps {
42
+ transition?: Transition;
43
+ reducedMotion?: 'always' | 'never' | 'user';
44
+ children?: any;
45
+ }
46
+ /** Presence renders a View wrapper; use its layout props to size retained content. */
47
+ export interface PresenceProps extends ViewProps {
48
+ present: boolean;
49
+ /** Called once after all registered exits finish, never after reversal/disposal. */
50
+ onExitComplete?: () => void;
51
+ }
@@ -0,0 +1,48 @@
1
+ import type { Clock } from './clock-types';
2
+ import type { Transition } from './types';
3
+ import { type AnimationControls } from './engine';
4
+ /** Events supported by numeric motion values. */
5
+ export interface MotionValueEvents {
6
+ change: (value: number) => void;
7
+ animationStart: () => void;
8
+ animationComplete: () => void;
9
+ animationCancel: () => void;
10
+ destroy: () => void;
11
+ }
12
+ /** Numeric value with direct subscriptions; it does not subscribe Octane renders. */
13
+ export declare class MotionValue {
14
+ private clock;
15
+ private current;
16
+ private previous;
17
+ private velocity;
18
+ private updated;
19
+ private controls?;
20
+ private generation;
21
+ private listeners;
22
+ private passive?;
23
+ private disposed;
24
+ constructor(initial: number, clock: Clock);
25
+ private assert;
26
+ /** Read the current sample. */
27
+ get(): number;
28
+ /** Read the previous sample. */
29
+ getPrevious(): number;
30
+ /** Velocity in units per second; stale samples have zero velocity. */
31
+ getVelocity(): number;
32
+ /** Set immediately, or animate when this is a useSpring value. */
33
+ set(value: number): void;
34
+ /** Snap and stop current playback, resetting velocity. */
35
+ jump(value: number): void;
36
+ /** Subscribe without causing a component render. Returns an unsubscribe function. */
37
+ on<K extends keyof MotionValueEvents>(event: K, callback: MotionValueEvents[K]): () => void;
38
+ private emit;
39
+ private write;
40
+ /** Animate to a destination. New playback replaces old playback. */
41
+ animate(target: number, transition?: Transition): AnimationControls;
42
+ /** Stop playback at its current value. */
43
+ stop(reason?: 'cancelled' | 'replaced'): void;
44
+ /** Release playback and listeners when the owning component unmounts. */
45
+ destroy(): void;
46
+ /** @internal Install the useSpring setter interception. */
47
+ attach(setter: (value: number) => void): () => void;
48
+ }