UNPKG

@ddtmm/angular-signal-generators

Version:

Specialized Angular signals to help with frequently encountered situations.

1,115 lines (1,094 loc) 87 kB
import { isSignal, DestroyRef, assertInInjectionContext, inject, Injector, isDevMode, computed, effect, untracked, signal, RendererFactory2, ElementRef, PLATFORM_ID } from '@angular/core'; import { SIGNAL, signalSetFn, createSignal, signalUpdateFn } from '@angular/core/primitives/signals'; import { toSignal } from '@angular/core/rxjs-interop'; import { isPlatformBrowser } from '@angular/common'; // export function createInterpolator(currentValue: number): InterpolateFactoryFn<number>; // export function createInterpolator(currentValue: number[]): InterpolateFactoryFn<number[]>; // export function createInterpolator(currentValue: Record<string | number | symbol, number>): InterpolateFactoryFn<Record<string | number | symbol, number>>; /** Creates an interpolator using an initial {@link NumericValues} to determine the type of function to use when interpolating future values.. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function createInterpolator(currentValue) { if (typeof currentValue === 'number') { return (a, b) => (progress) => interpolateNumber(a, b, progress); } else if (Array.isArray(currentValue)) { return (a, b) => (progress) => b.map((val, index) => interpolateNumber(a[index] ?? val, val, progress)); } return (a, b) => (progress) => Object.entries(b).reduce((acc, cur) => { acc[cur[0]] = interpolateNumber(a[cur[0]] ?? cur[1], cur[1], progress); return acc; }, {}); function interpolateNumber(a, b, progress) { return a * (1 - progress) + b * progress; } } /** * Tests if an object is valid for coerceSignal and meets the criteria for being a {@link ReactiveSource}. * * @param obj Any type of a value can be checked. */ function isReactive(obj) { return (obj != null) && (isSignal(obj) || isReactiveSourceFunction(obj) || isToSignalInput(obj)); } /** Is true if obj is a function, it has no arguments, and it is not a signal. */ function isReactiveSourceFunction(obj) { return typeof obj === 'function' && obj.length === 0 && !isSignal(obj); } /** * Determines if an object is a parameter for toSignal by looking for subscribe property. * It does this by seeing if there is a subscribe function. * If toSignal's implementation changes, then this needs to be reviewed. * * @param obj Any type of a value can be checked. */ function isToSignalInput(obj) { return typeof obj?.subscribe === 'function'; } /** * This is inspired by `signalAsReadonlyFn` from https://github.com/angular/angular/blob/main/packages/core/src/render3/reactivity/signal.ts#L90 * It does not cache the readonlyFn, just creates a new one each time. */ function asReadonlyFnFactory($src) { const $readonly = (() => $src()); $readonly[SIGNAL] = $src[SIGNAL]; return () => $readonly; } /** Gets the DestroyRef either using the passed injector or inject function. */ function getDestroyRef(fnType, injector) { if (injector) { return injector.get(DestroyRef); } assertInInjectionContext(fnType); return inject(DestroyRef); } /** Gets the injector, throwing if the function is in injection context. */ function getInjector(fnType) { assertInInjectionContext(fnType); return inject(Injector); } /** * A type safe way for determining a key is in an object. * This is still needed because when "in" is used the propertyType can be inferred as undefined. */ function hasKey(obj, key) { return obj != null && (key in obj); } // an interesting idea - the type passed is just an object's methods // export function isMethodKey<T extends { [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : never }>(obj: T, key: unknown): key is keyof T { // return (typeof obj[key as keyof T] === 'function'); // } /** Detects if a key is a key of an object's method */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function isMethodKey(obj, key) { return obj != null && (typeof obj[key] === 'function'); } /** Sets the debugName on a {@link ReactiveNode} from debugName property if it is set and if {@link isDevMode} returns true. */ function setDebugNameOnNode(node, debugName) { if (isDevMode() && debugName != null) { node.debugName = debugName; } } /** Sets the equal on a {@link SignalNode} if equalFn is defined. */ function setEqualOnNode(node, equalFn) { if (equalFn != null) { node.equal = equalFn; } } /** * Converts a source to a signal. * * If it is already a signal then it is returned. * * If it matches the input type to toSignal function, then it is converted to a signal. * If initial value is passed then it doesn't have to be an async observable. * * If it is just a function then it is converted to a signal with computed. * * @example * ```ts * const signalInput = signal(1); * const functionInput = () => signalInput() * 2; * const immediateInput = timer(0, 1000); * const delayedInput = timer(1000, 1000); * * const coercedSignal = coerceSignal(signalInput); * const coercedFunction = coerceSignal(functionInput); * const coercedImmediate = coerceSignal(immediateInput); * const coercedDelayed = coerceSignal(delayedInput, { initialValue: 0 }); * * effect(() => { * console.log(coercedSignal, coercedFunction, coercedImmediate, coercedDelayed); * }); * ``` */ function coerceSignal(source, options) { if (isSignal(source)) { return source; } else if (isToSignalInput(source)) { // if there is no initialValue then assume the observable has an initial value and set requireSync as true. // Options are explicitly passed to avoid unintended values from effecting output. return (hasKey(options, 'initialValue')) ? toSignal(source, { injector: options.injector, initialValue: options.initialValue }) : toSignal(source, { injector: options?.injector, requireSync: true }); } return computed(source); } /** Gets a function for requesting animation frames. Either requestAnimationFrame or a setTimeout approximating 30 fps. */ function getRequestAnimationFrame() { return (!globalThis.requestAnimationFrame) ? ((callback) => setTimeout(() => callback(Date.now()), 33)) : globalThis.requestAnimationFrame; } /** Request animation frame function */ const requestAnimationFrame = getRequestAnimationFrame(); // export function animatedSignalFactory<V extends ValueSource<number>, O extends AnimationOptions, TState extends object>( // source: V, // options: Partial<AnimatedNumericSignalOptions<number, O>> | undefined, // defaultAnimationOptions: Required<O>, // initialState: TState, // stepFn: AnimationStepFn<TState, O> // ): V extends ReactiveSource<number> ? AnimatedSignal<number, O> : WritableAnimatedSignal<number, O> // export function animatedSignalFactory<T extends NumericValues, V extends ValueSource<T>, O extends AnimationOptions, TState extends object>( // source: V, // options: Partial<AnimatedNumericSignalOptions<T, O>> | undefined, // defaultAnimationOptions: Required<O>, // initialState: TState, // stepFn: AnimationStepFn<TState, O> // ): V extends ReactiveSource<T> ? AnimatedSignal<T, O> : WritableAnimatedSignal<T, O> // export function animatedSignalFactory<T, V extends ValueSource<T>, O extends AnimationOptions, TState extends object>( // source: V, // options: Partial<AnimatedSignalOptions<T, O>> | undefined, // defaultAnimationOptions: Required<O>, // initialState: TState, // stepFn: AnimationStepFn<TState, O> // ): V extends ReactiveSource<T> ? AnimatedSignal<T, O> : WritableAnimatedSignal<T, O> function animatedSignalFactory(source, options, defaultAnimationOptions, initialState, stepFn) { const [ /** The output signal that will be returned and have methods added it it if writable. */ $output, /** A function that will get the current value of the source. It could be a signal */ signalValueFn] = isReactive(source) ? createFromReactiveSource(source, options) : createFromValue(source, options); /** THe SignalNode of the output signal. Used when the output is set during value changes. */ const outputNode = $output[SIGNAL]; // can't just use a spread since an option can be undefined. const defaults = { ...defaultAnimationOptions, interpolator: options?.interpolator || createInterpolator(outputNode.value) }; if (options) { overwriteProperties(defaults, options); } let delayTimeoutId = undefined; let instanceId = 0; let state; effect((onCleanup) => { const priorValue = untracked($output); const [nextValue, overrideOptions] = signalValueFn(); if (nextValue === priorValue) { // since an array is being passed from signalValueFn, it could be the same value was sent. return; } const animationOptions = overrideOptions ? { ...overwriteProperties({ ...defaults }, overrideOptions) } : defaults; const interpolate = (overrideOptions?.interpolator || defaults.interpolator)(priorValue, nextValue); const thisInstanceId = ++instanceId; // in case a previous animation was delayed then clear it before it starts. clearTimeout(delayTimeoutId); if (animationOptions.delay) { delayTimeoutId = setTimeout(start, animationOptions.delay); } else { start(); } function start() { const timeCurrent = Date.now(); // performance.now can't be tested [SAD EMOJI] (switch to Jest?) state = { ...initialState, ...state, isDone: false, progress: 0, timeCurrent, timeElapsed: 0, timeDelta: 0, timeStart: timeCurrent, }; stepFn(state, animationOptions); // run initial step function in case animation isn't necessary. if (state.isDone) { // don't bother with the animation since its done already. signalSetFn(outputNode, nextValue); return; } signalSetFn(outputNode, interpolate(state.progress)); requestAnimationFrame(step); } function step() { if (thisInstanceId !== instanceId) { return; // another effect has occurred. } // I'm not sure if this is necessary. It might have been something I copied from an animation example. // if (previousTime === time) { // requestAnimationFrame(step); // no time elapsed. // return; // } const timeCurrent = Date.now(); // performance.now can't be tested [SAD EMOJI] (switch to Jest?) state.timeDelta = timeCurrent - state.timeCurrent; state.timeCurrent = timeCurrent; state.timeElapsed = timeCurrent - state.timeStart; stepFn(state, animationOptions); signalSetFn(outputNode, interpolate(state.progress)); if (!state.isDone) { requestAnimationFrame(step); } } // force stop by incrementing instanceId on destroy. onCleanup(() => instanceId++); }, options); return $output; /** Coerces a source signal from signal input and creates the output signal.. */ function createFromReactiveSource(reactiveSource, signalOptions) { const $source = coerceSignal(reactiveSource, signalOptions); const $output = signal(untracked($source), signalOptions); $output.setOptions = (options) => overwriteProperties(defaults, options); const signalValueFn = () => [$source()]; return [$output, signalValueFn]; } /** Creates a writable source signal and output signal from the initial value. */ function createFromValue(sourceValue, signalOptions) { const $output = signal(sourceValue, signalOptions); const [get, set, update] = createSignal([sourceValue, undefined]); $output.set = (x, options) => set([x, options]); $output.setOptions = (options) => overwriteProperties(defaults, options); $output.update = (updateFn, options) => update(([value]) => [updateFn(value), options]); return [$output, get]; } } /** * Sets values on {@link target} if they are defined in both {@link target} and {@link values}. * Different then spread operator as it will ignore undefined values. * @returns The value of {@link target}. */ function overwriteProperties(target, values) { Object.entries(values).forEach(([key, value]) => { if (key in target && value !== undefined) { target[key] = value; } }); return target; } const DEFAULT_OPTIONS$1 = { clamp: false, damping: 3, delay: 0, precision: 0.01, stiffness: 100 }; /** * Creates a signal whose value morphs from the old value to the new over a specified duration. * @param source Either a value, signal, observable, or function that can be used in a computed function. * @param options Options for the signal. If a number, number[] or Record<string | number symbol, number> * is passed then this is not required. Otherwise an interpolator is required to translate the change of the value. * @example * ```ts * const $animatedValue = springSignal(1, { damping: 3, stiffness: 100 }); * function demo(): void { * $animatedValue.set(5); * } * ``` */ function springSignal(source, options) { return animatedSignalFactory(source, options, DEFAULT_OPTIONS$1, { velocity: 1 }, (state, options) => { const dt = state.timeDelta / 1000; const force = -options.stiffness * (state.progress - 1); const damping = options.damping * state.velocity; const acceleration = force - damping; state.velocity += acceleration * dt; state.progress += state.velocity * dt; if (Math.abs(1 - state.progress) < options.precision && Math.abs(state.velocity / dt) <= options.precision / dt) { state.isDone = true; state.progress = 1; return; } if (options.clamp && state.progress > 1) { state.progress = 2 - state.progress; state.velocity = -state.velocity + damping * dt; } }); } const DEFAULT_OPTIONS = { delay: 0, duration: 400, easing: (x) => x }; /** * Creates a signal whose value morphs from the old value to the new over a specified duration. * @param source Either a value, signal, observable, or function that can be used in a computed function. * @param options Options for the signal. If a number, number[] or Record<string | number symbol, number> * is passed then this is not required. Otherwise an interpolator is required to translate the change of the value. * @example * ```ts * const fastLinearChange = tweenSignal(1); * const slowEaseInChange = tweenSignal(1, { duration: 5000, easing: (x) => return x ** 2; }); * function demo(): void { * fastLinearChange.set(5); // in 400ms will display something like 1, 1.453, 2.134, 3.521, 4.123, 5. * slowEaseInChange.set(5, { duration: 10000 }); // in 10000ms will display something like 1, 1.21, 1.4301... * } * ``` */ function tweenSignal(source, options) { return animatedSignalFactory(source, options, DEFAULT_OPTIONS, {}, (state, options) => { const timeProgress = options.duration > 0 ? Math.min(1, state.timeElapsed / options.duration) : 1; state.isDone = timeProgress === 1; state.progress = options.easing(timeProgress); }); } const VOID_FN = () => { }; var AsyncSignalStatus; (function (AsyncSignalStatus) { AsyncSignalStatus[AsyncSignalStatus["Error"] = 0] = "Error"; AsyncSignalStatus[AsyncSignalStatus["Ok"] = 1] = "Ok"; AsyncSignalStatus[AsyncSignalStatus["NotSet"] = 2] = "NotSet"; })(AsyncSignalStatus || (AsyncSignalStatus = {})); /** * Takes an async source (Promise, Observable) or signal/function that returns an async source * and returns that source's values as part of a signal. Kind of like an rxjs flattening operator. * When the async source changes, the old source is immediately released and the new source is listened. * @param valueSource A Promise or Subscribable to create writable signal, * otherwise a signal or function that returns a Promise or Subscribable. * @param options The options for the async signal * @returns a signal that returns values from the async source.. * @example * ```ts * $id = signal(0); * // call getCustomer every time $id changes. * $customer = asyncSignal(() => this.$id() !== 0 ? this.getCustomer(this.$id()) : undefined); * * constructor() { * // writable overload can switch versions. * const artificialWritableExampleSource1 = new BehaviorSubject(1); * const $writable = asyncSignal(artificialWritableExampleSource1); * const artificialWritableExampleSource2 = new BehaviorSubject(2); * $writable.set(artificialWritableExampleSource2); * } * ``` */ function asyncSignal(valueSource, options = {}) { return isSignal(valueSource) ? createFromSignal(valueSource, options) : isReactiveSourceFunction(valueSource) ? createFromReactiveSourceFunction(valueSource, options) : createFromValue$2(valueSource, options); } /** Called if this is a function that's NOT a signal to create a readonly AsyncSignal. */ function createFromReactiveSourceFunction(reactiveFn, options) { const $input = coerceSignal(reactiveFn, { initialValue: undefined, injector: options.injector }); return createFromSignal($input, options); } /** Creates the writable version of an async signal from an initial async source. */ function createFromValue$2(initialSource, options) { const [get, set, update] = createSignal(initialSource); const $output = createFromSignal(get, options); $output.asReadonly = asReadonlyFnFactory($output); $output.set = set; $output.update = update; return $output; } /** Creates a readonly version of an async signal from another signal returning an async source. */ function createFromSignal($input, options) { /** * The current source of asynchronous values. * Initially this is undefined because we're trying to defer reading the source until the effect first runs. * If requireSync is true then it will get the value immediately */ let currentSource; /** An "unsubscribe" function. */ let currentListenerCleanupFn; // if requireSync is true, the set the state as NotSet, otherwise it's Ok. Being NotSet and read will throw an error. const $state = signal(options.requireSync ? { status: AsyncSignalStatus.NotSet, value: options.defaultValue } : { status: AsyncSignalStatus.Ok, value: options.defaultValue }); // if requireSync is true then immediately start listening. if (options?.requireSync) { currentSource = untracked($input); currentListenerCleanupFn = updateListener(currentSource); } else { currentListenerCleanupFn = () => { }; } effect(() => { // Initially this used to only run inside a conditional branch if the state was OK. // The problem with that was if another source had an error, the error would bubble up, since we're not catching it. const nextSource = $input(); if (nextSource === currentSource) { // don't start listening to an already listened to source. // This is only necessary in case requireSync was true and this is the first effect was run. return; } // manually cleanup old listener. currentListenerCleanupFn(); // store the currentSource so it can be used in next invocation of effect. currentSource = nextSource; // Call the updateListener process and set currentListenerCleanupFn from result. // (This is untracked because a signal may be used inside the source and cause additional invocations.) currentListenerCleanupFn = untracked(() => updateListener(nextSource)); }, { injector: options.injector }); // Call cleanup on the last listener. The effect cleanup can't be used because of the risk of initially repeating subscriptions. getDestroyRef(asyncSignal, options.injector).onDestroy(() => currentListenerCleanupFn()); return computed(() => { const { err, status, value } = $state(); switch (status) { case AsyncSignalStatus.Error: throw new Error('Error in Async Source', { cause: err }); case AsyncSignalStatus.NotSet: throw new Error('requireSync is true, but no value was returned from asynchronous source.'); case AsyncSignalStatus.Ok: return value; } }, options // pass along the debugName and equal options. ); /** Starts listening to the new async value, and returns the cleanup function. */ function updateListener(asyncSource) { // This was removed because it was confusing that a value could be a subscribable and a subscribable could be converted to a signal. // if (asyncSource === undefined) { // return VOID_FN; // don't listen to anything and return a dummy unsubscribe function. // } if ('subscribe' in asyncSource) { const unsubscribe = asyncSource.subscribe({ error: setError, next: setValue }); return () => unsubscribe.unsubscribe(); } asyncSource.then(setValue, setError).catch(setError); return VOID_FN; // there is no way to cleanup a promise that I know of. /** Sets the state of errored if an error hadn't already occurred. */ function setError(err) { $state.update((cur) => cur.status === AsyncSignalStatus.Error ? cur : { err, status: AsyncSignalStatus.Error, value: cur.value }); } /** Updates value if the status isn't error. */ function setValue(value) { $state.update((cur) => (cur.status === AsyncSignalStatus.Error ? cur : { status: AsyncSignalStatus.Ok, value })); } } } /** The status of the timer. */ var TimerStatus; (function (TimerStatus) { TimerStatus[TimerStatus["Destroyed"] = 0] = "Destroyed"; TimerStatus[TimerStatus["Paused"] = 1] = "Paused"; TimerStatus[TimerStatus["Running"] = 2] = "Running"; TimerStatus[TimerStatus["Stopped"] = 3] = "Stopped"; })(TimerStatus || (TimerStatus = {})); /** A general timer. */ class TimerInternal { /** Gets or set intervalTime if this was started with an interval. Will throw if not initially passed an interval. */ get intervalTime() { TimerInternal.assertHasIntervalRunner(this); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.intervalRunner.dueTime; } set intervalTime(value) { TimerInternal.assertHasIntervalRunner(this); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.updateRunnerDueTime(value, this.intervalRunner); } /** Gets the number of ticks since start. */ get ticks() { return this.tickCount; } /** Gets or sets the timeoutTime. */ get timeoutTime() { return this.timeoutRunner.dueTime; } set timeoutTime(value) { this.updateRunnerDueTime(value, this.timeoutRunner); } /** Readonly status of the timer. */ get timerStatus() { return this.status; } /** * passing only timeoutTime will have this behave like a timeout. * passing intervalTime will have this first execute timeoutTime then intervalTime. */ constructor(timeoutTime, intervalTime, options) { /** The time the last tick completed. Doesn't have to be the actual last time. */ this.lastCompleteTime = Date.now(); /** When paused, stores what time was remaining. */ this.remainingTimeAtPause = 0; /** The current status of timer. Do not modify directly! All changes should go through setStatus. */ this.status = TimerStatus.Stopped; /** The count of ticks */ this.tickCount = 0; this.runner = this.timeoutRunner = { dueTime: timeoutTime, onTickComplete: this.onTimeoutTickComplete.bind(this) }; if (intervalTime !== undefined) { this.intervalRunner = { dueTime: intervalTime, onTickComplete: this.tickStart.bind(this) // loop }; } this.onTickCallback = options?.onTick ?? (() => undefined); this.onStatusChangeCallback = options?.onStatusChange ?? (() => undefined); if (options?.runAtStart) { this.setStatus(TimerStatus.Running); this.tickStart(); } } /** Clears the current tick and prevents any future processing. */ destroy() { clearTimeout(this.timeoutId); this.setStatus(TimerStatus.Destroyed); } /** Pauses the timer. */ pause() { if (this.status === TimerStatus.Running) { this.remainingTimeAtPause = this.getRemainingTime(); clearTimeout(this.timeoutId); this.setStatus(TimerStatus.Paused); } } /** Resumes the timer if it was paused, otherwise does nothing. */ resume() { if (this.status === TimerStatus.Paused) { this.setStatus(TimerStatus.Running); // if duration is adjusted by a signal then this is a problem. this.lastCompleteTime = Date.now() - (this.runner.dueTime - this.remainingTimeAtPause); this.tickStart(); } } /** Start or restarts the timer as long as it isn't destroyed. */ start() { if (this.status !== TimerStatus.Destroyed) { this.setStatus(TimerStatus.Running); this.tickCount = 0; this.runner = this.timeoutRunner; this.lastCompleteTime = Date.now(); this.tickStart(); } } /** Throws if intervalRunner isn't defined. */ static assertHasIntervalRunner(ti) { if (!ti.intervalRunner) { throw new Error('This timer was not configured for intervals'); } return true; } /** Determines the remaining time. */ getRemainingTime() { return this.runner.dueTime - (Date.now() - this.lastCompleteTime); } /** Switch to intervalRunner or set as stopped. */ onTimeoutTickComplete() { if (this.intervalRunner) { this.runner = this.intervalRunner; this.tickStart(); // begin intervalLoop } else { this.setStatus(TimerStatus.Stopped); } } setStatus(status) { if (this.status !== status) { this.status = status; this.onStatusChangeCallback(status); } } /** * Handles when a tick is complete. * If for some reason there is remaining time, it will restart the tick. * Otherwise it will increase the internalCount, execute the callback, update the completed, * and call the current runner's onTickComplete method so that it handles the next step. */ tickComplete() { const remainingTime = this.getRemainingTime(); if (remainingTime > 0) { // this could occur if the end time changed. this.tickStart(); } else { ++this.tickCount; this.onTickCallback(this.tickCount); this.lastCompleteTime = Date.now() + this.getRemainingTime(); this.runner.onTickComplete(); } } /** Attempts to starts the tick timeout. */ tickStart() { clearTimeout(this.timeoutId); if (this.status === TimerStatus.Running) { this.timeoutId = setTimeout(this.tickComplete.bind(this), this.getRemainingTime()); } } /** Sets the dueTime on the runner and if necessary starts the timer. */ updateRunnerDueTime(dueTime, targetRunner) { const oldTime = targetRunner.dueTime; targetRunner.dueTime = dueTime; if (targetRunner === this.runner && this.status === TimerStatus.Running && oldTime > dueTime) { this.tickStart(); } } } /** * Creates a function that retrieves the current value from a {@link ValueSource}. * If the value is an {@link ReactiveSource} like an observable or a function that returns a value, then it will create a signal, * otherwise it will return a function that just returns a value. */ function createGetValueFn(valueSrc, injector) { return isReactive(valueSrc) ? coerceSignal(valueSrc, { injector }) : () => valueSrc; } /** * Determines if valueSrcFn is a signal, and if it is, creates an effect from callbackFn. * Otherwise does nothing since the valueSrc is constant. * The factory is used just so the effect callback doesn't need any assertions on valueSrcFn. */ function watchValueSourceFn(valueSrcFn, callback, injector) { if (isSignal(valueSrcFn)) { effect(() => callback(valueSrcFn()), { injector: injector }); } } /** * Creates either a writable signal whose values are debounced, or a signal who returns debounced values of another signal. */ function debounceSignal(initialValueOrSource, debounceTime, options) { return isReactive(initialValueOrSource) ? createFromReactiveSource(initialValueOrSource, debounceTime, options) : createFromValue$1(initialValueOrSource, debounceTime, options); } /** Creates a signal that debounces the srcSignal the given debounceTime. */ function createFromReactiveSource(sourceInput, debounceTime, options) { // QUESTION: Why are we explicitly ignore the options.equal function for the reactive source version? // We are even omitting it from the overload of the main function. // Is it because the equal function is running on the source signal in the value version? const timerTimeFn = createGetValueFn(debounceTime, options?.injector); const $source = coerceSignal(sourceInput, options); const $output = signal(untracked($source), { debugName: options?.debugName }); const outputNode = $output[SIGNAL]; const timer = new TimerInternal(timerTimeFn(), undefined, { onTick: () => signalSetFn(outputNode, untracked($source)) }); // setup cleanup actions. getDestroyRef(createFromReactiveSource, options?.injector).onDestroy(() => timer.destroy()); watchValueSourceFn(timerTimeFn, (x) => timer.timeoutTime = x, options?.injector); effect(() => { $source(); // wish there was a better way to watch the value. timer.start(); }, options); return $output; } /** Creates a writeable signal that updates after a certain amount of time. */ function createFromValue$1(initialValue, debounceTime, options) { const [get, set, update] = createSignal(initialValue); setEqualOnNode(get[SIGNAL], options?.equal); const $debounced = createFromReactiveSource(get, debounceTime, options); $debounced.asReadonly = asReadonlyFnFactory($debounced); $debounced.set = set; $debounced.update = update; return $debounced; } /** * * @param observerFactoryFn Returns an observer that executes a callback whenever a notification is received. * @param source The Observed value. * @param options A mixture of options for the signal and the observer. * @param nativeObservedTransformFn Converts the source value into the appropriate type for the observer. * @param initialOutput The initial output for the signal. * @param injector The injector used to create an effect to monitor changes to the source if it is a signal. * @returns A signal whose value changes to the latest observer output. */ function domObserverSignalFactory(observerFactoryFn, source, options, nativeObservedTransformFn, debugName, injector) { const [get, set] = createSignal([]); setDebugNameOnNode(get[SIGNAL], debugName); const observer = observerFactoryFn(set); const destroyRef = getDestroyRef(domObserverSignalFactory, injector); destroyRef.onDestroy(() => observer.disconnect()); if (isReactive(source)) { domObserverComputedSignalSetup(observer, source, options, nativeObservedTransformFn, injector); return get; } else { return domObserverWritableSignalFactory(observer, get, source, options, nativeObservedTransformFn); } } /** * Creates a writable signal. * @param observer The observer that watches subjects. * @param $output The signal function to add methods to. This will be mutated directly. * @param initialSubject The initially watched subject. * @param options A mixture of options for the signal and the observer. */ function domObserverWritableSignalFactory(observer, $output, initialSubject, options, nativeObservedTransformFn) { let untransformedSubject = initialSubject; observeNextSubject(observer, nativeObservedTransformFn(initialSubject), options); $output.asReadonly = asReadonlyFnFactory($output); $output.set = updateState; $output.update = (updateFn) => updateState(updateFn(untransformedSubject)); return $output; function updateState(nextSubject, nextOptions) { if (nextSubject !== untransformedSubject || nextOptions !== undefined) { untransformedSubject = nextSubject; options = nextOptions ?? options; observeNextSubject(observer, nativeObservedTransformFn(nextSubject), options); } } } /** Sets up watching changes to the source signal and routes it to the observer. */ function domObserverComputedSignalSetup(observer, subjectSource, options, nativeObservedTransformFn, injector) { const $subject = coerceSignal(subjectSource, { injector }); const subjectNode = $subject[SIGNAL]; let currentSubject; if (!subjectNode.producerMustRecompute(subjectNode)) { // only start observing immediately IF the signal value is updated. Otherwise wait for the effect. currentSubject = untracked($subject); observeNextSubject(observer, nativeObservedTransformFn(currentSubject), options); } effect(() => { const nextSubject = $subject(); if (nextSubject !== currentSubject) { // only update the observer if the subject has Changed. currentSubject = nextSubject; observeNextSubject(observer, nativeObservedTransformFn(nextSubject), options); } }, { injector }); } /** Common behavior when the subject changes. */ function observeNextSubject(observer, subject, options) { observer.disconnect(); if (subject !== undefined) { // The types are a intersection of all possible targets which is not possible. // Also IntersectionObserver doesn't support options changing. observer.observe(subject, options); } } /** * Uses IntersectionObserver to observe changes to nodes passed to the signal. * @param source Either a signal/observable/function that returns Elements or ElementRefs, or a value that is Elements or ElementRef. * If the source is a value then the signal will be writable. * @param options Options for the signal or the IntersectionObserver used to monitor changes. * @example * ```ts * const el = document.getElementById('el1'); * const $obs = intersectionSignal(el); * effect(() => console.log($obs()[0]?.attributeName)); // will log when scrolled into view. * el.scrollIntoView(); * ``` */ function intersectionSignal(source, options) { if (typeof IntersectionObserver === 'undefined') { return signal([], options); // return a dummy signal that never changes if there is no IntersectionObserver (like in SSR). } const injector = options?.injector ?? getInjector(intersectionSignal); return domObserverSignalFactory((callback) => { const nativeOptions = options ? { ...options, root: getRoot(options.root) } : undefined; return new IntersectionObserver(callback, nativeOptions); }, source, undefined, // intersection observer options never change getElement$1, options?.debugName, injector); } /** Converts IntersectionSignalValue to Element. If it cannot then undefined is returned. */ function getRoot(value) { if (value instanceof Element || value instanceof Document) { return value; } if (value != null && 'nativeElement' in value && value.nativeElement instanceof Element) { return value.nativeElement; } return undefined; } /** Converts IntersectionSignalValue to Element. If it cannot then undefined is returned. */ function getElement$1(value) { if (value instanceof Element) { return value; } if (value?.nativeElement instanceof Element) { return value.nativeElement; } return undefined; } /** * Uses MutationObserver to observe changes to nodes passed to the signal. * @param source Either a signal/observable/function that returns Nodes or ElementRefs, or a value that is Node or ElementRef. * If the source is a value then the signal will be writable. * @param options Options for the signal or the MutationObserver used to monitor changes. * @example * ```ts * const el = document.getElementById('el1'); * const $obs = mutationSignal(el); * effect(() => console.log($obs()[0]?.attributeName)); // will log 'data-node-value' * el.setAttribute('data-node-value', 'hello there'); * ``` */ function mutationSignal(source, options) { if (typeof MutationObserver === 'undefined') { return signal([], options); // return a dummy signal that never changes if there is no MutationObserver (like in SSR). } const injector = options?.injector ?? getInjector(mutationSignal); return domObserverSignalFactory((callback) => new MutationObserver(callback), source, getObserverOptions(options), getNode, options?.debugName, injector); } /** * Extracts Observer options from Signal Options. * If no options are passed then a default of attributes is used. */ function getObserverOptions(options) { // careful here. // The logic is to return default mutationObserver options if the only options are a non-mutation one. if (options === undefined || Object.keys(options).every((x) => x === 'injector')) { return { attributes: true }; } return options; // it's probably safe to return options directly. } /** Converts MutationSignalValue to Node. If it cannot then undefined is returned. */ function getNode(value) { if (value instanceof Node) { return value; } if (value?.nativeElement instanceof Node) { return value.nativeElement; } return undefined; } /** * Uses ResizeObserver to observe changes to elements passed to the signal. * @param source Either a signal/observable/function that returns Elements or ElementRefs, or a value that is Element or ElementRef. * If the source is a value then the signal will be writable. * @param options Options for the signal or the ResizeObserver used to monitor changes. * @example * ```ts * const el = document.getElementById('el1'); * const $obs = resizeSignal(el); * effect(() => console.log($obs()[0])); // will output changes to size. * el.style.height = '250px'; * ``` */ function resizeSignal(source, options) { if (typeof ResizeObserver === 'undefined') { return signal([]); // return a dummy signal that never changes if there is no MutationObserver (like in SSR). } const injector = options?.injector ?? getInjector(resizeSignal); return domObserverSignalFactory((callback) => new ResizeObserver(callback), source, options, getElement, options?.debugName, injector); } /** Converts MutationSignalValue to Node. If it cannot then undefined is returned. */ function getElement(value) { if (value instanceof Element) { return value; } if (value?.nativeElement instanceof Element) { return value.nativeElement; } return undefined; } /* eslint-disable @typescript-eslint/no-explicit-any */ const DUMMY_FN = () => { }; function eventSignal(source, eventName, selectorOrOptions, optionsWhenSelectorPresent) { const [selector, options] = resolveSelectorAndOptions(); const injector = options?.injector ?? getInjector(eventSignal); const $output = signal(options?.initialValue, options); const destroyRef = injector.get(DestroyRef); const renderer = injector.get(RendererFactory2).createRenderer(null, null); let destroyEventListener = DUMMY_FN; if (isReactive(source)) { listenToSignal(coerceSignal(source, options)); } else { listenToValue(source); } destroyRef.onDestroy(() => { renderer.destroy(); destroyEventListener(); }); return $output; function resolveSelectorAndOptions() { if (typeof selectorOrOptions === 'function') { return [selectorOrOptions, optionsWhenSelectorPresent]; } else { return [(evt) => evt, selectorOrOptions]; } } function listenToValue(target) { const targetListenable = coerceListenable(target); if (!targetListenable) { // console.warn('An undefined object was passed to eventSignal. It will not be listened to.'); destroyEventListener = DUMMY_FN; return; } destroyEventListener = renderer.listen(targetListenable, eventName, (event) => { const nextValue = untracked(() => selector(event)); $output.set(nextValue); }); } function listenToSignal($target) { effect((onCleanup) => { destroyEventListener(); listenToValue($target()); onCleanup(() => { destroyEventListener(); // make sure event unlistener is a dummy function incase there are any ramifications for double calling. destroyEventListener = DUMMY_FN; }); }, { injector }); } } /** If target is elementRef then returns nativeElement, otherwise returns target */ function coerceListenable(target) { if (target instanceof ElementRef) { return target.nativeElement; } return target; } // THIS SEEMED UNNECESSARY: Never encountered a scenario where Renderer2 could be injected. // /** The theory here is that if inside component Renderer can be injected without the need of RendererFactory. */ // function injectRenderer(injector: Injector): [render: Renderer2, destroy: () => void] { // // if renderer exists in context already then there is no reason to create it again. // // so we can return it with a dummy destroyer. // let renderer = injector.get(Renderer2, null, { optional: true }); // if (renderer) { // return [renderer, () => {}]; // } // const rendererFactory = injector.get(RendererFactory2); // renderer = rendererFactory.createRenderer(null, null); // const destroy = () => renderer.destroy(); // return [renderer, destroy]; // } ; /** * Filters values set to a directly so that the value only changes when the filter is passed. * Some overloads allow for a guard function which will change the type of the signal's output value. * @example * ```ts * const nonNegative = filterSignal<number>(0, x => x >= 0); * nonNegative.set(-1); * console.log(nonNegative()); // [LOG]: 0 * ``` * @typeParam T The input value of the signal * @typeParam O The output type of the signal or the value of the input and output if no guard function is used. * @param initialValue The initial value of the signal * @param filterFn A function that filters values. Can be a guard function. * @param options Options for the signal. * @returns A writable signal whose values are only updated when set. */ function filterSignal(initialValue, filterFn, options) { const [get, set] = createSignal(initialValue); const $output = get; setDebugNameOnNode(get[SIGNAL], options?.debugName); setEqualOnNode(get[SIGNAL], options?.equal); $output.asReadonly = asReadonlyFnFactory($output); $output.set = setConditionally; $output.update = (signalUpdateFn) => setConditionally(signalUpdateFn(untracked($output))); return $output; /** Sets the signal value only if it passes the filter function. */ function setConditionally(value) { if (filterFn(value)) { set(value); } } } /** * Lifts methods from the signal's value to the signal itself. * @example * ```ts * const awesomeArray = liftSignal([1, 2, 3, 4], ['filter'], ['push', 'pop']); * awesomeArray.push(5); * console.log(awesomeArray()); //[1, 2, 3, 4, 5]; * awesomeArray.pop(); * console.log(awesomeArray()); //[1, 2, 3, 4]; * awesomeArray.filter(x => x % 2 === 0); * console.log(awesomeArray()); //[2, 4]; * ``` * @param valueSource Either a value or a Writable signal. * @param updaters A tuple that contains the names that will return a new value. * @param mutators A tuple that contains the names that will modify the signal's value directly. * To guarantee this will return a new value, structuredClone or object.assign is used to create a brand new object, * so use with caution. * @typeParam T the type of the signal's value as well as the type where the functions are lifted from. * @typeParam U A tuple that contains the names of methods appropriate for updating. * @typeParam M A tuple that contains the names of methods appropriate for mutating. */ function liftSignal(valueSource, updaters, mutators, options) { const $output = isSignal(valueSource) ? valueSource : signal(valueSource, options); const boundMethods = {}; updaters?.forEach((cur) => { boundMethods[cur] = (...args) => $output.update((x) => x[cur](...args)); }); if (mutators) { const cloneFn = options?.cloneFn ?? cloneFnFactory($output()); mutators.forEach((cur) => { boundMethods[cur] = (...args) => $output.update((x) => { const cloned = cloneFn(x); cloned[cur](...args); return cloned; }); }); } return Object.assign($output, boundMethods); } /** Creates a cloning function based on the sample object. */ function cloneFnFactory(sample) { return Array.isArray(sample) ? (x) => structuredClone(x) : (x) => Object.assign(Object.create(Object.getPrototypeOf(x)), x); } function mapSignal(...params) { if (params.length < 2) { throw new Error('Invalid param count. At least two are required.'); } return (isFromReactiveParameters(params)) ? createFromReactiveParameters(...params) : createFromValue(params[0], params[1], params[2]); function isFromReactiveParameters(value) { return isReactive(value[0]); } } /** Creates a readonly signal that selects from one or more signals. */ function createFromReactiveParameters(...params) { const { inputs, options, selector } = destructureParams(); return computed(() => selector(...inputs.map(x => x())), options); function destructureParams() { let options; let endOffset; if (hasOptions(params)) { options = params[params.length - 1]; endOffset = 2; } else { options = {}; endOffset = 1; } return { inputs: params.slice(0, params.length - endOffset).map(x => coerceSignal(x, options)), options, selector: params[params.length - endOffset] }; } function hasOptions(value) { // relies on both selector being a function, so if the last element isn't a function then it must be options. return typeof value[value.length - 1] !== 'function'; } } /** Creates a new signal that runs selector after every value change. */ function createFromValue(initialValue, selector, options = {}) { const $input = signal(initialValue); const inputNode = $input[SIGNAL]; const $output = computed(() => selector($input()), options); $output.asReadonly = asReadonlyFnFactory($output); $output.input = $input; $output.set = (value) => signalSetFn(inputNode, value); $output.update = (updateFn) => signalUpdateFn(inputNode, updateFn); return $output; } /** * Creates a signal that determines if a document matches a given media query. * This uses {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia | window.matchMedia} internally. * @param querySource For a writable signal pass a string, otherwise a {@link ReactiveSource} that returns queries as a string. * @param options An optional object that affects behavior of the signal. * @example * ```ts * // As a writable signal. * const $orientationQry = mediaQuerySignal('(orientation: portrait)'); * effect(() => console.log(`browser is in ${$orientationQry().matches ? 'portrait' : 'landscape'} orientation`)); * // from another signal. * const $querySource = signal(`(min-width: 992px)`); * const $widthQry = mediaQuerySignal($querySource); * effect(() => console.log(`browser is in ${$widthQry().matches ? 'large' : 'small'} screen`)); * ``` */ function mediaQuerySignal(querySource, options) { if (!globalThis.matchMedia) { return createDummyOutput('matchMedia is not supported'); } /** The cleanup function for the active MediaQueryList.change eventListener. */ let cleanupFn = () => { }; /** When true, no more changes should be observed. */ let isDestroyed = false; return isReactive(querySource) ? createFromReactiveSource(querySource) : createFromValue(querySource); /** * Creates the output signal from a {@link ReactiveSource}. * This is currently done with an effect, but it might be okay if it were done from a computed signal. * The only caveat is that there would have to be a side-effect every time the query changed: * A new matchMedia query would have to be created, and the event subscribed to. * This is probably okay because it should be asynchronous. */ func