feat(store): initial release (#279)

This commit is contained in:
rahim
2026-01-02 14:41:58 +11:00
committed by GitHub
parent c9a216dd2d
commit d74e4e6701
54 changed files with 6047 additions and 66 deletions
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { animationFrame } from './animation-frame';
describe('animationFrame', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls the callback on next animation frame', async () => {
const callback = vi.fn();
animationFrame(callback);
expect(callback).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
it('passes timestamp to callback', async () => {
const callback = vi.fn();
animationFrame(callback);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledWith(expect.any(Number));
});
it('returns a cleanup function', () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
expect(cancel).toBeTypeOf('function');
});
it('cancel prevents callback from being called', async () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('cancel can be called multiple times safely', async () => {
const callback = vi.fn();
const cancel = animationFrame(callback);
cancel();
cancel();
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('allows multiple independent animation frames', async () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
animationFrame(callback1);
animationFrame(callback2);
const cancel3 = animationFrame(callback3);
cancel3();
await vi.runAllTimersAsync();
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).not.toHaveBeenCalled();
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Request an animation frame and return a cleanup function to cancel it.
*
* @param callback - The callback to invoke on the next animation frame
* @returns A cleanup function that cancels the animation frame request
*
* @example
* ```ts
* const cancel = animationFrame((time) => {
* console.log('Frame at', time);
* });
*
* // Later, cancel if needed
* cancel();
* ```
*/
export function animationFrame(callback: FrameRequestCallback): () => void {
const id = requestAnimationFrame(callback);
return () => cancelAnimationFrame(id);
}
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest';
import { onEvent } from './event';
describe('onEvent', () => {
it('returns a promise', () => {
const target = new EventTarget();
const promise = onEvent(target, 'click');
expect(promise).toBeInstanceOf(Promise);
// Dispatch to resolve
target.dispatchEvent(new Event('click'));
});
it('resolves with the event when it fires', async () => {
const target = new EventTarget();
const event = new Event('click');
const promise = onEvent(target, 'click');
target.dispatchEvent(event);
await expect(promise).resolves.toBe(event);
});
it('only listens for the first occurrence (once)', async () => {
const target = new EventTarget();
const promise = onEvent(target, 'click');
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
const result = await promise;
expect(result).toBeInstanceOf(Event);
});
it('rejects when signal is already aborted', async () => {
const target = new EventTarget();
const controller = new AbortController();
controller.abort();
const promise = onEvent(target, 'click', { signal: controller.signal });
await expect(promise).rejects.toThrow();
});
it('rejects with AbortError when signal is aborted', async () => {
const target = new EventTarget();
const controller = new AbortController();
const promise = onEvent(target, 'click', { signal: controller.signal });
controller.abort();
await expect(promise).rejects.toMatchObject({
name: 'AbortError',
});
});
it('rejects with custom abort reason', async () => {
const target = new EventTarget();
const controller = new AbortController();
const reason = new Error('Custom reason');
const promise = onEvent(target, 'click', { signal: controller.signal });
controller.abort(reason);
await expect(promise).rejects.toBe(reason);
});
it('cleans up abort listener when event fires', async () => {
const target = new EventTarget();
const controller = new AbortController();
const promise = onEvent(target, 'click', { signal: controller.signal });
target.dispatchEvent(new Event('click'));
await promise;
// Aborting after resolution should not cause issues
controller.abort();
});
it('passes options to addEventListener', async () => {
const target = new EventTarget();
const addSpy = vi.spyOn(target, 'addEventListener');
const promise = onEvent(target, 'click', { passive: true, capture: true });
target.dispatchEvent(new Event('click'));
await promise;
expect(addSpy).toHaveBeenCalledWith(
'click',
expect.any(Function),
expect.objectContaining({
passive: true,
capture: true,
once: true,
}),
);
});
it('works with HTMLMediaElement events', async () => {
const video = document.createElement('video');
const promise = onEvent(video, 'play');
video.dispatchEvent(new Event('play'));
const event = await promise;
expect(event.type).toBe('play');
});
it('works with Window events', async () => {
const promise = onEvent(window, 'resize');
window.dispatchEvent(new Event('resize'));
const event = await promise;
expect(event.type).toBe('resize');
});
it('works with Document events', async () => {
const promise = onEvent(document, 'visibilitychange');
document.dispatchEvent(new Event('visibilitychange'));
const event = await promise;
expect(event.type).toBe('visibilitychange');
});
});
+124
View File
@@ -0,0 +1,124 @@
export interface OnEventOptions extends AddEventListenerOptions {
/**
* An AbortSignal to cancel waiting for the event.
*
* If aborted, the returned promise will reject with an `AbortError`.
*/
signal?: AbortSignal;
}
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
type: K,
options?: OnEventOptions,
): Promise<HTMLMediaElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (HTMLElement)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
options?: OnEventOptions,
): Promise<HTMLElementEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Window)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof WindowEventMap>(
target: Window,
type: K,
options?: OnEventOptions,
): Promise<WindowEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target (Document)
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*/
export function onEvent<K extends keyof DocumentEventMap>(
target: Document,
type: K,
options?: OnEventOptions,
): Promise<DocumentEventMap[K]>;
/**
* Wait for an event to occur on a target.
*
* @param target - The event target
* @param type - The event type to wait for
* @param options - Optional event options including AbortSignal
* @returns A promise that resolves with the event
*
* @example
* ```ts
* // Wait for video to be seeked
* const event = await onEvent(video, 'seeked');
* ```
*
* @example
* ```ts
* // With AbortSignal for cancellation
* const controller = new AbortController();
*
* try {
* const event = await onEvent(video, 'seeked', { signal: controller.signal });
* } catch (e) {
* if (e.name === 'AbortError') {
* console.log('Cancelled waiting for event');
* }
* }
*
* // Cancel from elsewhere
* controller.abort();
* ```
*/
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event>;
export function onEvent(target: EventTarget, type: string, options?: OnEventOptions): Promise<Event> {
return new Promise((resolve, reject) => {
const handleAbort = () => {
reject(options?.signal?.reason ?? new DOMException('Aborted', 'AbortError'));
};
// If already aborted, reject immediately
if (options?.signal?.aborted) {
handleAbort();
return;
}
// Listen for abort
options?.signal?.addEventListener('abort', handleAbort, { once: true });
// Listen for the event
target.addEventListener(
type,
(event) => {
options?.signal?.removeEventListener('abort', handleAbort);
resolve(event);
},
{ ...options, once: true },
);
});
}
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { idleCallback } from './idle-callback';
describe('idleCallback', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('calls the callback when idle', async () => {
const callback = vi.fn();
idleCallback(callback);
expect(callback).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
it('passes deadline object to callback', async () => {
const callback = vi.fn();
idleCallback(callback);
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
didTimeout: expect.any(Boolean),
timeRemaining: expect.any(Function),
}),
);
});
it('returns a cleanup function', () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
expect(cancel).toBeTypeOf('function');
});
it('cancel prevents callback from being called', async () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('cancel can be called multiple times safely', async () => {
const callback = vi.fn();
const cancel = idleCallback(callback);
cancel();
cancel();
cancel();
await vi.runAllTimersAsync();
expect(callback).not.toHaveBeenCalled();
});
it('allows multiple independent idle callbacks', async () => {
const callback1 = vi.fn();
const callback2 = vi.fn();
const callback3 = vi.fn();
idleCallback(callback1);
idleCallback(callback2);
const cancel3 = idleCallback(callback3);
cancel3();
await vi.runAllTimersAsync();
expect(callback1).toHaveBeenCalledOnce();
expect(callback2).toHaveBeenCalledOnce();
expect(callback3).not.toHaveBeenCalled();
});
it('accepts options parameter', async () => {
const callback = vi.fn();
idleCallback(callback, { timeout: 1000 });
await vi.runAllTimersAsync();
expect(callback).toHaveBeenCalledOnce();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { supportsIdleCallback } from './supports';
/**
* Request an idle callback and return a cleanup function to cancel it.
*
* Falls back to `setTimeout` with 1ms delay in environments that don't
* support `requestIdleCallback` (e.g., Safari).
*
* @param callback - The callback to invoke when the browser is idle
* @param options - Optional idle callback options (timeout, etc.)
* @returns A cleanup function that cancels the idle callback request
*
* @example
* ```ts
* const cancel = idleCallback((deadline) => {
* console.log('Time remaining:', deadline.timeRemaining());
* });
*
* // Later, cancel if needed
* cancel();
* ```
*
* @example
* ```ts
* // With timeout option
* const cancel = idleCallback(doWork, { timeout: 1000 });
* ```
*/
export function idleCallback(callback: IdleRequestCallback, options?: IdleRequestOptions): () => void {
if (supportsIdleCallback()) {
const id = requestIdleCallback(callback, options);
return () => cancelIdleCallback(id);
}
// Fallback for Safari and other browsers without requestIdleCallback
const id = setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => 50, // Approximate idle time
});
}, 1);
return () => clearTimeout(id);
}
+5
View File
@@ -0,0 +1,5 @@
export { animationFrame } from './animation-frame';
export { onEvent, type OnEventOptions } from './event';
export { idleCallback } from './idle-callback';
export { listen } from './listen';
export { supportsAnimationFrame, supportsIdleCallback } from './supports';
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import { listen } from './listen';
describe('listen', () => {
it('adds an event listener', () => {
const target = new EventTarget();
const handler = vi.fn();
listen(target, 'click', handler);
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('returns a cleanup function', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler);
expect(cleanup).toBeTypeOf('function');
});
it('cleanup removes the listener', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler);
cleanup();
target.dispatchEvent(new Event('click'));
expect(handler).not.toHaveBeenCalled();
});
it('passes options to addEventListener', () => {
const target = new EventTarget();
const handler = vi.fn();
listen(target, 'click', handler, { once: true });
target.dispatchEvent(new Event('click'));
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('passes capture option correctly', () => {
const parent = document.createElement('div');
const child = document.createElement('span');
parent.appendChild(child);
const order: string[] = [];
listen(parent, 'click', () => order.push('parent-capture'), { capture: true });
listen(child, 'click', () => order.push('child'));
listen(parent, 'click', () => order.push('parent-bubble'));
child.dispatchEvent(new Event('click', { bubbles: true }));
expect(order).toEqual(['parent-capture', 'child', 'parent-bubble']);
});
it('cleanup works with options', () => {
const target = new EventTarget();
const handler = vi.fn();
const cleanup = listen(target, 'click', handler, { passive: true });
cleanup();
target.dispatchEvent(new Event('click'));
expect(handler).not.toHaveBeenCalled();
});
it('works with AbortSignal in options', () => {
const target = new EventTarget();
const handler = vi.fn();
const controller = new AbortController();
listen(target, 'click', handler, { signal: controller.signal });
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
controller.abort();
target.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledOnce();
});
it('provides correct event type for typed targets', () => {
const video = document.createElement('video');
const handler = vi.fn((event: Event) => {
expect(event.type).toBe('play');
});
listen(video, 'play', handler);
video.dispatchEvent(new Event('play'));
expect(handler).toHaveBeenCalledOnce();
});
it('works with Window', () => {
const handler = vi.fn();
const cleanup = listen(window, 'resize', handler);
window.dispatchEvent(new Event('resize'));
expect(handler).toHaveBeenCalledOnce();
cleanup();
});
it('works with Document', () => {
const handler = vi.fn();
const cleanup = listen(document, 'visibilitychange', handler);
document.dispatchEvent(new Event('visibilitychange'));
expect(handler).toHaveBeenCalledOnce();
cleanup();
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLMediaElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof HTMLMediaElementEventMap>(
target: HTMLMediaElement,
type: K,
listener: (event: HTMLMediaElementEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (HTMLElement)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof HTMLElementEventMap>(
target: HTMLElement,
type: K,
listener: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Window)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof WindowEventMap>(
target: Window,
type: K,
listener: (event: WindowEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target (Document)
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*/
export function listen<K extends keyof DocumentEventMap>(
target: Document,
type: K,
listener: (event: DocumentEventMap[K]) => void,
options?: AddEventListenerOptions,
): () => void;
/**
* Add an event listener and return a cleanup function to remove it.
*
* @param target - The event target
* @param type - The event type
* @param listener - The event listener
* @param options - Optional event listener options
* @returns A cleanup function that removes the event listener
*
* @example
* ```ts
* const cleanup = listen(video, 'play', () => console.log('playing'));
*
* // Later, remove the listener
* cleanup();
* ```
*
* @example
* ```ts
* // With options
* const cleanup = listen(video, 'play', handler, { once: true, passive: true });
* ```
*
* @example
* ```ts
* // With AbortSignal (native browser support)
* const controller = new AbortController();
* listen(video, 'play', handler, { signal: controller.signal });
*
* // Later, abort to remove the listener
* controller.abort();
* ```
*/
export function listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions,
): () => void;
export function listen(
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions,
): () => void {
target.addEventListener(type, listener, options);
return () => target.removeEventListener(type, listener, options);
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { supportsAnimationFrame, supportsIdleCallback } from './supports';
describe('supports', () => {
describe('supportsAnimationFrame', () => {
it('returns a boolean', () => {
const result = supportsAnimationFrame();
expect(typeof result).toBe('boolean');
});
it('returns true in browser environment', () => {
expect(supportsAnimationFrame()).toBe(true);
});
});
describe('supportsIdleCallback', () => {
it('returns a boolean', () => {
const result = supportsIdleCallback();
// Note: requestIdleCallback may or may not be available in jsdom
// depending on the version, so we just check it returns a boolean
expect(typeof result).toBe('boolean');
});
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* Check if `requestIdleCallback` is supported.
*
* @returns `true` if `requestIdleCallback` is available
*/
export function supportsIdleCallback(): boolean {
return typeof requestIdleCallback === 'function';
}
/**
* Check if `requestAnimationFrame` is supported.
*
* @returns `true` if `requestAnimationFrame` is available
*/
export function supportsAnimationFrame(): boolean {
return typeof requestAnimationFrame === 'function';
}