@ddtmm/angular-signal-generators
Version:
Specialized Angular signals to help with frequently encountered situations.
1,115 lines (1,094 loc) • 87 kB
JavaScript
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