packages/core/src/lib/hooks/use-visibility-transition.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.