Skip to content

packages/core/src/lib/hooks/use-visibility-transition.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { signal } from '../signals/graph.js';
2 import type { Signal } from '../signals/types.js';
3 import { drive } from '../animation/program.js';
4 import { defineAnimation, tween } from '../animation/definition.js';
5 import type { PibblPlayback } from '../animation/types.js';
6 import { createTransitionBinding, type PibblTransitionBinding } from '../transitions/binding.js';
7 import { createTransitionRuntime, transitionEffectSampler, transitionEffectTail, type PibblTransitionEffect } from '../transitions/fade.js';
8 import { useHookSlot } from './hook-slot.js';
9 import { queueBeginCommand } from '../scheduler/realm-scheduler.js';
10 import type { PibblBeginCommandHandle } from '../scheduler/types.js';
11 import { usePlayback } from './use-playback.js';
12 
13 /**
14  * The current hidden, entering, visible, exiting, or disposed transition state.
15  *
16  * @see {@link PibblVisibilityTransition}
17  */
18 export type PibblVisibilityTransitionStatus = 'hidden' | 'entering' | 'visible' | 'exiting' | 'disposed';
19 /**
20  * Settlement of one show or hide request, including its request identifier, phase, and outcome.
21  *
22  * @see {@link PibblVisibilityTransition}
23  */
24 export interface PibblVisibilityTransitionResult {
25   /**
26    * Monotonic identifier of the visibility request that produced this result. See
27    * {@link PibblVisibilityTransitionResult}.
28    */
29   readonly requestId: number;
30   /** Selects `"enter"`, `"exit"` for phase. See {@link PibblVisibilityTransitionResult}. */
31   readonly phase: 'enter' | 'exit';
32   /** How this specific show or hide request settled. See {@link PibblVisibilityTransitionResult}. */
33   readonly status: 'finished' | 'cancelled' | 'disposed';
34 }
35 /**
36  * Enter and exit effects and lifecycle configuration for visibility transitions.
37  *
38  * @see {@link PibblTransitionEffect}
39  * @see {@link useVisibilityTransition}
40  */
41 export interface PibblVisibilityTransitionOptions {
42   /** Mount-time options in the first slice. */
43   readonly initial?: boolean;
44   /** Effect used when moving toward visible. See {@link PibblTransitionEffect}. */
45   readonly enter: PibblTransitionEffect;
46   /**
47    * Effect used when moving toward hidden; defaults to the enter effect. See
48    * {@link PibblTransitionEffect}.
49    */
50   readonly exit?: PibblTransitionEffect;
51   /**
52    * Transition duration in milliseconds; defaults to 300. See
53    * {@link PibblVisibilityTransitionOptions}.
54    */
55   readonly duration?: number;
56 }
57 /**
58  * Mount-owned visibility controls, reactive progress and status, and a primitive presentation binding.
59  *
60  * @see {@link PibblTransitionBinding}
61  * @see {@link Signal}
62  * @see {@link PibblVisibilityTransitionStatus}
63  * @see {@link PibblVisibilityTransitionResult}
64  * @see {@link useVisibilityTransition}
65  */
66 export interface PibblVisibilityTransition {
67   /**
68    * Presentation binding to assign to the receiving primitive's style.transition. See
69    * {@link PibblTransitionBinding}.
70    */
71   readonly binding: PibblTransitionBinding;
72   /** Readonly view of the requested visibility target. See {@link Signal}. */
73   readonly visible: Signal<boolean>;
74   /** Normalized progress through the current animation or transition. See {@link Signal}. */
75   readonly progress: Signal<number>;
76   /**
77    * Reactive visibility-transition lifecycle state. See {@link Signal},
78    * {@link PibblVisibilityTransitionStatus}.
79    */
80   readonly status: Signal<PibblVisibilityTransitionStatus>;
81   /**
82    * Requests entry and resolves with the request's finished, cancelled, or disposed outcome. See
83    * {@link PibblVisibilityTransitionResult}.
84    * @returns A promise settling with the request's completion or interruption result. See
85    * {@link PibblVisibilityTransitionResult} .
86    */
87   show(): Promise<PibblVisibilityTransitionResult>;
88   /**
89    * Requests exit and resolves with the request's finished, cancelled, or disposed outcome. See
90    * {@link PibblVisibilityTransitionResult}.
91    * @returns A promise settling with the request's completion or interruption result. See
92    * {@link PibblVisibilityTransitionResult} .
93    */
94   hide(): Promise<PibblVisibilityTransitionResult>;
95   /**
96    * Forces the currently requested transition to its endpoint. See {@link PibblVisibilityTransition}
97    * .
98    */
99   finish(): void;
100 }
101 
102 /**
103  * Explicitly controlled visibility. It does not retain unmounted content or alter targeting.
104  *
105  * @param options - Visibility signal, effect, timing, and lifecycle options. See
106  * {@link PibblVisibilityTransitionOptions} .
107  * @returns Stable mount-owned controls for visibility and transition completion. See
108  * {@link PibblVisibilityTransition} .
109  *
110  * @see {@link PibblVisibilityTransitionOptions}
111  * @see {@link PibblVisibilityTransition}
112  */
113 export function useVisibilityTransition(options: PibblVisibilityTransitionOptions): PibblVisibilityTransition {
114   const duration = options.duration ?? 300;
115   if (!Number.isFinite(duration) || duration < 0) throw new RangeError('Visibility transition duration must be finite and nonnegative.');
116   if (options.initial !== undefined && typeof options.initial !== 'boolean') throw new TypeError('Visibility transition initial must be boolean.');
117   transitionEffectSampler(options.enter);
118   transitionEffectSampler(options.exit ?? options.enter);
119   const enterTail = transitionEffectTail(options.enter), exitTail = transitionEffectTail(options.exit ?? options.enter);
120   const state = useHookSlot('visibility-transition', teardowns => {
121     const enterRuntime = createTransitionRuntime(options.enter);
122     const exitRuntime = options.exit && options.exit !== options.enter ? createTransitionRuntime(options.exit) : enterRuntime;
123     const runtimes = [...new Set([enterRuntime, exitRuntime])];
124     const enter = enterRuntime.sample, exit = exitRuntime.sample;
125     const visible = signal(options.initial ?? true);
126     const progress = signal(visible.get() ? 1 : 0);
127     const status = signal<PibblVisibilityTransitionStatus>(visible.get() ? 'visible' : 'hidden');
128     let disposed = false, serial = 0;
129     let pending: { id: number; target: boolean; resolve: (result: PibblVisibilityTransitionResult) => void } | undefined;
130     const elapsed = signal(0), tailTime = signal(0);
131     const maxTail = Math.max(enterTail, exitTail);
132     let playback!: PibblPlayback, clock!: PibblPlayback, tail!: PibblPlayback;
133     let tailRunning = false;
134     const forced = signal(false);
135     let runStart = 0, runInitial = progress.get();
136     const historyCommands = new Set<PibblBeginCommandHandle>();
137     let history: { time: number; progress: number; direction: number }[] = [];
138     const progressAt = (time: number) => {
139       if (!Number.isFinite(time)) throw new RangeError('progressAt requires a finite time.');
140       if (time < 0) return runInitial;
141       const segment = history.findLast(value => value.time <= time);
142       if (!segment) return runInitial;
143       return Math.max(0, Math.min(1, segment.progress + segment.direction * (duration === 0 ? 1 : (time - segment.time) / duration)));
144     };
145     let direction: 'enter' | 'exit' = 'enter';
146     const settle = (outcome: PibblVisibilityTransitionResult['status']) => {
147       const request = pending; pending = undefined;
148       request?.resolve(Object.freeze({ requestId: request.id, phase: request.target ? 'enter' : 'exit', status: outcome }));
149     };
150     const request = (target: boolean): Promise<PibblVisibilityTransitionResult> => {
151       const id = ++serial, phase = target ? 'enter' : 'exit';
152       if (disposed) return Promise.resolve({ requestId: id, phase, status: 'disposed' });
153       // The signal write also enforces Pibbl's prohibition on commands during rendering.
154       visible.set(target);
155       const wasActive = pending !== undefined;
156       const wasTailing = tailRunning;
157       tailRunning = false; forced.set(false); if (wasTailing) tail.cancel();
158       settle('cancelled');
159       if (!wasActive && progress.get() === Number(target) && playback.status.get() !== 'running') {
160         status.set(target ? 'visible' : 'hidden');
161         return Promise.resolve({ requestId: id, phase, status: 'finished' });
162       }
163       const promise = new Promise<PibblVisibilityTransitionResult>(resolve => { pending = { id, target, resolve }; });
164       status.set(target ? 'entering' : 'exiting');
165       if (!wasActive) clock.play();
166       if (!wasActive || wasTailing || direction !== phase) {
167         const at = wasActive ? elapsed.get() : 0, from = progress.get();
168         let command!: PibblBeginCommandHandle;
169         command = queueBeginCommand(snapshot => {
170           historyCommands.delete(command);
171           if (disposed) return;
172           if (!wasActive) { history = []; runInitial = from; runStart = snapshot.logicalTime; for (const runtime of runtimes) runtime.reset?.(); }
173           const append = (time: number, slope: number) => {
174             if (history.at(-1)?.time === time) history.pop();
175             history.push({ time, progress: from, direction: slope });
176           };
177           // Playback changes direction in Begin, holding the last committed value
178           // until that frame. Keep that hold and all earlier run segments.
179           append(at, 0);
180           append(snapshot.logicalTime - runStart, target ? 1 : -1);
181         });
182         historyCommands.add(command);
183       }
184       if (wasTailing && progress.get() === Number(target)) { startTail(); return promise; }
185       if ((wasActive && !wasTailing) || playback.status.get() === 'running' || playback.status.get() === 'paused' || playback.status.get() === 'scheduled') {
186         if (direction !== phase) playback.reverse();
187         playback.resume();
188       } else if (target) playback.play();
189       else playback.reverse();
190       direction = phase;
191       return promise;
192     };
193     const complete = () => {
194       if (disposed || !pending || progress.get() !== Number(pending.target)) return;
195       tailRunning = false; clock.pause();
196       status.set(pending.target ? 'visible' : 'hidden'); settle('finished');
197     };
198     const startTail = () => {
199       if (disposed || !pending || progress.get() !== Number(pending.target)) return;
200       const length = pending.target ? enterTail : exitTail;
201       if (!length || forced.get()) { complete(); return; }
202       tailRunning = true; tail.play(); tail.setPlaybackRate(maxTail / length);
203     };
204     const binding = createTransitionBinding(() => {
205       const active = (status.get() === 'entering' || status.get() === 'exiting') && !forced.get();
206       return (visible.get() ? enter : exit)(progress.get(), visible.get() ? 'enter' : 'exit', {
207         active, elapsed: active ? elapsed.get() : 0, progressAt,
208       });
209     });
210     const handle: PibblVisibilityTransition = Object.freeze({
211       binding, visible: visible.asReadonly(), progress: progress.asReadonly(), status: status.asReadonly(),
212       show: () => request(true), hide: () => request(false), finish: () => { if (!disposed && pending) { forced.set(true); if (tailRunning) tail.finish(); else if (pending.target) playback.finish(); else playback.seek(0); } },
213     });
214     teardowns.add(() => { disposed = true; for (const runtime of runtimes) runtime.dispose?.(); history = []; for (const command of historyCommands) command.cancel(); historyCommands.clear(); status.set('disposed'); settle('disposed'); });
215     return {
216       handle, program: drive(progress, duration === 0 ? defineAnimation({ duration: 0, sample: () => Number(visible.get()) }) : tween({ from: 0, to: 1, duration })),
217       clockProgram: drive(elapsed, defineAnimation({ duration: 1e12, sample: ({ localTime }) => {
218         if (pending && !forced.get()) (visible.get() ? enterRuntime : exitRuntime).advance?.(localTime, progressAt);
219         return localTime;
220       } })),
221       tailProgram: drive(tailTime, tween({ from: 0, to: 1, duration: maxTail })),
222       setPlayback(value: PibblPlayback, clockValue: PibblPlayback, tailValue: PibblPlayback) { playback = value; clock = clockValue; tail = tailValue; },
223       fail() { disposed = true; for (const runtime of runtimes) runtime.dispose?.(); forced.set(true); status.set('disposed'); settle('disposed'); playback.cancel(); clock.cancel(); tail.cancel(); },
224       startTail,
225       completeTail() { if (tailRunning) complete(); },
226     };
227   }).value;
228   const playback = usePlayback(state.program, { onEvent: event => { if (event.type === 'finish') state.startTail(); } });
229   const clock = usePlayback(state.clockProgram, { onError: error => { state.fail(); throw error; } });
230   const tail = usePlayback(state.tailProgram, { onEvent: event => { if (event.type === 'finish') state.completeTail(); } });
231   state.setPlayback(playback, clock, tail);
232   return state.handle;
233 }
234 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.