feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
+22 -15
View File
@@ -1,6 +1,6 @@
# @robonen/vue # @robonen/vue
Collection of composables and utilities for Vue 3 — 100+ tree-shakeable, SSR-safe composables. Collection of composables and utilities for Vue 3 — 213+ tree-shakeable, SSR-safe composables.
## Install ## Install
@@ -10,32 +10,39 @@ pnpm install @robonen/vue
## Composables ## Composables
| Category | Composables | | Category | Composables |
| -------------- | ----------- | | -------------- | ----------- |
| **browser** | `onKeyStroke`, `useActiveElement`, `useBodyScrollLock`, `useClickOutside`, `useClipboard`, `useCloseWatcher`, `useColorMode`, `useDevicePixelRatio`, `useDocumentReadyState`, `useDocumentVisibility`, `useDropZone`, `useElementBounding`, `useElementHover`, `useElementSize`, `useElementVisibility`, `useEscapeKey`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFocus`, `useFocusGuard`, `useFocusWithin`, `useFps`, `useFullscreen`, `useGeolocation`, `useIdle`, `useIntersectionObserver`, `useIntervalFn`, `useKeyModifier`, `useMagicKeys`, `useMediaQuery`, `useMouse`, `useMousePressed`, `useMutationObserver`, `useNetwork`, `useObjectUrl`, `useOnline`, `usePageLeave`, `usePermission`, `usePointer`, `usePreferredColorScheme`, `usePreferredDark`, `useRafFn`, `useResizeObserver`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useShare`, `useSupported`, `useSwipe`, `useTabLeader`, `useTextSelection`, `useTitle`, `useVibrate`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` | | **animation** | `useAnimate`, `useCountdown`, `useDateFormat`, `useInterval`, `useIntervalFn`, `useNow`, `useRafFn`, `useTimeAgo`, `useTimeout`, `useTimeoutFn`, `useTimestamp`, `useTransition` |
| **component** | `unrefElement`, `useForwardExpose`, `useTemplateRefsList` | | **array** | `useArrayDifference`, `useArrayEvery`, `useArrayFilter`, `useArrayFind`, `useArrayFindIndex`, `useArrayFindLast`, `useArrayIncludes`, `useArrayJoin`, `useArrayMap`, `useArrayReduce`, `useArraySome`, `useArrayUnique`, `useSorted` |
| **debug** | `useRenderCount`, `useRenderInfo` | | **browser** | `broadcastedRef`, `useBreakpoints`, `useClipboard`, `useClipboardItems`, `useCloseWatcher`, `useColorMode`, `useCssVar`, `useDark`, `useDocumentPiP`, `useEventListener`, `useEyeDropper`, `useFavicon`, `useFileDialog`, `useFileSystemAccess`, `useFullscreen`, `useImage`, `useLocalFonts`, `useMediaQuery`, `useObjectUrl`, `usePermission`, `usePreferredColorScheme`, `usePreferredContrast`, `usePreferredDark`, `usePreferredLanguages`, `usePreferredReducedMotion`, `usePreferredReducedTransparency`, `useScriptTag`, `useShare`, `useStyleTag`, `useTabLeader`, `useTextareaAutosize`, `useTitle`, `useUrlSearchParams`, `useVibrate`, `useWakeLock`, `useWebNotification` |
| **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` | | **component** | `createReusableTemplate`, `unrefElement`, `useCurrentElement`, `useForwardExpose`, `useTemplateRefsList`, `useVirtualList` |
| **math** | `useClamp` | | **debug** | `useRenderCount`, `useRenderInfo` |
| **reactivity** | `broadcastedRef`, `refAutoReset`, `refDebounced`, `refThrottled`, `until`, `useArrayFilter`, `useArrayFind`, `useArrayMap`, `useCached`, `useCloned`, `useCycleList`, `useLastChanged`, `usePrevious`, `useSyncRefs`, `useToNumber`, `useToString`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` | | **elements** | `onElementRemoval`, `useActiveElement`, `useDocumentReadyState`, `useDocumentVisibility`, `useDraggable`, `useDropZone`, `useElementBounding`, `useElementSize`, `useElementVisibility`, `useFocusGuard`, `useIntersectionObserver`, `useMutationObserver`, `useParentElement`, `useResizeObserver`, `useWindowFocus`, `useWindowScroll`, `useWindowSize` |
| **state** | `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useId`, `useInjectionStore`, `useStepper`, `useToggle` | | **forms** | `useField`, `useFieldArray`, `useForm`, `useFormContext` |
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` | | **lifecycle** | `tryOnBeforeMount`, `tryOnMounted`, `tryOnScopeDispose`, `useMounted` |
| **utilities** | `useDebounceFn`, `useInterval`, `useOffsetPagination`, `useThrottleFn`, `useTimeoutFn`, `useTimestamp` | | **math** | `logicAnd`, `logicNot`, `logicOr`, `useAbs`, `useAverage`, `useCeil`, `useClamp`, `useFloor`, `useMath`, `useMax`, `useMin`, `usePrecision`, `useProjection`, `useRound`, `useSum`, `useTrunc` |
| **media** | `useBluetooth`, `useDisplayMedia`, `useMediaControls`, `useMemory`, `usePerformanceObserver`, `useSpeechRecognition`, `useSpeechSynthesis`, `useUserMedia`, `useWebWorker`, `useWebWorkerFn` |
| **reactivity** | `computedAsync`, `computedEager`, `computedWithControl`, `extendRef`, `reactiveComputed`, `reactiveOmit`, `reactivePick`, `refAutoReset`, `refDebounced`, `refDefault`, `refThrottled`, `refWithControl`, `syncRef`, `toReactive`, `useCached`, `useCloned`, `useDebounceFn`, `usePrevious`, `useSyncRefs`, `useThrottleFn`, `useToNumber`, `useToString` |
| **sensors** | `onKeyStroke`, `onLongPress`, `onStartTyping`, `useBattery`, `useBodyScrollLock`, `useClickOutside`, `useDeviceMotion`, `useDeviceOrientation`, `useDevicePixelRatio`, `useDevicesList`, `useElementByPoint`, `useElementHover`, `useEscapeKey`, `useFocus`, `useFocusWithin`, `useFps`, `useGamepad`, `useGeolocation`, `useIdle`, `useInfiniteScroll`, `useKeyModifier`, `useMagicKeys`, `useMouse`, `useMouseInElement`, `useMousePressed`, `useNetwork`, `useOnline`, `usePageLeave`, `useParallax`, `usePointer`, `usePointerLock`, `usePointerSwipe`, `useScreenOrientation`, `useScroll`, `useScrollLock`, `useSwipe`, `useTextSelection` |
| **state** | `createSharedComposable`, `useAppSharedState`, `useAsyncState`, `useContextFactory`, `useCounter`, `useCycleList`, `useDebouncedRefHistory`, `useId`, `useInjectionStore`, `useLastChanged`, `useManualRefHistory`, `useOffsetPagination`, `useRefHistory`, `useStepper`, `useThrottledRefHistory`, `useToggle` |
| **storage** | `useLocalStorage`, `useSessionStorage`, `useStorage`, `useStorageAsync` |
| **utilities** | `createEventHook`, `get`, `isDefined`, `set`, `useEventBus`, `useMemoize`, `useSupported` |
| **watch** | `until`, `watchDebounced`, `watchIgnorable`, `watchOnce`, `watchPausable`, `watchThrottled`, `whenever` |
The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `MaybeComputedElementRef`, …). The package also exports event-filter helpers (`debounceFilter`, `throttleFilter`, `pausableFilter`, `createFilterWrapper`) and shared types (`ConfigurableWindow`, `ConfigurableDocument`, `ConfigurableNavigator`, `ConfigurableFlush`, `MaybeComputedElementRef`, …).
## Usage ## Usage
```ts ```ts
import { useEventListener, useMagicKeys, useToggle } from '@robonen/vue'; import { useEventListener, useMagicKeys, useToggle } from @robonen/vue;
const { value, toggle } = useToggle(); const { value, toggle } = useToggle();
useEventListener('scroll', () => {/* … */}, { passive: true }); useEventListener(scroll, () => {/* … */}, { passive: true });
const keys = useMagicKeys(); const keys = useMagicKeys();
watchEffect(() => { watchEffect(() => {
if (keys['ctrl+s'].value) if (keys[ctrl+s].value)
save(); save();
}); });
``` ```
@@ -0,0 +1,12 @@
export * from './useAnimate';
export * from './useCountdown';
export * from './useDateFormat';
export * from './useInterval';
export * from './useIntervalFn';
export * from './useNow';
export * from './useRafFn';
export * from './useTimeAgo';
export * from './useTimeout';
export * from './useTimeoutFn';
export * from './useTimestamp';
export * from './useTransition';
@@ -0,0 +1,355 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useAnimate } from '.';
// --- Stub Animation -------------------------------------------------------
type Listener = (ev: any) => void;
class StubAnimation {
effect: any;
timeline: any = { kind: 'document' };
startTime: number | null = null;
currentTime: number | null = 0;
playbackRate = 1;
pending = false;
playState: AnimationPlayState = 'idle';
replaceState: AnimationReplaceState = 'active';
play = vi.fn(() => {
this.playState = 'running';
});
pause = vi.fn(() => {
this.playState = 'paused';
});
reverse = vi.fn(() => {
this.playbackRate = -1;
this.playState = 'running';
});
finish = vi.fn(() => {
this.playState = 'finished';
});
cancel = vi.fn(() => {
this.playState = 'idle';
});
persist = vi.fn();
commitStyles = vi.fn();
private listeners = new Map<string, Set<Listener>>();
addEventListener = vi.fn((type: string, listener: Listener) => {
if (!this.listeners.has(type))
this.listeners.set(type, new Set());
this.listeners.get(type)!.add(listener);
});
removeEventListener = vi.fn((type: string, listener: Listener) => {
this.listeners.get(type)?.delete(listener);
});
dispatch(type: string) {
this.listeners.get(type)?.forEach(l => l({ type }));
}
constructor(public keyframes: any, public options: any) {
instances.push(this);
}
}
let instances: StubAnimation[] = [];
let animateSpy: ReturnType<typeof vi.fn>;
// Flush enough rAF + microtasks for the useRafFn store sync to run.
async function flushFrames(count = 3) {
for (let i = 0; i < count; i++) {
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
await nextTick();
}
}
describe(useAnimate, () => {
beforeEach(() => {
instances = [];
animateSpy = vi.fn(function (this: HTMLElement, keyframes: any, options: any) {
return new StubAnimation(keyframes, options) as unknown as Animation;
});
// jsdom does not implement the Web Animations API
Object.defineProperty(HTMLElement.prototype, 'animate', {
configurable: true,
writable: true,
value: animateSpy,
});
vi.stubGlobal('KeyframeEffect', class {
constructor(public el: any, public kf: any, public opts: any) {}
});
});
afterEach(() => {
vi.unstubAllGlobals();
delete (HTMLElement.prototype as any).animate;
});
it('reports support when Element.animate exists', () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000);
});
expect(api.isSupported.value).toBeTruthy();
scope.stop();
});
it('creates the animation immediately for a resolved target', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }, { opacity: 1 }], 1000);
});
await flushFrames();
expect(animateSpy).toHaveBeenCalledTimes(1);
expect(animateSpy).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], 1000);
expect(api.animate.value).toBe(instances[0] as unknown as Animation);
scope.stop();
});
it('passes an options object through to animate, stripping reserved keys', async () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useAnimate(ref(el), { opacity: [0, 1] }, {
duration: 500,
easing: 'ease-in',
immediate: true,
commitStyles: true,
});
});
await flushFrames();
expect(animateSpy).toHaveBeenCalledWith({ opacity: [0, 1] }, { duration: 500, easing: 'ease-in' });
expect(instances[0]!.options).not.toHaveProperty('immediate');
expect(instances[0]!.options).not.toHaveProperty('commitStyles');
scope.stop();
});
it('does not auto-play when immediate is false', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, immediate: false });
});
await flushFrames();
expect(instances[0]!.pause).toHaveBeenCalled();
expect(api.animate.value).toBeDefined();
scope.stop();
});
it('play / pause / reverse / finish / cancel delegate to the Animation', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
});
await flushFrames();
const inst = instances[0]!;
api.pause();
expect(inst.pause).toHaveBeenCalled();
api.play();
expect(inst.play).toHaveBeenCalled();
api.reverse();
expect(inst.reverse).toHaveBeenCalled();
api.finish();
expect(inst.finish).toHaveBeenCalled();
api.cancel();
expect(inst.cancel).toHaveBeenCalled();
scope.stop();
});
it('syncs reactive playState from the live animation', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
});
await flushFrames();
const inst = instances[0]!;
inst.playState = 'running';
inst.currentTime = 42;
api.play();
await flushFrames();
expect(api.playState.value).toBe('running');
expect(api.currentTime.value).toBe(42);
scope.stop();
});
it('writes currentTime back to the animation', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
});
await flushFrames();
const inst = instances[0]!;
api.currentTime.value = 250;
expect(inst.currentTime).toBe(250);
expect(api.currentTime.value).toBe(250);
scope.stop();
});
it('writes playbackRate back to the animation', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], 1000);
});
await flushFrames();
const inst = instances[0]!;
api.playbackRate.value = 2;
expect(inst.playbackRate).toBe(2);
expect(api.playbackRate.value).toBe(2);
scope.stop();
});
it('applies persist and initial playbackRate options', async () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, persist: true, playbackRate: 3 });
});
await flushFrames();
const inst = instances[0]!;
expect(inst.persist).toHaveBeenCalled();
expect(inst.playbackRate).toBe(3);
scope.stop();
});
it('commits styles on finish when commitStyles is set', async () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, commitStyles: true });
});
await flushFrames();
const inst = instances[0]!;
inst.dispatch('finish');
expect(inst.commitStyles).toHaveBeenCalled();
scope.stop();
});
it('calls onReady with the created animation', async () => {
const el = document.createElement('div');
const onReady = vi.fn();
const scope = effectScope();
scope.run(() => {
useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onReady });
});
await flushFrames();
expect(onReady).toHaveBeenCalledWith(instances[0]);
scope.stop();
});
it('routes thrown errors to onError instead of throwing', async () => {
const el = document.createElement('div');
const onError = vi.fn();
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, onError });
});
await flushFrames();
const inst = instances[0]!;
const boom = new Error('boom');
inst.play.mockImplementationOnce(() => {
throw boom;
});
expect(() => api.play()).not.toThrow();
expect(onError).toHaveBeenCalledWith(boom);
scope.stop();
});
it('clears the animation when the target becomes null', async () => {
const el = document.createElement('div');
const target = ref<HTMLElement | null>(el);
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(target, [{ opacity: 0 }], 1000);
});
await flushFrames();
expect(api.animate.value).toBeDefined();
target.value = null;
await nextTick();
expect(api.animate.value).toBeUndefined();
scope.stop();
});
it('cancels the animation on scope dispose', async () => {
const el = document.createElement('div');
const scope = effectScope();
scope.run(() => {
useAnimate(ref(el), [{ opacity: 0 }], 1000);
});
await flushFrames();
const inst = instances[0]!;
scope.stop();
expect(inst.cancel).toHaveBeenCalled();
});
it('is not supported and never animates when window is undefined (SSR)', async () => {
const el = document.createElement('div');
const scope = effectScope();
let api!: ReturnType<typeof useAnimate>;
scope.run(() => {
api = useAnimate(ref(el), [{ opacity: 0 }], { duration: 1000, window: undefined });
});
await flushFrames();
expect(api.isSupported.value).toBeFalsy();
expect(animateSpy).not.toHaveBeenCalled();
expect(api.animate.value).toBeUndefined();
scope.stop();
});
});
@@ -0,0 +1,417 @@
import { computed, shallowReactive, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRef, Ref, ShallowRef, WritableComputedRef } from 'vue';
import { isObject, noop, omit } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { unrefElement } from '@/composables/component/unrefElement';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useRafFn } from '@/composables/animation/useRafFn';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseAnimateOptions extends KeyframeAnimationOptions, ConfigurableWindow {
/**
* Automatically call `play()` once the target element is resolved.
*
* @default true
*/
immediate?: boolean;
/**
* Commit the end styling state of the animation to the element when it finishes.
* Usually paired with the `fill` option.
*
* @default false
*/
commitStyles?: boolean;
/**
* Persist the animation so it is not automatically removed by the browser.
*
* @default false
*/
persist?: boolean;
/**
* Called once the underlying `Animation` instance has been created.
*/
onReady?: (animation: Animation) => void;
/**
* Called when an error is thrown while controlling the animation.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export type UseAnimateKeyframes
= MaybeRef<Keyframe[] | PropertyIndexedKeyframes | null>;
export interface UseAnimateReturn {
/**
* Whether the Web Animations API is supported in the current environment
*/
isSupported: Readonly<Ref<boolean>>;
/**
* The underlying `Animation` instance, or `undefined` before it is created
*/
animate: ShallowRef<Animation | undefined>;
/**
* Start or resume the animation
*/
play: () => void;
/**
* Suspend playback of the animation
*/
pause: () => void;
/**
* Reverse the playback direction of the animation
*/
reverse: () => void;
/**
* Seek the animation to the end of its active duration
*/
finish: () => void;
/**
* Abort the animation, clearing its effects
*/
cancel: () => void;
/**
* Whether the animation is currently waiting for an asynchronous operation
*/
pending: ComputedRef<boolean>;
/**
* The current playback state of the animation
*/
playState: ComputedRef<AnimationPlayState>;
/**
* The current replace state of the animation
*/
replaceState: ComputedRef<AnimationReplaceState>;
/**
* The scheduled time at which the animation should begin (writable)
*/
startTime: WritableComputedRef<CSSNumberish | number | null>;
/**
* The current time value of the animation in milliseconds (writable)
*/
currentTime: WritableComputedRef<CSSNumberish | null>;
/**
* The timeline associated with the animation (writable)
*/
timeline: WritableComputedRef<AnimationTimeline | null>;
/**
* The playback rate of the animation (writable)
*/
playbackRate: WritableComputedRef<number>;
}
interface AnimateStore {
startTime: CSSNumberish | number | null;
currentTime: CSSNumberish | null;
timeline: AnimationTimeline | null;
playbackRate: number;
pending: boolean;
playState: AnimationPlayState;
replaceState: AnimationReplaceState;
}
const RESERVED_KEYS = [
'window',
'immediate',
'commitStyles',
'persist',
'onReady',
'onError',
] as const;
/**
* @name useAnimate
* @category Animation
* @description Reactive [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API)
* wrapper for a single element. Exposes imperative controls (`play`, `pause`, `reverse`,
* `finish`, `cancel`) alongside reactive state (`playState`, `currentTime`, `playbackRate`, ...).
* The reactive state is synced via `requestAnimationFrame` only while the animation is running,
* so an idle animation costs nothing. SSR-safe: nothing touches the DOM until the element resolves.
*
* @param {MaybeComputedElementRef} target Element to animate (reactive ref, getter, or element)
* @param {UseAnimateKeyframes} keyframes Keyframes to animate, reactive
* @param {number | UseAnimateOptions} [options] Duration in ms, or full options object
* @returns {UseAnimateReturn} Support flag, the `Animation` instance, controls, and reactive state
*
* @example
* const el = useTemplateRef<HTMLElement>('el');
* const { playState, play, pause } = useAnimate(
* el,
* [{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
* { duration: 1000, iterations: Infinity },
* );
*
* @example
* // Shorthand: third argument is the duration in milliseconds
* useAnimate(el, { opacity: [0, 1] }, 500);
*
* @since 0.0.15
*/
export function useAnimate(
target: MaybeComputedElementRef,
keyframes: UseAnimateKeyframes,
options?: number | UseAnimateOptions,
): UseAnimateReturn {
let config: UseAnimateOptions;
let animateOptions: number | KeyframeAnimationOptions | undefined;
if (isObject(options)) {
config = options;
animateOptions = omit(options, RESERVED_KEYS as unknown as Array<keyof UseAnimateOptions>);
}
else {
config = { duration: options };
animateOptions = options;
}
const {
immediate = true,
commitStyles = false,
persist = false,
playbackRate: initialPlaybackRate = 1,
onReady,
onError = noop,
} = config;
// Honor an explicit `window: undefined` (SSR / opt-out) rather than letting a
// default parameter silently restore `defaultWindow`.
const window = 'window' in config ? config.window : defaultWindow;
const isSupported = useSupported(() =>
Boolean(window) && typeof HTMLElement !== 'undefined' && 'animate' in HTMLElement.prototype);
const animate = shallowRef<Animation | undefined>(undefined);
const store = shallowReactive<AnimateStore>({
startTime: null,
currentTime: null,
timeline: null,
playbackRate: initialPlaybackRate,
pending: false,
playState: immediate ? 'idle' : 'paused',
replaceState: 'active',
});
const pending = computed(() => store.pending);
const playState = computed(() => store.playState);
const replaceState = computed(() => store.replaceState);
const startTime = computed<CSSNumberish | number | null>({
get: () => store.startTime,
set(value) {
store.startTime = value;
if (animate.value)
animate.value.startTime = value;
},
});
const currentTime = computed<CSSNumberish | null>({
get: () => store.currentTime,
set(value) {
store.currentTime = value;
if (animate.value) {
animate.value.currentTime = value;
syncResume();
}
},
});
const timeline = computed<AnimationTimeline | null>({
get: () => store.timeline,
set(value) {
store.timeline = value;
if (animate.value)
animate.value.timeline = value;
},
});
const playbackRate = computed<number>({
get: () => store.playbackRate,
set(value) {
store.playbackRate = value;
if (animate.value)
animate.value.playbackRate = value;
},
});
function update(init?: boolean): void {
const el = unrefElement(target);
if (!isSupported.value || !el)
return;
if (!animate.value)
animate.value = (el as HTMLElement).animate(toValue(keyframes), animateOptions);
if (persist)
animate.value.persist();
if (initialPlaybackRate !== 1)
animate.value.playbackRate = initialPlaybackRate;
if (init && !immediate)
animate.value.pause();
else
syncResume();
onReady?.(animate.value);
}
function play(): void {
if (!animate.value) {
update();
return;
}
try {
animate.value.play();
syncResume();
}
catch (error) {
syncPause();
onError(error);
}
}
function pause(): void {
try {
animate.value?.pause();
syncPause();
}
catch (error) {
onError(error);
}
}
function reverse(): void {
if (!animate.value)
update();
try {
animate.value?.reverse();
syncResume();
}
catch (error) {
syncPause();
onError(error);
}
}
function finish(): void {
try {
animate.value?.finish();
syncPause();
}
catch (error) {
onError(error);
}
}
function cancel(): void {
try {
animate.value?.cancel();
syncPause();
}
catch (error) {
onError(error);
}
}
// Sync the reactive store from the live Animation on every frame. The loop is
// paused by default and only resumed while the animation is actually playing,
// so an idle (or finished) animation incurs zero per-frame cost.
const { resume: resumeRaf, pause: pauseRaf } = useRafFn(() => {
const a = animate.value;
if (!a)
return;
store.pending = a.pending;
store.playState = a.playState;
store.replaceState = a.replaceState;
store.startTime = a.startTime;
store.currentTime = a.currentTime;
store.timeline = a.timeline;
store.playbackRate = a.playbackRate;
}, { immediate: false, window });
function syncResume(): void {
if (isSupported.value)
resumeRaf();
}
function syncPause(): void {
// Defer the stop by one frame so the final state (e.g. 'finished') is captured
// before the loop halts.
if (isSupported.value && window)
window.requestAnimationFrame(pauseRaf);
}
watch(() => unrefElement(target), (el) => {
if (el)
update(true);
else
animate.value = undefined;
});
watch(() => keyframes, (value) => {
if (!animate.value)
return;
update();
const el = unrefElement(target);
if (el && typeof KeyframeEffect !== 'undefined')
animate.value.effect = new KeyframeEffect(el as HTMLElement, toValue(value), animateOptions);
}, { deep: true });
const listenerOptions = { passive: true };
useEventListener(animate, ['cancel', 'finish', 'remove'], syncPause, listenerOptions);
useEventListener(animate, 'finish', () => {
if (commitStyles)
animate.value?.commitStyles();
}, listenerOptions);
tryOnMounted(() => update(true), { sync: false });
tryOnScopeDispose(cancel);
return {
isSupported,
animate,
play,
pause,
reverse,
finish,
cancel,
pending,
playState,
replaceState,
startTime,
currentTime,
timeline,
playbackRate,
};
}
@@ -0,0 +1,187 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, ref } from 'vue';
import { useCountdown } from '.';
describe(useCountdown, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('does not start until start/resume is called (immediate defaults to false)', () => {
const { remaining } = useCountdown(5);
expect(remaining.value).toBe(5);
vi.advanceTimersByTime(3000);
expect(remaining.value).toBe(5);
});
it('decrements remaining on each tick', () => {
const { remaining, start } = useCountdown(5);
start();
expect(remaining.value).toBe(5);
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(4);
vi.advanceTimersByTime(2000);
expect(remaining.value).toBe(2);
});
it('starts immediately when immediate is true', () => {
const { remaining } = useCountdown(3, { immediate: true });
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(2);
});
it('exposes a read-only remaining ref', () => {
const { remaining } = useCountdown(5);
expect(isReadonly(remaining)).toBeTruthy();
});
it('stops at zero and never goes negative', () => {
const { remaining, start } = useCountdown(2);
start();
vi.advanceTimersByTime(5000);
expect(remaining.value).toBe(0);
});
it('calls onComplete exactly once when reaching zero', () => {
const onComplete = vi.fn();
const { start } = useCountdown(2, { onComplete });
start();
vi.advanceTimersByTime(2000);
expect(onComplete).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(2000);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('calls onTick with the current remaining value', () => {
const onTick = vi.fn();
const { start } = useCountdown(3, { onTick });
start();
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenLastCalledWith(2);
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenLastCalledWith(1);
});
it('pauses and resumes from where it left off', () => {
const { remaining, start, pause, resume, isActive } = useCountdown(10);
start();
vi.advanceTimersByTime(2000);
expect(remaining.value).toBe(8);
pause();
expect(isActive.value).toBeFalsy();
vi.advanceTimersByTime(3000);
expect(remaining.value).toBe(8);
resume();
expect(isActive.value).toBeTruthy();
vi.advanceTimersByTime(2000);
expect(remaining.value).toBe(6);
});
it('does not resume when remaining is already zero', () => {
const { remaining, start, resume, isActive } = useCountdown(1);
start();
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(0);
expect(isActive.value).toBeFalsy();
resume();
expect(isActive.value).toBeFalsy();
});
it('stop pauses and resets remaining to the initial value', () => {
const { remaining, start, stop, isActive } = useCountdown(5);
start();
vi.advanceTimersByTime(2000);
expect(remaining.value).toBe(3);
stop();
expect(isActive.value).toBeFalsy();
expect(remaining.value).toBe(5);
});
it('reset restores the initial value without changing the running state', () => {
const { remaining, start, reset, isActive } = useCountdown(5);
start();
vi.advanceTimersByTime(2000);
expect(remaining.value).toBe(3);
reset();
expect(remaining.value).toBe(5);
expect(isActive.value).toBeTruthy();
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(4);
});
it('reset and start accept an explicit countdown override', () => {
const { remaining, start, reset } = useCountdown(5);
reset(8);
expect(remaining.value).toBe(8);
start(3);
expect(remaining.value).toBe(3);
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(2);
});
it('honours a custom tick interval', () => {
const { remaining, start } = useCountdown(5, { interval: 500 });
start();
vi.advanceTimersByTime(500);
expect(remaining.value).toBe(4);
vi.advanceTimersByTime(1000);
expect(remaining.value).toBe(2);
});
it('resolves a reactive initial countdown', () => {
const initial = ref(4);
const { remaining, reset } = useCountdown(initial);
expect(remaining.value).toBe(4);
initial.value = 9;
reset();
expect(remaining.value).toBe(9);
});
it('toggle flips the active state', () => {
const { start, toggle, isActive } = useCountdown(10);
start();
expect(isActive.value).toBeTruthy();
toggle();
expect(isActive.value).toBeFalsy();
toggle();
expect(isActive.value).toBeTruthy();
});
it('cleans up the interval when the scope is disposed', () => {
const scope = effectScope();
let api: ReturnType<typeof useCountdown> | undefined;
scope.run(() => {
api = useCountdown(10, { immediate: true });
});
vi.advanceTimersByTime(2000);
expect(api!.remaining.value).toBe(8);
scope.stop();
vi.advanceTimersByTime(5000);
expect(api!.remaining.value).toBe(8);
});
});
@@ -0,0 +1,147 @@
import { shallowReadonly, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import type { ResumableActions } from '@/types';
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
export interface UseCountdownOptions {
/**
* Tick interval in milliseconds. Each tick decrements `remaining` by one.
*
* @default 1000
*/
interval?: MaybeRefOrGetter<number>;
/**
* Start the countdown immediately when the composable is created
*
* @default false
*/
immediate?: boolean;
/**
* Callback invoked on every tick with the current remaining value
*/
onTick?: (remaining: number) => void;
/**
* Callback invoked once when the countdown reaches zero
*/
onComplete?: () => void;
}
export interface UseCountdownReturn extends ResumableActions {
/**
* The remaining seconds, read-only (use `reset`/`start` to change it)
*/
remaining: Readonly<ShallowRef<number>>;
/**
* Whether the countdown is currently running
*/
isActive: UseIntervalFnReturn['isActive'];
/**
* Reset `remaining` (defaults to the initial value) without changing the
* running state
*/
reset: (countdown?: MaybeRefOrGetter<number>) => void;
/**
* Pause the countdown and reset `remaining` to the initial value
*/
stop: () => void;
/**
* Reset `remaining` (defaults to the initial value) and start counting down
*/
start: (countdown?: MaybeRefOrGetter<number>) => void;
}
/**
* @name useCountdown
* @category Animation
* @description Reactive countdown timer exposing the remaining seconds plus
* `start`/`stop`/`pause`/`resume`/`reset` controls and `onTick`/`onComplete`
* callbacks. Built on `useIntervalFn`, so it is SSR-safe and cleans up on scope
* dispose.
*
* @param {MaybeRefOrGetter<number>} initialCountdown The starting value, in seconds (can be reactive)
* @param {UseCountdownOptions} [options={}] Options
* @returns {UseCountdownReturn} The reactive remaining value and countdown controls
*
* @example
* const { remaining, start, pause, resume, stop } = useCountdown(60);
* start();
*
* @example
* useCountdown(10, {
* immediate: true,
* onTick: (n) => console.log(n),
* onComplete: () => console.log('done'),
* });
*
* @since 0.0.15
*/
export function useCountdown(
initialCountdown: MaybeRefOrGetter<number>,
options: UseCountdownOptions = {},
): UseCountdownReturn {
const {
interval = 1000,
immediate = false,
onTick,
onComplete,
} = options;
const remaining = shallowRef(toValue(initialCountdown));
const controls = useIntervalFn(() => {
const next = remaining.value - 1;
remaining.value = next < 0 ? 0 : next;
onTick?.(remaining.value);
if (remaining.value <= 0) {
controls.pause();
onComplete?.();
}
}, interval, { immediate });
const reset = (countdown?: MaybeRefOrGetter<number>): void => {
remaining.value = toValue(countdown) ?? toValue(initialCountdown);
};
const stop = (): void => {
controls.pause();
reset();
};
const resume = (): void => {
if (!controls.isActive.value && remaining.value > 0)
controls.resume();
};
const start = (countdown?: MaybeRefOrGetter<number>): void => {
reset(countdown);
controls.resume();
};
const toggle = (): void => {
if (controls.isActive.value)
controls.pause();
else
resume();
};
return {
remaining: shallowReadonly(remaining),
isActive: controls.isActive,
reset,
stop,
start,
pause: controls.pause,
resume,
toggle,
};
}
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { effectScope, ref } from 'vue';
import { formatDate, normalizeDate, useDateFormat } from '.';
// A fixed local date: 2024-03-09 18:07:05.042 (a Saturday).
function fixture(): Date {
return new Date(2024, 2, 9, 18, 7, 5, 42);
}
describe(useDateFormat, () => {
it('defaults to HH:mm:ss', () => {
const formatted = useDateFormat(fixture());
expect(formatted.value).toBe('18:07:05');
});
it('formats year/month/day tokens', () => {
const date = fixture();
expect(useDateFormat(date, 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat(date, 'YY/M/D').value).toBe('24/3/9');
});
it('formats time tokens including milliseconds and 12-hour', () => {
const date = fixture();
expect(useDateFormat(date, 'HH:mm:ss.SSS').value).toBe('18:07:05.042');
expect(useDateFormat(date, 'h:mm').value).toBe('6:07');
expect(useDateFormat(date, 'hh').value).toBe('06');
});
it('handles 12 -> 12 and 0 -> 12 for the h token', () => {
expect(useDateFormat(new Date(2024, 0, 1, 0, 0, 0), 'h A').value).toBe('12 AM');
expect(useDateFormat(new Date(2024, 0, 1, 12, 0, 0), 'h A').value).toBe('12 PM');
});
it('formats the meridiem variants', () => {
const pm = fixture();
expect(useDateFormat(pm, 'A').value).toBe('PM');
expect(useDateFormat(pm, 'AA').value).toBe('P.M.');
expect(useDateFormat(pm, 'a').value).toBe('pm');
expect(useDateFormat(pm, 'aa').value).toBe('p.m.');
const am = new Date(2024, 0, 1, 6, 0, 0);
expect(useDateFormat(am, 'A').value).toBe('AM');
});
it('formats ordinal tokens', () => {
const date = new Date(2024, 0, 1, 3, 0, 0); // Jan 1st, 3 o'clock
expect(useDateFormat(date, 'Do').value).toBe('1st');
expect(useDateFormat(date, 'Mo').value).toBe('1st');
expect(useDateFormat(new Date(2024, 1, 22), 'Do').value).toBe('22nd');
expect(useDateFormat(new Date(2024, 1, 23), 'Do').value).toBe('23rd');
expect(useDateFormat(new Date(2024, 1, 11), 'Do').value).toBe('11th');
});
it('formats localized weekday and month with the locales option', () => {
const date = fixture(); // a Saturday in March
expect(useDateFormat(date, 'dddd', { locales: 'en-US' }).value).toBe('Saturday');
expect(useDateFormat(date, 'ddd', { locales: 'en-US' }).value).toBe('Sat');
expect(useDateFormat(date, 'MMMM', { locales: 'en-US' }).value).toBe('March');
expect(useDateFormat(date, 'MMM', { locales: 'en-US' }).value).toBe('Mar');
expect(useDateFormat(date, 'd', { locales: 'en-US' }).value).toBe('6'); // Saturday
});
it('uses a custom meridiem function', () => {
const date = fixture();
const formatted = useDateFormat(date, 'h:mm a', {
customMeridiem: hours => (hours < 12 ? 'morning' : 'evening'),
});
expect(formatted.value).toBe('6:07 evening');
});
it('emits [literal] escapes verbatim', () => {
const date = fixture();
expect(useDateFormat(date, '[Year:] YYYY').value).toBe('Year: 2024');
expect(useDateFormat(date, '[YYYY] YYYY').value).toBe('YYYY 2024');
});
it('is reactive to the date, format, and locale', () => {
const date = ref<Date>(fixture());
const format = ref('YYYY');
const locale = ref('en-US');
const formatted = useDateFormat(date, format, { locales: locale });
expect(formatted.value).toBe('2024');
date.value = new Date(2025, 0, 1);
expect(formatted.value).toBe('2025');
format.value = 'MMMM';
expect(formatted.value).toBe('January');
locale.value = 'fr-FR';
expect(formatted.value).toBe('janvier');
});
it('accepts a numeric timestamp and a getter', () => {
const date = fixture();
expect(useDateFormat(date.getTime(), 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat(() => date, 'YYYY').value).toBe('2024');
});
it('parses loose date strings without a trailing Z', () => {
expect(useDateFormat('2024-03-09', 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat('2024-3', 'YYYY-MM-DD').value).toBe('2024-03-01');
expect(useDateFormat('2024-03-09 18:07:05', 'HH:mm:ss').value).toBe('18:07:05');
});
it('handles null/undefined by resolving to now without throwing', () => {
expect(useDateFormat(undefined, 'YYYY').value).toMatch(/^\d{4}$/);
expect(useDateFormat(null, 'YYYY').value).toMatch(/^\d{4}$/);
});
it('returns "Invalid Date" for unparseable input instead of NaN tokens', () => {
expect(useDateFormat('not a date', 'YYYY-MM-DD').value).toBe('Invalid Date');
expect(useDateFormat(Number.NaN, 'HH:mm:ss').value).toBe('Invalid Date');
});
it('constructs inside an effect scope without throwing (SSR-safe, no global access)', () => {
const scope = effectScope();
let formatted: ReturnType<typeof useDateFormat> | undefined;
scope.run(() => {
formatted = useDateFormat(fixture(), 'YYYY-MM-DD');
});
expect(formatted?.value).toBe('2024-03-09');
scope.stop();
});
});
describe(formatDate, () => {
it('formats a date one-shot', () => {
expect(formatDate(fixture(), 'YYYY/MM/DD')).toBe('2024/03/09');
});
it('returns "Invalid Date" for an invalid date', () => {
expect(formatDate(new Date(Number.NaN), 'YYYY')).toBe('Invalid Date');
});
});
describe(normalizeDate, () => {
it('returns a fresh Date for a Date input', () => {
const date = fixture();
const normalized = normalizeDate(date);
expect(normalized).not.toBe(date);
expect(normalized.getTime()).toBe(date.getTime());
});
it('resolves null/undefined to a valid current Date', () => {
expect(Number.isNaN(normalizeDate(undefined).getTime())).toBeFalsy();
expect(Number.isNaN(normalizeDate(null).getTime())).toBeFalsy();
});
it('parses a numeric timestamp', () => {
const date = fixture();
expect(normalizeDate(date.getTime()).getTime()).toBe(date.getTime());
});
});
@@ -0,0 +1,217 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isDate, isString } from '@robonen/stdlib';
/**
* Accepted input for {@link useDateFormat}: a `Date`, a millisecond timestamp,
* a parseable date string, or `null`/`undefined` (resolves to "now").
*/
export type DateLike = Date | number | string | null | undefined;
/**
* Signature for a custom meridiem (AM/PM) formatter.
*
* @param hours The hour of the day, 0-23
* @param minutes The minute of the hour, 0-59
* @param isLowercase Whether the token requested a lowercase form (`a`/`aa`)
* @param hasPeriod Whether the token requested period separators (`AA`/`aa`)
*/
export type CustomMeridiem
= (hours: number, minutes: number, isLowercase?: boolean, hasPeriod?: boolean) => string;
export interface UseDateFormatOptions {
/**
* The locale(s) used for the `dd`/`ddd`/`dddd`/`MMM`/`MMMM`/`z` tokens.
*
* Accepts a reactive value (ref or getter); the output recomputes when it
* changes.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument
*/
locales?: MaybeRefOrGetter<Intl.LocalesArgument>;
/**
* A custom function controlling how the meridiem (`A`/`AA`/`a`/`aa`) is
* rendered.
*/
customMeridiem?: CustomMeridiem;
}
/**
* Reactive formatted date string.
*/
export type UseDateFormatReturn = ComputedRef<string>;
// Matches a token, or a `[literal]` escape that is emitted verbatim.
const REGEX_FORMAT
= /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g;
// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional
// separators make adjacent digit groups technically "misleading" to the linter,
// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and
// `20240101`); JS lacks possessive quantifiers to disambiguate it.
// eslint-disable-next-line regexp/no-misleading-capturing-group
const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i;
const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
function defaultMeridiem(
hours: number,
_minutes: number,
isLowercase?: boolean,
hasPeriod?: boolean,
): string {
let m = hours < 12 ? 'AM' : 'PM';
if (hasPeriod) m = `${m[0]}.${m[1]}.`;
return isLowercase ? m.toLowerCase() : m;
}
function formatOrdinal(num: number): string {
const v = num % 100;
return num + (ORDINAL_SUFFIXES[(v - 20) % 10] || ORDINAL_SUFFIXES[v] || ORDINAL_SUFFIXES[0]);
}
/**
* Coerce a {@link DateLike} into a `Date`. `null`/`undefined` become the
* current time; a non-UTC string is parsed leniently so partial dates such as
* `'2024-3'` are accepted.
*
* @param date The value to coerce
* @returns A `Date` instance (possibly `Invalid Date`)
*/
export function normalizeDate(date: DateLike): Date {
if (date === null || date === undefined) return new Date();
if (isDate(date)) return new Date(date.getTime());
if (isString(date) && !/z$/i.test(date)) {
const d = REGEX_PARSE.exec(date);
if (d) {
const month = d[2] ? Number(d[2]) - 1 : 0;
const ms = (d[7] || '0').slice(0, 3);
return new Date(
Number(d[1]),
month,
Number(d[3]) || 1,
Number(d[4]) || 0,
Number(d[5]) || 0,
Number(d[6]) || 0,
Number(ms),
);
}
}
return new Date(date);
}
/**
* Format a `Date` against a token string. Exposed for one-shot, non-reactive
* formatting; {@link useDateFormat} wraps this in a `computed`.
*
* @param date The date to format
* @param formatStr The combination of tokens (e.g. `'YYYY-MM-DD HH:mm:ss'`)
* @param options Locale and meridiem options
* @returns The formatted string
*/
export function formatDate(
date: Date,
formatStr: string,
options: UseDateFormatOptions = {},
): string {
// Invalid dates round-trip to the literal "Invalid Date" rather than
// emitting `NaN` for every numeric token.
if (Number.isNaN(date.getTime())) return 'Invalid Date';
const years = date.getFullYear();
const month = date.getMonth();
const days = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
const day = date.getDay();
const hour12 = hours % 12 || 12;
const locales = toValue(options.locales);
const meridiem = options.customMeridiem ?? defaultMeridiem;
// The timeZoneName lands after the date in the localized string; grab it.
const offsetName = (style: 'shortOffset' | 'longOffset'): string =>
date.toLocaleDateString(locales, { timeZoneName: style }).split(' ')[1] ?? '';
const matches: Record<string, () => string | number> = {
Yo: () => formatOrdinal(years),
YY: () => String(years).slice(-2),
YYYY: () => years,
M: () => month + 1,
Mo: () => formatOrdinal(month + 1),
MM: () => String(month + 1).padStart(2, '0'),
MMM: () => date.toLocaleDateString(locales, { month: 'short' }),
MMMM: () => date.toLocaleDateString(locales, { month: 'long' }),
D: () => String(days),
Do: () => formatOrdinal(days),
DD: () => String(days).padStart(2, '0'),
H: () => String(hours),
Ho: () => formatOrdinal(hours),
HH: () => String(hours).padStart(2, '0'),
h: () => String(hour12),
ho: () => formatOrdinal(hour12),
hh: () => String(hour12).padStart(2, '0'),
m: () => String(minutes),
mo: () => formatOrdinal(minutes),
mm: () => String(minutes).padStart(2, '0'),
s: () => String(seconds),
so: () => formatOrdinal(seconds),
ss: () => String(seconds).padStart(2, '0'),
SSS: () => String(milliseconds).padStart(3, '0'),
d: () => day,
dd: () => date.toLocaleDateString(locales, { weekday: 'narrow' }),
ddd: () => date.toLocaleDateString(locales, { weekday: 'short' }),
dddd: () => date.toLocaleDateString(locales, { weekday: 'long' }),
A: () => meridiem(hours, minutes),
AA: () => meridiem(hours, minutes, false, true),
a: () => meridiem(hours, minutes, true),
aa: () => meridiem(hours, minutes, true, true),
z: () => offsetName('shortOffset'),
zz: () => offsetName('shortOffset'),
zzz: () => offsetName('shortOffset'),
zzzz: () => offsetName('longOffset'),
};
return formatStr.replaceAll(REGEX_FORMAT, (match, literal) =>
literal ?? String(matches[match]?.() ?? match),
);
}
/**
* @name useDateFormat
* @category Animation
* @description Reactively format a `Date`, timestamp, or date string against a
* token string (`YYYY MM DD HH mm ss SSS dddd A` etc.). Recomputes when the
* date, format, or locale changes.
*
* @param {MaybeRefOrGetter<DateLike>} date The date to format
* @param {MaybeRefOrGetter<string>} [formatStr='HH:mm:ss'] The token string
* @param {UseDateFormatOptions} [options={}] Locale and meridiem options
* @returns {ComputedRef<string>} The reactive formatted string
*
* @example
* const formatted = useDateFormat(useNow(), 'YYYY-MM-DD HH:mm:ss');
*
* @example
* // Localized weekday + month, reactive locale
* const locale = ref('fr-FR');
* const label = useDateFormat(date, 'dddd, MMMM D', { locales: locale });
*
* @example
* // Custom meridiem
* const t = useDateFormat(date, 'hh:mm a', {
* customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'),
* });
*
* @since 0.0.15
*/
export function useDateFormat(
date: MaybeRefOrGetter<DateLike>,
formatStr: MaybeRefOrGetter<string> = 'HH:mm:ss',
options: UseDateFormatOptions = {},
): UseDateFormatReturn {
return computed(() => formatDate(normalizeDate(toValue(date)), toValue(formatStr), options));
}
@@ -1,8 +1,8 @@
import { shallowReadonly, shallowRef } from 'vue'; import { shallowReadonly, shallowRef } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue'; import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import type { ResumableActions } from '@/types'; import type { ResumableActions } from '@/types';
import { useIntervalFn } from '@/composables/browser/useIntervalFn'; import { useIntervalFn } from '@/composables/animation/useIntervalFn';
import type { UseIntervalFnReturn } from '@/composables/browser/useIntervalFn'; import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
export interface UseIntervalOptions<Controls extends boolean> { export interface UseIntervalOptions<Controls extends boolean> {
/** /**
@@ -46,7 +46,7 @@ export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalContro
/** /**
* @name useInterval * @name useInterval
* @category Utilities * @category Animation
* @description Reactive counter that increments on every interval tick. * @description Reactive counter that increments on every interval tick.
* *
* @param {MaybeRefOrGetter<number>} [interval=1000] Interval in milliseconds (can be reactive) * @param {MaybeRefOrGetter<number>} [interval=1000] Interval in milliseconds (can be reactive)
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import type { Ref } from 'vue';
import { useNow } from '.';
import type { UseNowControls } from '.';
describe(useNow, () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1000);
});
afterEach(() => vi.useRealTimers());
it('returns the current date', () => {
const now = useNow({ interval: 100 });
expect(now.value).toBeInstanceOf(Date);
expect(now.value.getTime()).toBe(1000);
});
it('updates on the interval', () => {
const now = useNow({ interval: 100 });
// advanceTimersByTime also advances the mocked clock, so the tick fires at 1100
vi.advanceTimersByTime(100);
expect(now.value.getTime()).toBe(1100);
vi.advanceTimersByTime(100);
expect(now.value.getTime()).toBe(1200);
});
it('exposes controls when controls: true', () => {
const { now, pause } = useNow({ controls: true, interval: 100 });
expect(now.value).toBeInstanceOf(Date);
vi.advanceTimersByTime(100);
expect(now.value.getTime()).toBe(1100);
pause();
vi.advanceTimersByTime(100);
expect(now.value.getTime()).toBe(1100);
});
it('exposes isActive and reflects pause/resume/toggle', () => {
const { isActive, pause, resume, toggle } = useNow({ controls: true, interval: 100 });
expect(isActive.value).toBeTruthy();
pause();
expect(isActive.value).toBeFalsy();
resume();
expect(isActive.value).toBeTruthy();
toggle();
expect(isActive.value).toBeFalsy();
});
it('does not start updating when immediate is false', () => {
const { now, isActive } = useNow({ controls: true, interval: 100, immediate: false });
expect(isActive.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(now.value.getTime()).toBe(1000);
});
it('invokes the callback on every update with the current date', () => {
const callback = vi.fn();
useNow({ interval: 100, callback });
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback.mock.lastCall?.[0]).toBeInstanceOf(Date);
expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1100);
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledTimes(2);
expect((callback.mock.lastCall?.[0] as Date).getTime()).toBe(1200);
});
it('produces a fresh Date instance on each update', () => {
const now = useNow({ interval: 100 });
const first = now.value;
vi.advanceTimersByTime(100);
expect(now.value).not.toBe(first);
expect(now.value.getTime()).toBe(1100);
});
it('defaults to the requestAnimationFrame strategy', () => {
const raf = vi.fn().mockReturnValue(1);
const caf = vi.fn();
vi.stubGlobal('requestAnimationFrame', raf);
vi.stubGlobal('cancelAnimationFrame', caf);
try {
const scope = effectScope();
let result: UseNowControls | undefined;
scope.run(() => {
result = useNow({ controls: true });
});
// RAF strategy starts the loop immediately
expect(result?.isActive.value).toBeTruthy();
expect(raf).toHaveBeenCalled();
scope.stop();
}
finally {
vi.unstubAllGlobals();
}
});
it('cleans up the updater when the scope is disposed', () => {
const scope = effectScope();
let now: Ref<Date> | undefined;
scope.run(() => {
now = useNow({ interval: 100 });
});
vi.advanceTimersByTime(100);
expect(now?.value.getTime()).toBe(1100);
scope.stop();
vi.advanceTimersByTime(100);
expect(now?.value.getTime()).toBe(1100);
});
it('does not update when interval mode runs without a callback firing (SSR-safe construction)', () => {
// useNow must construct without throwing even before any tick; the initial
// value is always a valid Date regardless of environment.
const now = useNow({ interval: 100, immediate: false });
expect(now.value).toBeInstanceOf(Date);
expect(now.value.getTime()).toBe(1000);
});
});
@@ -0,0 +1,110 @@
import { shallowRef } from 'vue';
import type { Ref } from 'vue';
import type { ResumableActions } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn';
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
export interface UseNowOptions<Controls extends boolean> {
/**
* Expose pause/resume controls alongside the date
*
* @default false
*/
controls?: Controls;
/**
* Start updating immediately
*
* @default true
*/
immediate?: boolean;
/**
* Update strategy. `'requestAnimationFrame'` updates every frame; a number
* updates on a fixed interval (ms).
*
* @default 'requestAnimationFrame'
*/
interval?: 'requestAnimationFrame' | number;
/**
* Callback invoked on every update with the current date
*/
callback?: (now: Date) => void;
}
/**
* Pause/resume controls returned when `controls: true`.
*/
export interface UseNowControls extends ResumableActions {
/**
* The reactive current date
*/
now: Ref<Date>;
/**
* Whether the updater (RAF loop or interval) is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
export type UseNowReturn<Controls extends boolean>
= Controls extends true ? UseNowControls : Ref<Date>;
/**
* @name useNow
* @category Animation
* @description Reactive current `Date`, updated via `requestAnimationFrame`
* or a fixed interval.
*
* @param {UseNowOptions} [options={}] Options
* @returns {Ref<Date> | UseNowControls} The date, or controls when `controls: true`
*
* @example
* const now = useNow();
*
* @example
* const { now, pause, resume, isActive } = useNow({ controls: true, interval: 1000 });
*
* @example
* // Run a callback on every update
* useNow({ interval: 1000, callback: date => console.log(date.toISOString()) });
*
* @since 0.0.15
*/
export function useNow(options?: UseNowOptions<false>): Ref<Date>;
export function useNow(options: UseNowOptions<true>): UseNowControls;
export function useNow(
options: UseNowOptions<boolean> = {},
): Ref<Date> | UseNowControls {
const {
controls = false,
immediate = true,
interval = 'requestAnimationFrame',
callback,
} = options;
const now = shallowRef(new Date());
const update = callback
? () => {
now.value = new Date();
callback(now.value);
}
: () => {
now.value = new Date();
};
const resumableControls = interval === 'requestAnimationFrame'
? useRafFn(update, { immediate })
: useIntervalFn(update, interval, { immediate });
if (controls) {
return {
now,
...resumableControls,
};
}
return now;
}
@@ -0,0 +1,295 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, shallowRef } from 'vue';
import type { ComputedRef } from 'vue';
import { formatTimeAgo, useTimeAgo } from '.';
import type { UseTimeAgoControls, UseTimeAgoMessages } from '.';
const BASE = 1_700_000_000_000; // fixed epoch for deterministic diffs
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
describe(formatTimeAgo, () => {
it('returns justNow when under a minute by default', () => {
expect(formatTimeAgo(new Date(BASE - 30 * SECOND), {}, BASE)).toBe('just now');
});
it('shows seconds when showSecond is true', () => {
expect(formatTimeAgo(new Date(BASE - 30 * SECOND), { showSecond: true }, BASE)).toBe('30 seconds ago');
});
it('formats minutes in the past', () => {
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), {}, BASE)).toBe('3 minutes ago');
});
it('formats a single minute (no pluralization)', () => {
expect(formatTimeAgo(new Date(BASE - 1 * MINUTE), {}, BASE)).toBe('1 minute ago');
});
it('formats future minutes', () => {
expect(formatTimeAgo(new Date(BASE + 5 * MINUTE), {}, BASE)).toBe('in 5 minutes');
});
it('formats hours', () => {
expect(formatTimeAgo(new Date(BASE - 2 * HOUR), {}, BASE)).toBe('2 hours ago');
});
it('uses the special yesterday/tomorrow forms for a single day', () => {
expect(formatTimeAgo(new Date(BASE - 1 * DAY), {}, BASE)).toBe('yesterday');
expect(formatTimeAgo(new Date(BASE + 1 * DAY), {}, BASE)).toBe('tomorrow');
});
it('uses last week / next week for a single week', () => {
expect(formatTimeAgo(new Date(BASE - 1 * WEEK), {}, BASE)).toBe('last week');
expect(formatTimeAgo(new Date(BASE + 1 * WEEK), {}, BASE)).toBe('next week');
});
it('falls back to the full date when a numeric max is exceeded', () => {
const from = new Date(BASE - 10 * DAY);
expect(formatTimeAgo(from, { max: 5 * DAY }, BASE)).toBe(from.toISOString().slice(0, 10));
});
it('falls back to the full date when a named-unit max is exceeded', () => {
const from = new Date(BASE - 3 * WEEK);
expect(formatTimeAgo(from, { max: 'day' }, BASE)).toBe(from.toISOString().slice(0, 10));
});
it('respects a custom fullDateFormatter', () => {
const from = new Date(BASE - 10 * DAY);
expect(formatTimeAgo(from, { max: 'day', fullDateFormatter: () => 'CUSTOM' }, BASE)).toBe('CUSTOM');
});
it('honors a ceil rounding strategy', () => {
// 90 seconds -> 1.5 minutes -> ceil = 2
expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 'ceil' }, BASE)).toBe('2 minutes ago');
});
it('honors floor rounding', () => {
// 119 seconds -> 1.98 minutes -> floor = 1
expect(formatTimeAgo(new Date(BASE - 119 * SECOND), { rounding: 'floor' }, BASE)).toBe('1 minute ago');
});
it('honors numeric (decimal-place) rounding', () => {
// 90 seconds -> 1.5 minutes, rounded to 1 dp = 1.5
expect(formatTimeAgo(new Date(BASE - 90 * SECOND), { rounding: 1 }, BASE)).toBe('1.5 minutes ago');
});
it('returns the invalid message for an unparseable date', () => {
expect(formatTimeAgo(new Date('not a date'), {}, BASE)).toBe('');
});
it('supports custom i18n messages', () => {
const messages: UseTimeAgoMessages = {
justNow: 'à l\'instant',
past: n => `il y a ${n}`,
future: n => `dans ${n}`,
invalid: 'invalide',
second: n => `${n} seconde${n > 1 ? 's' : ''}`,
minute: n => `${n} minute${n > 1 ? 's' : ''}`,
hour: n => `${n} heure${n > 1 ? 's' : ''}`,
day: n => `${n} jour${n > 1 ? 's' : ''}`,
week: n => `${n} semaine${n > 1 ? 's' : ''}`,
month: n => `${n} mois`,
year: n => `${n} an${n > 1 ? 's' : ''}`,
};
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('il y a 3 minutes');
expect(formatTimeAgo(new Date(BASE + 3 * MINUTE), { messages }, BASE)).toBe('dans 3 minutes');
});
it('supports string-template (i18n) past/future with {0} placeholder', () => {
const messages: UseTimeAgoMessages = {
justNow: 'now',
past: '{0} ago',
future: 'in {0}',
invalid: '',
second: '{0}s',
minute: '{0}m',
hour: '{0}h',
day: '{0}d',
week: '{0}w',
month: '{0}mo',
year: '{0}y',
};
expect(formatTimeAgo(new Date(BASE - 3 * MINUTE), { messages }, BASE)).toBe('3m ago');
});
});
describe(useTimeAgo, () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(BASE);
});
afterEach(() => vi.useRealTimers());
it('returns a computed string by default', () => {
const scope = effectScope();
let timeAgo: ComputedRef<string> | undefined;
scope.run(() => {
timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE));
});
expect(timeAgo?.value).toBe('3 minutes ago');
scope.stop();
});
it('recomputes as the clock advances on the interval', () => {
const scope = effectScope();
let timeAgo: ComputedRef<string> | undefined;
scope.run(() => {
timeAgo = useTimeAgo(new Date(BASE - 5 * SECOND), { updateInterval: 1000, showSecond: true });
});
expect(timeAgo?.value).toBe('5 seconds ago');
vi.advanceTimersByTime(55 * SECOND);
expect(timeAgo?.value).toBe('1 minute ago');
scope.stop();
});
it('reacts to a changing reactive time source', () => {
const scope = effectScope();
const time = shallowRef(new Date(BASE - 1 * MINUTE));
let timeAgo: ComputedRef<string> | undefined;
scope.run(() => {
timeAgo = useTimeAgo(time);
});
expect(timeAgo?.value).toBe('1 minute ago');
time.value = new Date(BASE - 2 * HOUR);
expect(timeAgo?.value).toBe('2 hours ago');
scope.stop();
});
it('accepts a numeric timestamp and a string date', () => {
const scope = effectScope();
let fromNumber: ComputedRef<string> | undefined;
let fromString: ComputedRef<string> | undefined;
scope.run(() => {
fromNumber = useTimeAgo(BASE - 3 * MINUTE);
fromString = useTimeAgo(new Date(BASE - 3 * MINUTE).toISOString());
});
expect(fromNumber?.value).toBe('3 minutes ago');
expect(fromString?.value).toBe('3 minutes ago');
scope.stop();
});
it('exposes controls when controls: true', () => {
const scope = effectScope();
let ctrl: UseTimeAgoControls | undefined;
scope.run(() => {
ctrl = useTimeAgo(new Date(BASE - 5 * SECOND), { controls: true, updateInterval: 1000, showSecond: true });
});
if (!ctrl)
throw new Error('controls not created');
expect(ctrl.timeAgo.value).toBe('5 seconds ago');
expect(ctrl.isActive.value).toBeTruthy();
vi.advanceTimersByTime(55 * SECOND);
expect(ctrl.timeAgo.value).toBe('1 minute ago');
// pausing stops further recomputation
ctrl.pause();
expect(ctrl.isActive.value).toBeFalsy();
vi.advanceTimersByTime(60 * SECOND);
expect(ctrl.timeAgo.value).toBe('1 minute ago');
ctrl.resume();
expect(ctrl.isActive.value).toBeTruthy();
// resume does not fire the callback immediately; the next tick refreshes now
vi.advanceTimersByTime(1 * SECOND);
expect(ctrl.timeAgo.value).toBe('2 minutes ago');
ctrl.toggle();
expect(ctrl.isActive.value).toBeFalsy();
scope.stop();
});
it('does not start ticking when immediate is false', () => {
const scope = effectScope();
let result: { timeAgo: { value: string }; isActive: { value: boolean } } | undefined;
scope.run(() => {
result = useTimeAgo(new Date(BASE - 5 * SECOND), {
controls: true,
updateInterval: 1000,
immediate: false,
showSecond: true,
});
});
expect(result?.isActive.value).toBeFalsy();
vi.advanceTimersByTime(60 * SECOND);
// value reflects construction-time "now" since no tick fired
expect(result?.timeAgo.value).toBe('5 seconds ago');
scope.stop();
});
it('stops updating once the scope is disposed (cleanup)', () => {
const scope = effectScope();
let timeAgo: ComputedRef<string> | undefined;
scope.run(() => {
timeAgo = useTimeAgo(new Date(BASE), { updateInterval: 1000, showSecond: true });
});
vi.advanceTimersByTime(60 * SECOND);
expect(timeAgo?.value).toBe('1 minute ago');
scope.stop();
vi.advanceTimersByTime(120 * SECOND);
expect(timeAgo?.value).toBe('1 minute ago');
});
it('constructs without touching window/document/navigator (SSR-safe)', () => {
// useTimeAgo is pure date math + an interval; it must build with no DOM
// globals present. We simulate an SSR-ish absence and assert no throw.
const originalWindow = (globalThis as Record<string, unknown>).window;
const originalDocument = (globalThis as Record<string, unknown>).document;
const originalNavigator = (globalThis as Record<string, unknown>).navigator;
vi.stubGlobal('window', undefined);
vi.stubGlobal('document', undefined);
vi.stubGlobal('navigator', undefined);
try {
const scope = effectScope();
let timeAgo: ComputedRef<string> | undefined;
scope.run(() => {
timeAgo = useTimeAgo(new Date(BASE - 3 * MINUTE), { immediate: false });
});
expect(timeAgo?.value).toBe('3 minutes ago');
scope.stop();
}
finally {
vi.unstubAllGlobals();
// restore (vi.unstubAllGlobals already does, but keep references used)
void originalWindow;
void originalDocument;
void originalNavigator;
}
});
});
@@ -0,0 +1,345 @@
import { computed, shallowRef, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction, isNumber, isString } from '@robonen/stdlib';
import type { ResumableActions } from '@/types';
import { useIntervalFn } from '@/composables/animation/useIntervalFn';
/**
* Formatter for a single unit value. Receives the rounded numeric value and
* whether the instant is in the past, and returns the localized fragment.
*/
export type UseTimeAgoFormatter<T = number> = (value: T, isPast: boolean) => string;
/**
* The default set of unit names recognized by `useTimeAgo`.
*/
export type UseTimeAgoUnitName
= 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
/**
* A single time unit used while resolving the most appropriate granularity.
*/
export interface UseTimeAgoUnit<Unit extends string = UseTimeAgoUnitName> {
/**
* Upper bound (exclusive) of the absolute diff (ms) this unit applies to
*/
max: number;
/**
* Length of one unit in milliseconds
*/
value: number;
/**
* Unit name; used to look up the matching message formatter
*/
name: Unit;
}
/**
* Built-in (non-unit) message slots.
*/
export interface UseTimeAgoMessagesBuiltIn {
/**
* Shown when the diff is below the smallest displayed unit
*/
justNow: string;
/**
* Wraps a past fragment (e.g. `'3 minutes'` -> `'3 minutes ago'`)
*/
past: string | UseTimeAgoFormatter<string>;
/**
* Wraps a future fragment (e.g. `'3 minutes'` -> `'in 3 minutes'`)
*/
future: string | UseTimeAgoFormatter<string>;
/**
* Shown when the provided time cannot be parsed into a valid date
*/
invalid: string;
}
/**
* Full message map: the built-in slots plus a formatter per unit name.
*/
export type UseTimeAgoMessages<UnitNames extends string = UseTimeAgoUnitName>
= UseTimeAgoMessagesBuiltIn & Record<UnitNames, string | UseTimeAgoFormatter<number>>;
/**
* Options shared by the pure `formatTimeAgo` and the reactive `useTimeAgo`.
*/
export interface FormatTimeAgoOptions<UnitNames extends string = UseTimeAgoUnitName> {
/**
* Maximum unit (or absolute ms diff) to display before falling back to
* `fullDateFormatter`.
*/
max?: UnitNames | number;
/**
* Formatter applied when the diff exceeds `max`.
*
* @default (date) => date.toISOString().slice(0, 10)
*/
fullDateFormatter?: (date: Date) => string;
/**
* Localized messages.
*/
messages?: UseTimeAgoMessages<UnitNames>;
/**
* Show seconds (i.e. allow sub-minute granularity) instead of `justNow`.
*
* @default false
*/
showSecond?: boolean;
/**
* Rounding strategy applied to unit values. A string maps to the matching
* `Math` method; a number rounds to that many decimal places.
*
* @default 'round'
*/
rounding?: 'round' | 'ceil' | 'floor' | number;
/**
* Custom ordered list of units (ascending by `value`).
*/
units?: Array<UseTimeAgoUnit<UnitNames>>;
}
/**
* Options for `useTimeAgo`.
*/
export interface UseTimeAgoOptions<Controls extends boolean, UnitNames extends string = UseTimeAgoUnitName>
extends FormatTimeAgoOptions<UnitNames> {
/**
* Expose pause/resume controls alongside the time string.
*
* @default false
*/
controls?: Controls;
/**
* Interval (ms) at which the relative string is recomputed.
*
* @default 30000
*/
updateInterval?: number;
/**
* Start the update interval immediately.
*
* @default true
*/
immediate?: boolean;
}
/**
* Controls returned when `controls: true`.
*/
export interface UseTimeAgoControls extends ResumableActions {
/**
* The reactive relative-time string
*/
timeAgo: ComputedRef<string>;
/**
* Whether the update interval is currently active
*/
isActive: Readonly<Ref<boolean>>;
}
export type UseTimeAgoReturn<Controls extends boolean = false>
= Controls extends true ? UseTimeAgoControls : ComputedRef<string>;
const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
{ max: 60000, value: 1000, name: 'second' },
{ max: 2760000, value: 60000, name: 'minute' },
{ max: 72000000, value: 3600000, name: 'hour' },
{ max: 518400000, value: 86400000, name: 'day' },
{ max: 2419200000, value: 604800000, name: 'week' },
{ max: 28512000000, value: 2592000000, name: 'month' },
{ max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' },
];
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
justNow: 'just now',
past: n => /\d/.test(n) ? `${n} ago` : n,
future: n => /\d/.test(n) ? `in ${n}` : n,
month: (n, past) => n === 1 ? (past ? 'last month' : 'next month') : `${n} month${n > 1 ? 's' : ''}`,
year: (n, past) => n === 1 ? (past ? 'last year' : 'next year') : `${n} year${n > 1 ? 's' : ''}`,
day: (n, past) => n === 1 ? (past ? 'yesterday' : 'tomorrow') : `${n} day${n > 1 ? 's' : ''}`,
week: (n, past) => n === 1 ? (past ? 'last week' : 'next week') : `${n} week${n > 1 ? 's' : ''}`,
hour: n => `${n} hour${n > 1 ? 's' : ''}`,
minute: n => `${n} minute${n > 1 ? 's' : ''}`,
second: n => `${n} second${n > 1 ? 's' : ''}`,
invalid: '',
};
function defaultFullDateFormatter(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* Pure (non-reactive) relative-time formatter. Useful on its own and reused by
* `useTimeAgo` on every tick.
*
* @param {Date} from The instant to describe
* @param {FormatTimeAgoOptions} [options={}] Formatting options
* @param {Date | number} [now=Date.now()] The reference "now"
* @returns {string} The localized relative-time string
*
* @example
* formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago'
*
* @since 0.0.15
*/
export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
from: Date,
options: FormatTimeAgoOptions<UnitNames> = {},
now: Date | number = Date.now(),
): string {
const {
max,
messages = DEFAULT_MESSAGES as UseTimeAgoMessages<UnitNames>,
fullDateFormatter = defaultFullDateFormatter,
units = DEFAULT_UNITS as Array<UseTimeAgoUnit<UnitNames>>,
showSecond = false,
rounding = 'round',
} = options;
const fromMs = +from;
if (Number.isNaN(fromMs))
return messages.invalid;
const roundFn = isNumber(rounding)
? (n: number): number => +n.toFixed(rounding)
: Math[rounding];
const diff = +now - fromMs;
const absDiff = Math.abs(diff);
function getValue(unit: UseTimeAgoUnit<UnitNames>): number {
return roundFn(absDiff / unit.value);
}
function applyFormat(
name: UnitNames | keyof UseTimeAgoMessagesBuiltIn,
val: number | string,
isPast: boolean,
): string {
const formatter = messages[name];
if (isFunction(formatter))
return formatter(val as never, isPast);
return formatter.replace('{0}', val.toString());
}
function format(unit: UseTimeAgoUnit<UnitNames>): string {
const val = getValue(unit);
const past = diff > 0;
const str = applyFormat(unit.name, val, past);
return applyFormat(past ? 'past' : 'future', str, past);
}
if (absDiff < 60000 && !showSecond)
return messages.justNow;
if (isNumber(max) && absDiff > max)
return fullDateFormatter(new Date(from));
if (isString(max)) {
const unitMax = units.find(unit => unit.name === max)?.max;
if (unitMax && absDiff > unitMax)
return fullDateFormatter(new Date(from));
}
for (let idx = 0; idx < units.length; idx++) {
const unit = units[idx]!;
const prev = units[idx - 1];
if (getValue(unit) <= 0 && prev)
return format(prev);
if (absDiff < unit.max)
return format(unit);
}
return messages.invalid;
}
/**
* @name useTimeAgo
* @category Animation
* @description Reactive relative time string (e.g. `'3 minutes ago'`) that
* ticks on a fixed interval. Fully customizable messages (i18n), units,
* rounding, and an automatic fallback to a full date once `max` is exceeded.
*
* @param {MaybeRefOrGetter<Date | number | string>} time The instant to describe (reactive)
* @param {UseTimeAgoOptions} [options={}] Options
* @returns {ComputedRef<string> | UseTimeAgoControls} The reactive string, or controls when `controls: true`
*
* @example
* const timeAgo = useTimeAgo(new Date(Date.now() - 60_000)); // '1 minute ago'
*
* @example
* // With pause/resume controls and a custom update cadence
* const { timeAgo, pause, resume } = useTimeAgo(date, { controls: true, updateInterval: 1000 });
*
* @example
* // i18n + full-date fallback past one month
* const timeAgo = useTimeAgo(date, {
* max: 'month',
* messages: { ...customMessages },
* fullDateFormatter: d => d.toLocaleDateString('fr-FR'),
* });
*
* @since 0.0.15
*/
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
time: MaybeRefOrGetter<Date | number | string>,
options?: UseTimeAgoOptions<false, UnitNames>,
): ComputedRef<string>;
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
time: MaybeRefOrGetter<Date | number | string>,
options: UseTimeAgoOptions<true, UnitNames>,
): UseTimeAgoControls;
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
time: MaybeRefOrGetter<Date | number | string>,
options: UseTimeAgoOptions<boolean, UnitNames> = {},
): ComputedRef<string> | UseTimeAgoControls {
const {
controls = false,
updateInterval = 30000,
immediate = true,
} = options;
// A single ticking ref drives recomputation; the heavy formatting stays in
// a computed so it only runs when `now` or `time` actually change.
const now = shallowRef(Date.now());
const resumable = useIntervalFn(() => {
now.value = Date.now();
}, updateInterval, { immediate });
const timeAgo = computed(() => formatTimeAgo(new Date(toValue(time)), options, now.value));
if (controls) {
return {
timeAgo,
isActive: resumable.isActive,
pause: resumable.pause,
resume: resumable.resume,
toggle: resumable.toggle,
};
}
return timeAgo;
}
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, isReadonly, ref } from 'vue';
import { useTimeout } from '.';
describe(useTimeout, () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('flips ready to true after the interval', () => {
const ready = useTimeout(100);
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(ready.value).toBeTruthy();
});
it('defaults the interval to 1000ms', () => {
const ready = useTimeout();
vi.advanceTimersByTime(999);
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(1);
expect(ready.value).toBeTruthy();
});
it('returns a read-only computed by default', () => {
const ready = useTimeout(100);
expect(isReadonly(ready)).toBeTruthy();
});
it('starts ready when immediate is false', () => {
const ready = useTimeout(100, { immediate: false });
expect(ready.value).toBeTruthy();
vi.advanceTimersByTime(100);
expect(ready.value).toBeTruthy();
});
it('invokes the callback when the timeout elapses', () => {
const callback = vi.fn();
useTimeout(100, { callback });
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it('reads the interval reactively each time it starts', () => {
const delay = ref(100);
const { ready, start } = useTimeout(delay, { controls: true, immediate: false });
delay.value = 200;
start();
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(ready.value).toBeTruthy();
});
describe('controls', () => {
it('exposes ready, start and stop', () => {
const controls = useTimeout(100, { controls: true });
expect(controls).toHaveProperty('ready');
expect(controls).toHaveProperty('start');
expect(controls).toHaveProperty('stop');
});
it('start restarts the pending timeout', () => {
const { ready, start } = useTimeout(100, { controls: true });
vi.advanceTimersByTime(50);
start();
vi.advanceTimersByTime(50);
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(50);
expect(ready.value).toBeTruthy();
});
it('start re-arms the timeout after it has elapsed', () => {
const callback = vi.fn();
const { ready, start } = useTimeout(100, { controls: true, callback });
vi.advanceTimersByTime(100);
expect(ready.value).toBeTruthy();
expect(callback).toHaveBeenCalledOnce();
start();
expect(ready.value).toBeFalsy();
vi.advanceTimersByTime(100);
expect(ready.value).toBeTruthy();
expect(callback).toHaveBeenCalledTimes(2);
});
it('stop cancels the pending callback', () => {
const callback = vi.fn();
const { stop } = useTimeout(100, { controls: true, callback });
stop();
vi.advanceTimersByTime(100);
expect(callback).not.toHaveBeenCalled();
});
});
it('cleans up on scope dispose', () => {
const callback = vi.fn();
const scope = effectScope();
scope.run(() => {
useTimeout(100, { callback });
});
scope.stop();
vi.advanceTimersByTime(100);
expect(callback).not.toHaveBeenCalled();
});
it('does not auto-start the real timer in a non-client (SSR) environment', () => {
// `useTimeoutFn` is guarded by `isClient`; with immediate auto-start it
// marks the timeout pending but only schedules a timer on the client.
// We assert the SSR-safe contract: no callback fires without timers running.
const callback = vi.fn();
const { ready } = useTimeout(100, { controls: true, immediate: false, callback });
// immediate:false means never auto-armed -> ready stays true, callback never fires
expect(ready.value).toBeTruthy();
vi.advanceTimersByTime(1000);
expect(callback).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,90 @@
import { computed } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import type { VoidFunction } from '@robonen/stdlib';
import { noop } from '@robonen/stdlib';
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
import type { UseTimeoutFnOptions, UseTimeoutFnReturn } from '@/composables/animation/useTimeoutFn';
export interface UseTimeoutOptions<Controls extends boolean> extends UseTimeoutFnOptions {
/**
* Expose `start`/`stop` controls alongside the `ready` flag
*
* @default false
*/
controls?: Controls;
/**
* Callback invoked when the timeout elapses
*/
callback?: VoidFunction;
}
export interface UseTimeoutControls {
/**
* Reactive flag that is `false` while the timeout is pending and flips to
* `true` once the delay has elapsed
*/
ready: ComputedRef<boolean>;
/**
* Start (or restart) the timeout
*/
start: UseTimeoutFnReturn<[]>['start'];
/**
* Cancel the pending timeout (leaves `ready` at its current value)
*/
stop: UseTimeoutFnReturn<[]>['stop'];
}
export type UseTimeoutReturn
= ComputedRef<boolean> | UseTimeoutControls;
/**
* @name useTimeout
* @category Animation
* @description Reactive boolean that flips to `true` after a given delay.
* Built on `useTimeoutFn`; optionally exposes `start`/`stop` controls. SSR-safe.
*
* @param {MaybeRefOrGetter<number>} [interval=1000] Delay in milliseconds (resolved each time the timeout starts, can be reactive)
* @param {UseTimeoutOptions} [options={}] Options
* @returns {ComputedRef<boolean> | UseTimeoutControls} The read-only `ready` flag, or controls when `controls: true`
*
* @example
* const ready = useTimeout(1000);
* // `ready.value` becomes true after 1s
*
* @example
* const { ready, start, stop } = useTimeout(1000, { controls: true });
*
* @example
* // Run a callback when the timeout elapses
* useTimeout(5000, { callback: refresh });
*
* @since 0.0.15
*/
export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>;
export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls;
export function useTimeout(
interval: MaybeRefOrGetter<number> = 1000,
options: UseTimeoutOptions<boolean> = {},
): UseTimeoutReturn {
const {
controls: exposeControls = false,
callback = noop,
} = options;
const { isPending, start, stop } = useTimeoutFn(callback, interval, options);
const ready = computed(() => !isPending.value);
if (exposeControls) {
return {
ready,
start,
stop,
};
}
return ready;
}
@@ -40,7 +40,7 @@ export interface UseTimeoutFnReturn<Args extends any[]> {
/** /**
* @name useTimeoutFn * @name useTimeoutFn
* @category Utilities * @category Animation
* @description Call a function after a given delay, with manual `start`/`stop` * @description Call a function after a given delay, with manual `start`/`stop`
* control and a reactive `isPending` flag. SSR-safe and cleans up on scope dispose. * control and a reactive `isPending` flag. SSR-safe and cleans up on scope dispose.
* *
@@ -2,8 +2,8 @@ import { shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue'; import type { MaybeRefOrGetter, Ref } from 'vue';
import { timestamp } from '@robonen/stdlib'; import { timestamp } from '@robonen/stdlib';
import type { ResumableActions } from '@/types'; import type { ResumableActions } from '@/types';
import { useRafFn } from '@/composables/browser/useRafFn'; import { useRafFn } from '@/composables/animation/useRafFn';
import { useIntervalFn } from '@/composables/browser/useIntervalFn'; import { useIntervalFn } from '@/composables/animation/useIntervalFn';
export interface UseTimestampOptions<Controls extends boolean> { export interface UseTimestampOptions<Controls extends boolean> {
/** /**
@@ -64,7 +64,7 @@ export type UseTimestampReturn<Controls extends boolean> = Controls extends true
/** /**
* @name useTimestamp * @name useTimestamp
* @category Utilities * @category Animation
* @description Reactive current timestamp, updated via `requestAnimationFrame` * @description Reactive current timestamp, updated via `requestAnimationFrame`
* or a fixed interval. * or a fixed interval.
* *
@@ -0,0 +1,315 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import {
TransitionPresets,
useTransition,
} from '@/composables/animation/useTransition';
// A controllable window stub: requestAnimationFrame frames are flushed
// manually via `frame()`, and timers are driven by vitest fake timers.
function createWindowStub() {
let rafId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
const win = {
requestAnimationFrame: (cb: FrameRequestCallback) => {
const id = ++rafId;
callbacks.set(id, cb);
return id;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
setTimeout: (fn: (...args: unknown[]) => void, ms?: number) =>
setTimeout(fn, ms) as unknown as number,
clearTimeout: (id: number) => clearTimeout(id),
} as unknown as Window;
function frame() {
const pending = [...callbacks.entries()];
callbacks.clear();
for (const [, cb] of pending)
cb(Date.now());
}
return { win, frame, get pending() {
return callbacks.size;
} };
}
describe(useTransition, () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('seeds output with the initial source value', () => {
const { win } = createWindowStub();
const scope = effectScope();
scope.run(() => {
const output = useTransition(ref(5), { window: win });
expect(output.value).toBe(5);
});
scope.stop();
});
it('transitions a number from old to new over the duration', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const scope = effectScope();
const source = ref(0);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 100, window: win });
});
source.value = 100;
await nextTick();
// Halfway through.
vi.setSystemTime(50);
frame();
expect(output.value).toBeCloseTo(50, 5);
// End.
vi.setSystemTime(100);
frame();
expect(output.value).toBe(100);
scope.stop();
});
it('fires onStarted and onFinished', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const onStarted = vi.fn();
const onFinished = vi.fn();
const scope = effectScope();
const source = ref(0);
scope.run(() => {
useTransition(source, { duration: 100, window: win, onStarted, onFinished });
});
source.value = 10;
await nextTick();
expect(onStarted).toHaveBeenCalledTimes(1);
expect(onFinished).not.toHaveBeenCalled();
vi.setSystemTime(100);
frame();
expect(onFinished).toHaveBeenCalledTimes(1);
scope.stop();
});
it('transitions numeric arrays element-wise', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const scope = effectScope();
const source = ref([0, 100]);
let output!: ReturnType<typeof useTransition<number[]>>;
scope.run(() => {
output = useTransition(source, { duration: 100, window: win });
});
source.value = [100, 0];
await nextTick();
vi.setSystemTime(50);
frame();
expect(output.value[0]).toBeCloseTo(50, 5);
expect(output.value[1]).toBeCloseTo(50, 5);
vi.setSystemTime(100);
frame();
expect(output.value).toEqual([100, 0]);
scope.stop();
});
it('applies an easing preset (eased value differs from linear midpoint)', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const scope = effectScope();
const source = ref(0);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, {
duration: 100,
transition: TransitionPresets.easeInCubic,
window: win,
});
});
source.value = 100;
await nextTick();
vi.setSystemTime(50);
frame();
// easeInCubic at t=0.5 is well below the linear midpoint of 50.
expect(output.value).toBeLessThan(50);
expect(output.value).toBeGreaterThan(0);
scope.stop();
});
it('accepts a custom easing function', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const scope = effectScope();
const source = ref(0);
const easing = vi.fn((n: number) => n);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 100, transition: easing, window: win });
});
source.value = 100;
await nextTick();
vi.setSystemTime(50);
frame();
expect(easing).toHaveBeenCalled();
expect(output.value).toBeCloseTo(50, 5);
scope.stop();
});
it('delays the start of a transition', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const onStarted = vi.fn();
const scope = effectScope();
const source = ref(0);
scope.run(() => {
useTransition(source, { duration: 100, delay: 200, window: win, onStarted });
});
source.value = 100;
await nextTick();
// Still in the delay window: not started yet.
expect(onStarted).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(onStarted).toHaveBeenCalledTimes(1);
vi.setSystemTime(200);
frame();
expect(onStarted).toHaveBeenCalledTimes(1);
scope.stop();
});
it('snaps instantly when disabled and tracks the source', async () => {
const { win, frame, pending } = createWindowStub();
const scope = effectScope();
const source = ref(0);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 100, disabled: true, window: win });
});
source.value = 42;
await nextTick();
expect(output.value).toBe(42);
expect(pending).toBe(0);
frame();
expect(output.value).toBe(42);
scope.stop();
});
it('disabling mid-transition snaps to the source', async () => {
const { win, frame } = createWindowStub();
vi.setSystemTime(0);
const scope = effectScope();
const source = ref(0);
const disabled = ref(false);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 100, disabled, window: win });
});
source.value = 100;
await nextTick();
vi.setSystemTime(50);
frame();
expect(output.value).toBeCloseTo(50, 5);
disabled.value = true;
await nextTick();
expect(output.value).toBe(100);
scope.stop();
});
it('jumps immediately when duration is zero', async () => {
const { win } = createWindowStub();
vi.setSystemTime(0);
const onFinished = vi.fn();
const scope = effectScope();
const source = ref(0);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 0, window: win, onFinished });
});
source.value = 100;
await nextTick();
expect(output.value).toBe(100);
expect(onFinished).toHaveBeenCalledTimes(1);
scope.stop();
});
it('is SSR-safe: without a window it mirrors the source synchronously', async () => {
const onFinished = vi.fn();
const scope = effectScope();
const source = ref(0);
let output!: ReturnType<typeof useTransition<number>>;
scope.run(() => {
output = useTransition(source, { duration: 100, window: undefined, onFinished });
});
expect(output.value).toBe(0);
source.value = 100;
await nextTick();
// No RAF available: transition resolves immediately to the target.
expect(output.value).toBe(100);
expect(onFinished).toHaveBeenCalledTimes(1);
scope.stop();
});
it('exposes the documented easing presets', () => {
expect(TransitionPresets.linear).toEqual([0, 0, 1, 1]);
expect(TransitionPresets.easeInOutCubic).toEqual([0.65, 0, 0.35, 1]);
expect(Object.keys(TransitionPresets)).toContain('easeOutBack');
});
});
@@ -0,0 +1,360 @@
import { computed, ref, toValue, watch } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { clamp, isFunction, isNumber, lerp, noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useRafFn } from '@/composables/animation/useRafFn';
/**
* Cubic bezier control points `[x1, y1, x2, y2]` (the implied endpoints are
* `(0, 0)` and `(1, 1)`), matching the CSS `cubic-bezier()` argument order.
*/
export type CubicBezierPoints = [number, number, number, number];
/**
* An easing function mapping linear progress in `[0, 1]` to eased progress.
*/
export type EasingFunction = (n: number) => number;
/**
* Interpolates between two values of `T` given an eased progress `alpha`.
*/
export type TransitionInterpolation<T> = (from: T, to: T, alpha: number) => T;
/**
* The transition easing: either a cubic bezier tuple or a custom easing function.
*/
export type TransitionEasing = CubicBezierPoints | EasingFunction;
/**
* Values that can be transitioned: a single number or a fixed-length number array.
*/
export type TransitionValue = number | number[];
/**
* Common cubic bezier easing presets (same curves as CSS / VueUse).
*/
export const TransitionPresets = {
linear: [0, 0, 1, 1],
easeInSine: [0.12, 0, 0.39, 0],
easeOutSine: [0.61, 1, 0.88, 1],
easeInOutSine: [0.37, 0, 0.63, 1],
easeInQuad: [0.11, 0, 0.5, 0],
easeOutQuad: [0.5, 1, 0.89, 1],
easeInOutQuad: [0.45, 0, 0.55, 1],
easeInCubic: [0.32, 0, 0.67, 0],
easeOutCubic: [0.33, 1, 0.68, 1],
easeInOutCubic: [0.65, 0, 0.35, 1],
easeInQuart: [0.5, 0, 0.75, 0],
easeOutQuart: [0.25, 1, 0.5, 1],
easeInOutQuart: [0.76, 0, 0.24, 1],
easeInQuint: [0.64, 0, 0.78, 0],
easeOutQuint: [0.22, 1, 0.36, 1],
easeInOutQuint: [0.83, 0, 0.17, 1],
easeInExpo: [0.7, 0, 0.84, 0],
easeOutExpo: [0.16, 1, 0.3, 1],
easeInOutExpo: [0.87, 0, 0.13, 1],
easeInCirc: [0.55, 0, 1, 0.45],
easeOutCirc: [0, 0.55, 0.45, 1],
easeInOutCirc: [0.85, 0, 0.15, 1],
easeInBack: [0.36, 0, 0.66, -0.56],
easeOutBack: [0.34, 1.56, 0.64, 1],
easeInOutBack: [0.68, -0.6, 0.32, 1.6],
} satisfies Record<string, CubicBezierPoints>;
export interface UseTransitionOptions {
/**
* Transition duration in milliseconds. Accepts a reactive value (resolved
* at the start of each transition).
*
* @default 1000
*/
duration?: MaybeRefOrGetter<number>;
/**
* Easing applied to the progress: a cubic bezier tuple (e.g. one of
* {@link TransitionPresets}) or a custom easing function.
*
* @default identity (linear)
*/
transition?: MaybeRefOrGetter<TransitionEasing>;
/**
* Delay in milliseconds before a transition begins after the source changes.
*
* @default 0
*/
delay?: MaybeRefOrGetter<number>;
/**
* When `true`, transitions are skipped and the output tracks the source
* value directly (no animation). Reactive.
*
* @default false
*/
disabled?: MaybeRefOrGetter<boolean>;
/**
* Called when a transition starts.
*/
onStarted?: () => void;
/**
* Called when a transition finishes (not called when aborted by a new change).
*/
onFinished?: () => void;
}
export type UseTransitionReturn<T> = Readonly<Ref<T>>;
const identity: EasingFunction = n => n;
interface BezierCoefficients {
a: (a1: number, a2: number) => number;
b: (a1: number, a2: number) => number;
c: (a1: number) => number;
}
function createEasingFunction(points: CubicBezierPoints): EasingFunction {
const [p0, p1, p2, p3] = points;
const coeffs: BezierCoefficients = {
a: (a1, a2) => 1 - 3 * a2 + 3 * a1,
b: (a1, a2) => 3 * a2 - 6 * a1,
c: a1 => 3 * a1,
};
const calcBezier = (t: number, a1: number, a2: number): number =>
((coeffs.a(a1, a2) * t + coeffs.b(a1, a2)) * t + coeffs.c(a1)) * t;
const getSlope = (t: number, a1: number, a2: number): number =>
3 * coeffs.a(a1, a2) * t * t + 2 * coeffs.b(a1, a2) * t + coeffs.c(a1);
const getTForX = (x: number): number => {
let guess = x;
for (let i = 0; i < 4; ++i) {
const slope = getSlope(guess, p0, p2);
if (slope === 0)
return guess;
const currentX = calcBezier(guess, p0, p2) - x;
guess -= currentX / slope;
}
return guess;
};
return n => (p0 === p1 && p2 === p3) ? n : calcBezier(getTForX(n), p1, p3);
}
function resolveEasing(transition: TransitionEasing | undefined): EasingFunction {
if (!transition)
return identity;
if (isFunction(transition))
return transition;
return createEasingFunction(transition);
}
// Interpolate a single number or a (fixed-length) numeric array.
function interpolate(from: TransitionValue, to: TransitionValue, alpha: number): TransitionValue {
if (isNumber(from) && isNumber(to))
return lerp(from, to, alpha);
const source = from as number[];
const target = to as number[];
return source.map((value, index) => lerp(value, target[index] ?? value, alpha));
}
function snapshot<T extends TransitionValue>(value: T): T {
return (isNumber(value) ? value : (value as number[]).slice()) as T;
}
function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
if (isNumber(a) && isNumber(b))
return a === b;
if (isNumber(a) || isNumber(b))
return false;
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i])
return false;
}
return true;
}
/**
* @name useTransition
* @category Animation
* @description Reactively transition between numeric values (or numeric arrays)
* over a duration with configurable easing. Wraps a single, paused
* `requestAnimationFrame` loop that only runs while a transition is in flight,
* so it is cheaper than re-creating an RAF loop per change. SSR-safe: without a
* `window` the output tracks the source synchronously.
*
* @param {MaybeRefOrGetter<T>} source The reactive source value (number or number[])
* @param {UseTransitionOptions & ConfigurableWindow} [options={}] Transition options
* @returns {UseTransitionReturn<T>} A readonly ref of the transitioned value
*
* @example
* const progress = ref(0);
* const output = useTransition(progress, {
* duration: 500,
* transition: TransitionPresets.easeOutCubic,
* });
*
* @example
* // Transition a tuple (e.g. an RGB color)
* const color = ref([0, 0, 0]);
* const animated = useTransition(color, { duration: 1000 });
*
* @since 0.0.15
*/
export function useTransition<T extends TransitionValue>(
source: MaybeRefOrGetter<T>,
options: UseTransitionOptions & ConfigurableWindow = {},
): UseTransitionReturn<T> {
const {
duration = 1000,
transition = identity,
delay = 0,
disabled = false,
onStarted = noop,
onFinished = noop,
} = options;
const window = 'window' in options ? options.window : defaultWindow;
// The animated output. Seeded with a snapshot of the current source.
const outputRef = ref(snapshot(toValue(source))) as Ref<T>;
// Active-transition state. `endpoints` are detached snapshots so that later
// source mutations cannot bleed into an in-flight transition.
let fromValue: T = outputRef.value;
let toValue_: T = outputRef.value;
let startedAt = 0;
let durationMs = 0;
let easing: EasingFunction = identity;
let delayTimer: ReturnType<typeof setTimeout> | null = null;
let finishPending = false;
const { pause, resume, isActive } = useRafFn(tick, { immediate: false, window });
function clearDelay() {
if (delayTimer !== null && window) {
window.clearTimeout(delayTimer);
delayTimer = null;
}
}
function settle(value: T) {
outputRef.value = snapshot(value);
if (isActive.value)
pause();
if (finishPending) {
finishPending = false;
onFinished();
}
}
function tick() {
const now = Date.now();
const alpha = durationMs <= 0 ? 1 : clamp((now - startedAt) / durationMs, 0, 1);
outputRef.value = interpolate(fromValue, toValue_, easing(alpha)) as T;
if (alpha >= 1)
settle(toValue_);
}
function begin(target: T) {
fromValue = snapshot(outputRef.value);
toValue_ = snapshot(target);
durationMs = Math.max(0, toValue(duration));
easing = resolveEasing(toValue(transition));
startedAt = Date.now();
finishPending = true;
onStarted();
if (durationMs <= 0 || !window) {
settle(toValue_);
return;
}
if (!isActive.value)
resume();
}
function start(target: T) {
clearDelay();
const delayMs = Math.max(0, toValue(delay));
if (delayMs > 0 && window) {
delayTimer = window.setTimeout(() => {
delayTimer = null;
begin(target);
}, delayMs);
return;
}
begin(target);
}
watch(
() => toValue(source),
(value) => {
// When disabled, mirror the source instantly and abort any animation.
if (toValue(disabled)) {
clearDelay();
if (isActive.value)
pause();
finishPending = false;
outputRef.value = snapshot(value);
return;
}
// Skip no-op changes so we don't restart an identical transition.
if (valuesEqual(value, outputRef.value) && !isActive.value && delayTimer === null)
return;
start(value);
},
{ deep: true },
);
// Reacting to `disabled` flipping to true mid-transition: snap to source.
watch(
() => toValue(disabled),
(off) => {
if (off) {
clearDelay();
if (isActive.value)
pause();
finishPending = false;
outputRef.value = snapshot(toValue(source));
}
},
);
return computed(() => outputRef.value);
}
@@ -0,0 +1,13 @@
export * from './useArrayDifference';
export * from './useArrayEvery';
export * from './useArrayFilter';
export * from './useArrayFind';
export * from './useArrayFindIndex';
export * from './useArrayFindLast';
export * from './useArrayIncludes';
export * from './useArrayJoin';
export * from './useArrayMap';
export * from './useArrayReduce';
export * from './useArraySome';
export * from './useArrayUnique';
export * from './useSorted';
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayDifference } from '.';
describe(useArrayDifference, () => {
it('returns the asymmetric difference of two arrays', () => {
const list = ref([1, 2, 3, 4, 5]);
const values = ref([2, 4]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3, 5]);
});
it('returns an empty array when all items are subtracted', () => {
const list = ref([1, 2, 3]);
const values = ref([1, 2, 3, 4]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([]);
});
it('returns the full list when values is empty', () => {
const list = ref([1, 2, 3]);
const values = ref<number[]>([]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 2, 3]);
});
it('reacts to changes in the source array', () => {
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
list.value = [1, 2, 3, 4];
expect(diff.value).toEqual([1, 3, 4]);
});
it('reacts to changes in the values array', () => {
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
values.value = [1, 2];
expect(diff.value).toEqual([3]);
});
it('accepts getters as sources', () => {
const a = ref(1);
const b = ref(2);
const diff = useArrayDifference(() => [a.value, b.value, 3], () => [b.value]);
expect(diff.value).toEqual([1, 3]);
b.value = 1;
expect(diff.value).toEqual([3]);
});
it('compares by key (positional argument)', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const values = ref([{ id: 2 }]);
const diff = useArrayDifference(list, values, 'id');
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
});
it('compares with a custom comparator function', () => {
const list = ref([1, 2, 3, 4, 5, 6]);
const values = ref([2]);
// Treat numbers with the same parity as equal.
const diff = useArrayDifference(list, values, (a, b) => a % 2 === b % 2);
expect(diff.value).toEqual([1, 3, 5]);
});
it('returns the symmetric difference via options', () => {
const a = ref([1, 2, 3]);
const b = ref([2, 3, 4]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 4]);
});
it('returns the symmetric difference via the trailing options argument', () => {
const a = ref([{ id: 1 }, { id: 2 }]);
const b = ref([{ id: 2 }, { id: 3 }]);
const diff = useArrayDifference(a, b, 'id', { symmetric: true });
expect(diff.value).toEqual([{ id: 1 }, { id: 3 }]);
});
it('accepts a comparator inside the options object', () => {
const list = ref([1, 2, 3, 4]);
const values = ref([20, 30]);
const diff = useArrayDifference(list, values, {
comparator: (a, b) => a === b / 10,
});
expect(diff.value).toEqual([1, 4]);
});
it('reacts to source changes when symmetric', () => {
const a = ref([1, 2]);
const b = ref([2, 3]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 3]);
a.value = [1, 2, 3];
expect(diff.value).toEqual([1]);
});
it('does not mutate the source arrays in symmetric mode', () => {
const a = ref([1, 2]);
const b = ref([2, 3]);
const diff = useArrayDifference(a, b, { symmetric: true });
expect(diff.value).toEqual([1, 3]);
expect(a.value).toEqual([1, 2]);
expect(b.value).toEqual([2, 3]);
});
it('is SSR-safe: never touches window/document/navigator', () => {
// Pure computed wrapper — evaluating it relies on no browser globals.
const list = ref([1, 2, 3]);
const values = ref([2]);
const diff = useArrayDifference(list, values);
expect(diff.value).toEqual([1, 3]);
});
});
@@ -0,0 +1,133 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
/**
* Comparator deciding whether two array elements are considered equal.
*/
export type UseArrayDifferenceComparatorFn<T>
= (value: T, othVal: T) => boolean;
export interface UseArrayDifferenceOptions<T> {
/**
* When `true`, returns the symmetric difference: items present in exactly one
* of the two arrays (`list` XOR `values`). When `false`, returns the
* asymmetric difference: items in `list` that are not in `values`.
*
* @see https://en.wikipedia.org/wiki/Symmetric_difference
* @default false
*/
symmetric?: boolean;
/**
* Custom comparator function, or a key of `T` to compare a single property by.
*/
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T;
}
export type UseArrayDifferenceReturn<T = any>
= ComputedRef<T[]>;
function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenceOptions<T> {
// isObject matches PLAIN objects only, so comparator functions/keys never reach here.
return isObject(value) && ('symmetric' in value || 'comparator' in value);
}
/**
* @name useArrayDifference
* @category Array
* @description Reactive difference of two arrays. Returns items in `list` that are not in `values` (asymmetric), or items in exactly one array (symmetric). Both arrays may be reactive (refs or getters).
*
* @param {MaybeRefOrGetter<T[]>} list The source array
* @param {MaybeRefOrGetter<T[]>} values The array of values to subtract from `list`
* @param {UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`symmetric`
* @param {UseArrayDifferenceOptions<T>} [options] Extra options when `comparator` is a function or key
* @returns {UseArrayDifferenceReturn<T>} A computed array of the difference
*
* @example
* const list = ref([1, 2, 3, 4, 5]);
* const values = ref([2, 4]);
* const diff = useArrayDifference(list, values); // [1, 3, 5]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
* const values = ref([{ id: 2 }]);
* const diff = useArrayDifference(list, values, 'id'); // [{ id: 1 }, { id: 3 }]
*
* @example
* const a = ref([1, 2, 3]);
* const b = ref([2, 3, 4]);
* const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4]
*
* @since 0.0.15
*/
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: UseArrayDifferenceComparatorFn<T>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: keyof T,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T>;
export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>,
values: MaybeRefOrGetter<T[]>,
comparator?: UseArrayDifferenceComparatorFn<T> | keyof T | UseArrayDifferenceOptions<T>,
options?: UseArrayDifferenceOptions<T>,
): UseArrayDifferenceReturn<T> {
let symmetric = false;
let resolved: UseArrayDifferenceComparatorFn<T> | keyof T | undefined;
if (isArrayDifferenceOptions<T>(comparator)) {
symmetric = comparator.symmetric ?? false;
resolved = comparator.comparator;
}
else {
resolved = comparator;
symmetric = options?.symmetric ?? false;
// An explicit comparator/key in `options` wins over the positional argument.
if (options?.comparator !== undefined)
resolved = options.comparator;
}
// Resolve the comparator once instead of rebuilding it on every recompute.
let compare: UseArrayDifferenceComparatorFn<T>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
const key = resolved as keyof T;
compare = (value, othVal) => value[key] === othVal[key];
}
else if (typeof resolved === 'function') {
compare = resolved;
}
else {
compare = (value, othVal) => value === othVal;
}
return computed(() => {
const source = toValue(list);
const other = toValue(values);
// Items in `source` absent from `other`.
const diff = source.filter(value => !other.some(othVal => compare(value, othVal)));
if (!symmetric)
return diff;
// Items in `other` absent from `source`, appended for the symmetric difference.
for (const value of other) {
if (!source.some(srcVal => compare(value, srcVal)))
diff.push(value);
}
return diff;
});
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayEvery } from '.';
describe(useArrayEvery, () => {
it('returns true when every element passes', () => {
const list = ref([1, 2, 3, 4]);
const allPositive = useArrayEvery(list, n => n > 0);
expect(allPositive.value).toBeTruthy();
});
it('returns false when some element fails', () => {
const list = ref([1, -2, 3, 4]);
const allPositive = useArrayEvery(list, n => n > 0);
expect(allPositive.value).toBeFalsy();
});
it('reacts to source array changes', () => {
const list = ref([2, 4, 6]);
const allEven = useArrayEvery(list, n => n % 2 === 0);
expect(allEven.value).toBeTruthy();
list.value = [2, 4, 5];
expect(allEven.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const items = [ref(2), ref(4), ref(6)];
const allEven = useArrayEvery(items, n => n % 2 === 0);
expect(allEven.value).toBeTruthy();
items[1]!.value = 5;
expect(allEven.value).toBeFalsy();
});
it('accepts a getter as the list source', () => {
const a = ref(1);
const b = ref(2);
const allPositive = useArrayEvery(() => [a.value, b.value], n => n > 0);
expect(allPositive.value).toBeTruthy();
b.value = -1;
expect(allPositive.value).toBeFalsy();
});
it('passes index and array to the predicate', () => {
const list = ref([0, 1, 2, 3]);
const matchesIndex = useArrayEvery(list, (element, index, array) => {
expect(array).toBe(list.value);
return element === index;
});
expect(matchesIndex.value).toBeTruthy();
});
it('returns true for an empty array (vacuous truth)', () => {
const list = ref<number[]>([]);
const result = useArrayEvery(list, n => n > 0);
expect(result.value).toBeTruthy();
});
it('is SSR-safe: never touches window/document/navigator', () => {
const list = ref([1, 2, 3]);
// Pure computed wrapper — evaluating it relies on no browser globals.
const result = useArrayEvery(list, n => n > 0);
expect(result.value).toBeTruthy();
});
});
@@ -0,0 +1,30 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayEveryReturn = ComputedRef<boolean>;
/**
* @name useArrayEvery
* @category Array
* @description Reactive `Array.prototype.every`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
* @returns {UseArrayEveryReturn} A computed boolean that is `true` if `fn` returns a truthy value for every element, otherwise `false`
*
* @example
* const list = ref([1, 2, 3, 4]);
* const allPositive = useArrayEvery(list, n => n > 0); // true
*
* @example
* const items = [ref(2), ref(4), ref(6)];
* const allEven = useArrayEvery(items, n => n % 2 === 0); // true
*
* @since 0.0.15
*/
export function useArrayEvery<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
): UseArrayEveryReturn {
return computed(() => toValue(list).every((element, index, array) => fn(toValue(element), index, array)));
}
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/** /**
* @name useArrayFilter * @name useArrayFilter
* @category Reactivity * @category Array
* @description Reactive `Array.prototype.filter`. * @description Reactive `Array.prototype.filter`.
* *
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/** /**
* @name useArrayFind * @name useArrayFind
* @category Reactivity * @category Array
* @description Reactive `Array.prototype.find`. * @description Reactive `Array.prototype.find`.
* *
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayFindIndex } from '.';
describe(useArrayFindIndex, () => {
it('finds the index reactively', () => {
const list = ref([1, 2, 3]);
const index = useArrayFindIndex(list, n => n > 1);
expect(index.value).toBe(1);
list.value = [10, 20];
expect(index.value).toBe(0);
});
it('returns -1 when nothing matches', () => {
const index = useArrayFindIndex(ref([1, 2]), n => n > 5);
expect(index.value).toBe(-1);
});
it('returns -1 for an empty array', () => {
const index = useArrayFindIndex(ref<number[]>([]), () => true);
expect(index.value).toBe(-1);
});
it('passes element, index and the resolved array to the predicate', () => {
const calls: Array<[number, number, number[]]> = [];
const index = useArrayFindIndex(ref([5, 6, 7]), (element, idx, array) => {
calls.push([element, idx, array]);
return element === 7;
});
expect(index.value).toBe(2);
expect(calls).toEqual([
[5, 0, [5, 6, 7]],
[6, 1, [5, 6, 7]],
[7, 2, [5, 6, 7]],
]);
});
it('unwraps reactive items inside the list', () => {
const a = ref(1);
const b = ref(2);
const index = useArrayFindIndex([a, b], n => n === 2);
expect(index.value).toBe(1);
b.value = 0;
a.value = 0;
expect(index.value).toBe(-1);
});
it('accepts a getter as the source list', () => {
const source = ref([3, 4, 5]);
const index = useArrayFindIndex(() => source.value, n => n % 2 === 0);
expect(index.value).toBe(1);
source.value = [1, 3, 5];
expect(index.value).toBe(-1);
});
it('accepts a plain (non-reactive) array', () => {
const index = useArrayFindIndex([10, 20, 30], n => n === 30);
expect(index.value).toBe(2);
});
it('returns the FIRST matching index', () => {
const index = useArrayFindIndex(ref([2, 4, 6, 8]), n => n % 2 === 0);
expect(index.value).toBe(0);
});
});
@@ -0,0 +1,29 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayFindIndexReturn = ComputedRef<number>;
/**
* @name useArrayFindIndex
* @category Array
* @description Reactive `Array.prototype.findIndex`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
* @returns {UseArrayFindIndexReturn} The index of the first matching element, or `-1` if none match
*
* @example
* const list = ref([1, 2, 3]);
* const index = useArrayFindIndex(list, n => n > 1); // 1
*
* @since 0.0.15
*/
export function useArrayFindIndex<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => unknown,
): UseArrayFindIndexReturn {
return computed(() => {
const resolved = toValue(list).map(item => toValue(item));
return resolved.findIndex(fn);
});
}
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useArrayFindLast } from '.';
describe(useArrayFindLast, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('finds the last matching element reactively', () => {
const list = ref([1, 2, 3, 4]);
const found = useArrayFindLast(list, n => n % 2 === 0);
expect(found.value).toBe(4);
list.value = [10, 20, 21];
expect(found.value).toBe(20);
});
it('returns undefined when nothing matches', () => {
const found = useArrayFindLast(ref([1, 2]), n => n > 5);
expect(found.value).toBeUndefined();
});
it('returns undefined for an empty array', () => {
const found = useArrayFindLast(ref<number[]>([]), () => true);
expect(found.value).toBeUndefined();
});
it('returns the LAST matching element', () => {
const found = useArrayFindLast(ref([2, 4, 6, 8]), n => n % 2 === 0);
expect(found.value).toBe(8);
});
it('passes element, index and the resolved array to the predicate', () => {
const calls: Array<[number, number, number[]]> = [];
const found = useArrayFindLast(ref([5, 6, 7]), (element, idx, array) => {
calls.push([element, idx, array]);
return element === 7;
});
expect(found.value).toBe(7);
// findLast iterates from the end; the match at index 2 stops it immediately.
expect(calls).toEqual([
[7, 2, [5, 6, 7]],
]);
});
it('unwraps reactive items inside the list', () => {
const a = ref(1);
const b = ref(2);
const found = useArrayFindLast([a, b], n => n < 5);
expect(found.value).toBe(2);
b.value = 9;
expect(found.value).toBe(1);
});
it('accepts a getter as the source list', () => {
const source = ref([3, 4, 5, 6]);
const found = useArrayFindLast(() => source.value, n => n % 2 === 0);
expect(found.value).toBe(6);
source.value = [1, 3, 5];
expect(found.value).toBeUndefined();
});
it('accepts a plain (non-reactive) array', () => {
const found = useArrayFindLast([10, 20, 30], n => n < 25);
expect(found.value).toBe(20);
});
it('works via the polyfill when Array.prototype.findLast is unavailable', () => {
const native = Array.prototype.findLast;
try {
// Simulate a runtime older than ES2023.
(Array.prototype as { findLast?: unknown }).findLast = undefined;
vi.resetModules();
// The presence check runs at module import time, so re-import here.
return import('.').then(({ useArrayFindLast: useArrayFindLastFresh }) => {
const found = useArrayFindLastFresh(ref([1, 2, 3, 4]), n => n % 2 === 0);
expect(found.value).toBe(4);
const none = useArrayFindLastFresh(ref([1, 3, 5]), n => n % 2 === 0);
expect(none.value).toBeUndefined();
});
}
finally {
(Array.prototype as { findLast?: unknown }).findLast = native;
}
});
});
@@ -0,0 +1,48 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayFindLastReturn<T = unknown>
= ComputedRef<T | undefined>;
/**
* `Array.prototype.findLast` polyfill for runtimes older than ES2023.
*/
function findLast<T>(
array: T[],
fn: (element: T, index: number, array: T[]) => unknown,
): T | undefined {
let index = array.length;
while (index-- > 0) {
const element = array[index]!;
if (fn(element, index, array))
return element;
}
return undefined;
}
const hasNativeFindLast = typeof Array.prototype.findLast === 'function';
/**
* @name useArrayFindLast
* @category Array
* @description Reactive `Array.prototype.findLast`.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: T[]) => unknown} fn Predicate testing each element
* @returns {UseArrayFindLastReturn<T>} The last matching element, or `undefined` if none match
*
* @example
* const list = ref([1, 2, 3, 4]);
* const found = useArrayFindLast(list, n => n % 2 === 0); // 4
*
* @since 0.0.15
*/
export function useArrayFindLast<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: T[]) => unknown,
): UseArrayFindLastReturn<T> {
return computed(() => {
const resolved = toValue(list).map(item => toValue(item));
return hasNativeFindLast ? resolved.findLast(fn) : findLast(resolved, fn);
});
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayIncludes } from '.';
describe(useArrayIncludes, () => {
it('returns true when the value is present', () => {
const list = ref([1, 2, 3, 4]);
const has = useArrayIncludes(list, 3);
expect(has.value).toBeTruthy();
});
it('returns false when the value is absent', () => {
const list = ref([1, 2, 3, 4]);
const has = useArrayIncludes(list, 5);
expect(has.value).toBeFalsy();
});
it('returns false for an empty array', () => {
const list = ref<number[]>([]);
const has = useArrayIncludes(list, 1);
expect(has.value).toBeFalsy();
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 2, 3]);
const has = useArrayIncludes(list, 4);
expect(has.value).toBeFalsy();
list.value = [1, 4, 5];
expect(has.value).toBeTruthy();
list.value = [1, 2];
expect(has.value).toBeFalsy();
});
it('updates reactively when the searched value changes', () => {
const list = ref([1, 2, 3]);
const target = ref(2);
const has = useArrayIncludes(list, target);
expect(has.value).toBeTruthy();
target.value = 9;
expect(has.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2), ref(3)];
const has = useArrayIncludes(list, 2);
expect(has.value).toBeTruthy();
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(2);
const has = useArrayIncludes([a, b], 9);
expect(has.value).toBeFalsy();
b.value = 9;
expect(has.value).toBeTruthy();
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 3]);
const has = useArrayIncludes(() => source.value, 3);
expect(has.value).toBeTruthy();
source.value = [1, 2];
expect(has.value).toBeFalsy();
});
it('supports a custom comparator function', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const has = useArrayIncludes(list, 2, (element, value) => element.id === value);
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 9, (element, value) => element.id === value);
expect(missing.value).toBeFalsy();
});
it('passes index and array to the comparator', () => {
const list = ref(['a', 'b', 'c']);
const calls: Array<[string, string, number, number]> = [];
const has = useArrayIncludes(list, 'z', (element, value, index, array) => {
calls.push([element, value, index, array.length]);
return false;
});
expect(has.value).toBeFalsy();
expect(calls).toEqual([
['a', 'z', 0, 3],
['b', 'z', 1, 3],
['c', 'z', 2, 3],
]);
});
it('supports a key of T as the comparator', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 3 }]);
const has = useArrayIncludes(list, 2, 'id');
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 9, 'id');
expect(missing.value).toBeFalsy();
});
it('reacts to changes when comparing by key', () => {
const list = ref([{ id: 1 }, { id: 2 }]);
const target = ref(2);
const has = useArrayIncludes(list, target, 'id');
expect(has.value).toBeTruthy();
target.value = 5;
expect(has.value).toBeFalsy();
list.value = [{ id: 5 }];
expect(has.value).toBeTruthy();
});
it('honors a positive fromIndex', () => {
const list = ref(['a', 'b', 'a']);
const fromZero = useArrayIncludes(list, 'a', { fromIndex: 0 });
expect(fromZero.value).toBeTruthy();
const fromTwo = useArrayIncludes(list, 'a', { fromIndex: 2 });
expect(fromTwo.value).toBeTruthy();
const fromThree = useArrayIncludes(list, 'a', { fromIndex: 3 });
expect(fromThree.value).toBeFalsy();
});
it('honors a negative fromIndex like Array.includes', () => {
const list = ref([1, 2, 3, 4, 5]);
const lastTwo = useArrayIncludes(list, 3, { fromIndex: -2 });
expect(lastTwo.value).toBeFalsy();
const lastThree = useArrayIncludes(list, 3, { fromIndex: -3 });
expect(lastThree.value).toBeTruthy();
// Negative index beyond the start clamps to 0.
const wayBack = useArrayIncludes(list, 1, { fromIndex: -100 });
expect(wayBack.value).toBeTruthy();
});
it('combines comparator and fromIndex in the options object', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const has = useArrayIncludes(list, 1, {
comparator: 'id',
fromIndex: 1,
});
expect(has.value).toBeTruthy();
const missing = useArrayIncludes(list, 2, {
comparator: 'id',
fromIndex: 2,
});
expect(missing.value).toBeFalsy();
});
it('uses strict equality by default', () => {
const list = ref<Array<number | string>>([1, 2, 3]);
const has = useArrayIncludes(list, '2');
expect(has.value).toBeFalsy();
});
it('matches the searched value when it is a reactive getter', () => {
const list = ref([10, 20, 30]);
const has = useArrayIncludes(list, () => 20);
expect(has.value).toBeTruthy();
});
});
@@ -0,0 +1,115 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isObject, isString } from '@robonen/stdlib';
/**
* Comparator deciding whether an array element equals the searched value.
*/
export type UseArrayIncludesComparatorFn<T, V>
= (element: T, value: V, index: number, array: T[]) => boolean;
export interface UseArrayIncludesOptions<T, V> {
/**
* Index at which to start searching (negative counts from the end, like `Array.prototype.includes`).
*
* @default 0
*/
fromIndex?: number;
/**
* Custom comparator function, or a key of `T` to compare a single property by.
*/
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T;
}
export type UseArrayIncludesReturn = ComputedRef<boolean>;
function isArrayIncludesOptions<T, V>(value: unknown): value is UseArrayIncludesOptions<T, V> {
// isObject matches PLAIN objects only, so functions/keys never reach here.
return isObject(value) && ('fromIndex' in value || 'comparator' in value);
}
/**
* @name useArrayIncludes
* @category Array
* @description Reactive `Array.prototype.includes` with an optional comparator and `fromIndex`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {MaybeRefOrGetter<V>} value The value to search for (may be reactive)
* @param {UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>} [comparator] A comparator function, a key of `T` to compare by, or an options object with `comparator`/`fromIndex`
* @returns {UseArrayIncludesReturn} A computed boolean that is `true` when the value is found
*
* @example
* const list = ref([1, 2, 3, 4]);
* const hasThree = useArrayIncludes(list, 3); // true
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }]);
* const hasTwo = useArrayIncludes(list, 2, 'id'); // compare by key
*
* @example
* const list = ref(['a', 'b', 'a']);
* const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true
*
* @since 0.0.15
*/
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: UseArrayIncludesComparatorFn<T, V>,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: keyof T,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
options?: UseArrayIncludesOptions<T, V>,
): UseArrayIncludesReturn;
export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
value: MaybeRefOrGetter<V>,
comparator?: UseArrayIncludesComparatorFn<T, V> | keyof T | UseArrayIncludesOptions<T, V>,
): UseArrayIncludesReturn {
let fromIndex = 0;
let resolved = comparator;
if (isArrayIncludesOptions<T, V>(resolved)) {
fromIndex = resolved.fromIndex ?? 0;
resolved = resolved.comparator;
}
// Resolve the comparator once instead of on every recompute.
let compare: UseArrayIncludesComparatorFn<T, V>;
if (isString(resolved) || typeof resolved === 'symbol' || typeof resolved === 'number') {
const key = resolved as keyof T;
compare = (element, searched) => element[key] === (searched as unknown);
}
else if (typeof resolved === 'function') {
compare = resolved;
}
else {
compare = (element, searched) => (element as unknown) === searched;
}
return computed(() => {
const array = toValue(list);
const searched = toValue(value);
const length = array.length;
// Resolve a negative / out-of-range fromIndex the same way Array.includes does.
let start = fromIndex < 0 ? length + fromIndex : fromIndex;
if (start < 0)
start = 0;
for (let index = start; index < length; index++) {
// `index` is bounded by `length`; `!` drops the index-access undefined.
if (compare(toValue(array[index]!), searched, index, array as T[]))
return true;
}
return false;
});
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayJoin } from '.';
describe(useArrayJoin, () => {
it('joins with the default comma separator', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list);
expect(joined.value).toBe('a,b,c');
});
it('joins with a static separator', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b-c');
});
it('recomputes when the source array changes', () => {
const list = ref(['a', 'b']);
const joined = useArrayJoin(list, '/');
expect(joined.value).toBe('a/b');
list.value = ['x', 'y', 'z'];
expect(joined.value).toBe('x/y/z');
});
it('reacts to a reactive separator', () => {
const list = ref(['a', 'b', 'c']);
const sep = ref('-');
const joined = useArrayJoin(list, sep);
expect(joined.value).toBe('a-b-c');
sep.value = ' | ';
expect(joined.value).toBe('a | b | c');
});
it('unwraps reactive items', () => {
const list = [ref('a'), ref('b'), ref('c')];
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b-c');
});
it('reacts to changes in reactive items', () => {
const a = ref('a');
const list = [a, ref('b')];
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('a-b');
a.value = 'z';
expect(joined.value).toBe('z-b');
});
it('accepts a getter as the source list', () => {
const a = ref('a');
const b = ref('b');
const joined = useArrayJoin(() => [a.value, b.value], '-');
expect(joined.value).toBe('a-b');
a.value = 'z';
expect(joined.value).toBe('z-b');
});
it('returns an empty string for an empty array', () => {
const list = ref<string[]>([]);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('');
});
it('returns the single element with no separator applied', () => {
const list = ref(['only']);
const joined = useArrayJoin(list, '-');
expect(joined.value).toBe('only');
});
it('stringifies non-string elements like native join', () => {
const list = ref([1, 2, 3]);
const joined = useArrayJoin(list, '+');
expect(joined.value).toBe('1+2+3');
});
it('renders null and undefined as empty strings like native join', () => {
const list = ref([null, 'a', undefined, 'b']);
const joined = useArrayJoin(list, ',');
expect(joined.value).toBe(',a,,b');
});
it('treats an empty-string separator as concatenation', () => {
const list = ref(['a', 'b', 'c']);
const joined = useArrayJoin(list, '');
expect(joined.value).toBe('abc');
});
it('joins a getter list of reactive items', () => {
const a = ref('a');
const b = ref('b');
const list = ref([a, b]);
const joined = useArrayJoin(() => list.value, '-');
expect(joined.value).toBe('a-b');
b.value = 'z';
expect(joined.value).toBe('a-z');
});
});
@@ -0,0 +1,42 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayJoinReturn = ComputedRef<string>;
/**
* @name useArrayJoin
* @category Array
* @description Reactive `Array.prototype.join`, with an optional reactive separator.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<unknown>[]>} list The source array (items can be reactive)
* @param {MaybeRefOrGetter<string>} [separator] A reactive separator placed between adjacent elements (defaults to `,`)
* @returns {UseArrayJoinReturn} A computed string of all elements joined; empty string when the array is empty
*
* @example
* const list = ref(['a', 'b', 'c']);
* const sep = ref('-');
* const joined = useArrayJoin(list, sep); // 'a-b-c'
*
* @since 0.0.15
*/
export function useArrayJoin(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<unknown>>>,
separator?: MaybeRefOrGetter<string>,
): UseArrayJoinReturn {
return computed(() => {
const resolved = toValue(list);
// `Array.prototype.join` already stringifies each element, but resolving
// reactive items first lets the computed track per-item ref dependencies.
let needsUnwrap = false;
for (const item of resolved) {
if (typeof item === 'function' || (typeof item === 'object' && item !== null && 'value' in item)) {
needsUnwrap = true;
break;
}
}
const source = needsUnwrap ? resolved.map(item => toValue(item)) : resolved;
return source.join(toValue(separator));
});
}
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
/** /**
* @name useArrayMap * @name useArrayMap
* @category Reactivity * @category Array
* @description Reactive `Array.prototype.map`. * @description Reactive `Array.prototype.map`.
* *
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive) * @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArrayReduce } from '.';
describe(useArrayReduce, () => {
it('reduces without an initial value', () => {
const list = ref([1, 2, 3, 4]);
const sum = useArrayReduce(list, (acc, n) => acc + n);
expect(sum.value).toBe(10);
});
it('reduces with an initial value', () => {
const list = ref([1, 2, 3, 4]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 100);
expect(sum.value).toBe(110);
});
it('recomputes when the source array changes', () => {
const list = ref([1, 2, 3]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
expect(sum.value).toBe(6);
list.value = [10, 20];
expect(sum.value).toBe(30);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(2), ref(3)];
const sum = useArrayReduce(list, (acc, n) => acc + n, 0);
expect(sum.value).toBe(6);
});
it('reacts to a reactive initial value', () => {
const list = ref([1, 2, 3]);
const seed = ref(10);
const sum = useArrayReduce(list, (acc, n) => acc + n, seed);
expect(sum.value).toBe(16);
seed.value = 100;
expect(sum.value).toBe(106);
});
it('passes the current index to the reducer', () => {
const list = ref(['a', 'b', 'c']);
const indexed = useArrayReduce(
list,
(acc, value, index) => `${acc}${index}:${value};`,
'',
);
expect(indexed.value).toBe('0:a;1:b;2:c;');
});
it('supports a different accumulator type via initial value', () => {
const list = ref(['a', 'b', 'a', 'c', 'b']);
const counts = useArrayReduce(
list,
(acc: Record<string, number>, key) => {
acc[key] = (acc[key] ?? 0) + 1;
return acc;
},
() => ({}) as Record<string, number>,
);
expect(counts.value).toEqual({ a: 2, b: 2, c: 1 });
});
it('treats undefined as a valid initial value (not a missing seed)', () => {
const list = ref([1, 2]);
// With a real seed of `undefined`, the reducer runs for every element.
const calls: Array<[unknown, number]> = [];
const result = useArrayReduce<number, number | undefined>(
list,
(acc, n) => {
calls.push([acc, n]);
return n;
},
undefined,
);
expect(result.value).toBe(2);
expect(calls).toEqual([[undefined, 1], [1, 2]]);
});
it('throws on an empty array with no initial value (native reduce semantics)', () => {
const list = ref<number[]>([]);
const sum = useArrayReduce(list, (acc, n) => acc + n);
expect(() => sum.value).toThrow(TypeError);
});
it('returns the initial value for an empty array', () => {
const list = ref<number[]>([]);
const sum = useArrayReduce(list, (acc, n) => acc + n, 42);
expect(sum.value).toBe(42);
});
it('accepts a getter as the source list', () => {
const a = ref(1);
const b = ref(2);
const product = useArrayReduce(() => [a.value, b.value], (acc, n) => acc * n, 1);
expect(product.value).toBe(2);
a.value = 5;
expect(product.value).toBe(10);
});
});
@@ -0,0 +1,73 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArrayReducer<PV, CV, R>
= (accumulator: PV, currentValue: CV, currentIndex: number) => R;
export type UseArrayReduceReturn<T> = ComputedRef<T>;
/**
* @name useArrayReduce
* @category Array
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayReducer<T, T, T>} reducer A reducer callback applied to each element
* @returns {UseArrayReduceReturn<T>} The reduced value
*
* @example
* const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n); // 10
*
* @since 0.0.15
*/
export function useArrayReduce<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<T, T, T>,
): UseArrayReduceReturn<T>;
/**
* @name useArrayReduce
* @category Array
* @description Reactive `Array.prototype.reduce`, with an optional initial value.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayReducer<U, T, U>} reducer A reducer callback applied to each element
* @param {MaybeRefOrGetter<U>} initialValue A reactive value to seed the accumulator with
* @returns {UseArrayReduceReturn<U>} The reduced value
*
* @example
* const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110
*
* @since 0.0.15
*/
export function useArrayReduce<T, U>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<U, T, U>,
initialValue: MaybeRefOrGetter<U>,
): UseArrayReduceReturn<U>;
export function useArrayReduce<T, U>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
reducer: UseArrayReducer<U, T, U>,
initialValue?: MaybeRefOrGetter<U>,
): UseArrayReduceReturn<U> {
const step = (
accumulator: U,
current: MaybeRefOrGetter<T>,
index: number,
): U => reducer(accumulator, toValue(current), index);
// Capture presence here (arguments.length, not a default value) so that an
// explicitly-passed `undefined` is still honoured as a real initial value.
const hasInitial = arguments.length >= 3;
return computed(() => {
const resolved = toValue(list);
return hasInitial
? resolved.reduce(step, toValue(initialValue as MaybeRefOrGetter<U>))
: (resolved as unknown as U[]).reduce(step as unknown as (a: U, c: U, i: number) => U);
});
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { ref } from 'vue';
import { useArraySome } from '.';
describe(useArraySome, () => {
it('returns true when any element matches', () => {
const list = ref([1, 2, 3, 4]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeTruthy();
});
it('returns false when no element matches', () => {
const list = ref([1, 3, 5, 7]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
});
it('returns false for an empty array', () => {
const list = ref<number[]>([]);
const result = useArraySome(list, () => true);
expect(result.value).toBeFalsy();
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 3, 5]);
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
list.value = [1, 2, 5];
expect(hasEven.value).toBeTruthy();
list.value = [7, 9];
expect(hasEven.value).toBeFalsy();
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(3), ref(4)];
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeTruthy();
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(3);
const list = [a, b];
const hasEven = useArraySome(list, n => n % 2 === 0);
expect(hasEven.value).toBeFalsy();
b.value = 4;
expect(hasEven.value).toBeTruthy();
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 3]);
const hasThree = useArraySome(() => source.value, n => n === 3);
expect(hasThree.value).toBeTruthy();
source.value = [1, 2];
expect(hasThree.value).toBeFalsy();
});
it('passes index and array to the predicate', () => {
const list = ref(['a', 'b', 'c']);
const calls: Array<[string, number, number]> = [];
const result = useArraySome(list, (element, index, array) => {
calls.push([element, index, array.length]);
return false;
});
expect(result.value).toBeFalsy();
expect(calls).toEqual([['a', 0, 3], ['b', 1, 3], ['c', 2, 3]]);
});
it('short-circuits on the first truthy result', () => {
const list = ref([1, 2, 3, 4]);
let visited = 0;
const result = useArraySome(list, (n) => {
visited++;
return n === 2;
});
expect(result.value).toBeTruthy();
expect(visited).toBe(2);
});
});
@@ -0,0 +1,30 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
export type UseArraySomeReturn = ComputedRef<boolean>;
/**
* @name useArraySome
* @category Array
* @description Reactive `Array.prototype.some`. The source array and its items may be reactive.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {(element: T, index: number, array: MaybeRefOrGetter<T>[]) => unknown} fn Predicate to test each element
* @returns {UseArraySomeReturn} A computed boolean that is `true` if `fn` returns a truthy value for any element, otherwise `false`
*
* @example
* const list = ref([1, 2, 3, 4]);
* const hasEven = useArraySome(list, n => n % 2 === 0); // true
*
* @example
* const items = [ref(1), ref(3), ref(5)];
* const hasEven = useArraySome(items, n => n % 2 === 0); // false
*
* @since 0.0.15
*/
export function useArraySome<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
fn: (element: T, index: number, array: Array<MaybeRefOrGetter<T>>) => unknown,
): UseArraySomeReturn {
return computed(() => toValue(list).some((element, index, array) => fn(toValue(element), index, array)));
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import { effectScope, ref } from 'vue';
import { useArrayUnique } from '.';
describe(useArrayUnique, () => {
it('de-duplicates primitive values using strict identity', () => {
const list = ref([1, 2, 2, 3, 3, 3]);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2, 3]);
});
it('preserves first-seen insertion order', () => {
const list = ref([3, 1, 3, 2, 1]);
const result = useArrayUnique(list);
expect(result.value).toEqual([3, 1, 2]);
});
it('distinguishes values of different types with === semantics', () => {
const list = ref<Array<number | string>>([1, '1', 1, '1']);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, '1']);
});
it('treats NaN occurrences as a single unique value', () => {
const list = ref([Number.NaN, Number.NaN, 1]);
const result = useArrayUnique(list);
expect(result.value).toEqual([Number.NaN, 1]);
});
it('returns an empty array for an empty source', () => {
const list = ref<number[]>([]);
const result = useArrayUnique(list);
expect(result.value).toEqual([]);
});
it('updates reactively when the source array changes', () => {
const list = ref([1, 1, 2]);
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2]);
list.value = [3, 3, 3, 4];
expect(result.value).toEqual([3, 4]);
});
it('accepts a getter as the source', () => {
const source = ref([1, 2, 2]);
const result = useArrayUnique(() => source.value);
expect(result.value).toEqual([1, 2]);
source.value = [5, 5, 6];
expect(result.value).toEqual([5, 6]);
});
it('unwraps reactive items', () => {
const list = [ref(1), ref(1), ref(2)];
const result = useArrayUnique(list);
expect(result.value).toEqual([1, 2]);
});
it('reacts to changes in reactive items', () => {
const a = ref(1);
const b = ref(2);
const result = useArrayUnique([a, b]);
expect(result.value).toEqual([1, 2]);
b.value = 1;
expect(result.value).toEqual([1]);
});
it('de-duplicates by a key of T', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
});
it('keeps the first occurrence when de-duplicating by key', () => {
const list = ref([
{ id: 1, label: 'a' },
{ id: 1, label: 'b' },
{ id: 2, label: 'c' },
]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([
{ id: 1, label: 'a' },
{ id: 2, label: 'c' },
]);
});
it('de-duplicates by a key extractor function', () => {
const list = ref([
{ name: 'Ann' },
{ name: 'Bob' },
{ name: 'Ann' },
]);
const result = useArrayUnique(list, item => item.name);
expect(result.value).toEqual([{ name: 'Ann' }, { name: 'Bob' }]);
});
it('de-duplicates with a custom comparator function', () => {
const list = ref([1.1, 1.4, 2.2, 2.9, 3.0]);
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
expect(result.value).toEqual([1.1, 2.2, 3.0]);
});
it('passes the resolved array to the comparator', () => {
const list = ref([1, 2, 2]);
const seen: number[] = [];
const result = useArrayUnique(list, (a: number, b: number, array: number[]) => {
seen.push(array.length);
return a === b;
});
expect(result.value).toEqual([1, 2]);
expect(seen.every(length => length === 3)).toBeTruthy();
});
it('reacts to changes when comparing by key', () => {
const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
const result = useArrayUnique(list, 'id');
expect(result.value).toEqual([{ id: 1 }, { id: 2 }]);
list.value = [{ id: 3 }, { id: 3 }, { id: 4 }];
expect(result.value).toEqual([{ id: 3 }, { id: 4 }]);
});
it('reacts to changes when using a comparator function', () => {
const list = ref([1.1, 1.9]);
const result = useArrayUnique(list, (a: number, b: number) => Math.floor(a) === Math.floor(b));
expect(result.value).toEqual([1.1]);
list.value = [1.1, 2.2, 2.9];
expect(result.value).toEqual([1.1, 2.2]);
});
it('works outside of a component instance (SSR-safe, no global access)', () => {
// The composable must not touch window/document/navigator: running it inside
// a bare effectScope (no component, no DOM globals needed) must succeed.
const scope = effectScope();
let result: ReturnType<typeof useArrayUnique<number>> | undefined;
scope.run(() => {
result = useArrayUnique(ref([1, 1, 2, 3, 3]));
});
expect(result?.value).toEqual([1, 2, 3]);
scope.stop();
});
});
@@ -0,0 +1,127 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isString, unique } from '@robonen/stdlib';
/**
* Equality comparator deciding whether two array elements are duplicates.
*/
export type UseArrayUniqueComparatorFn<T>
= (a: T, b: T, array: T[]) => boolean;
/**
* Extracts the comparison key for an element. Two elements that produce the
* same key (via `===`/`Set` identity) are considered duplicates.
*/
export type UseArrayUniqueKeyFn<T>
= (element: T) => PropertyKey;
export type UseArrayUniqueReturn<T = unknown> = ComputedRef<T[]>;
/**
* @name useArrayUnique
* @category Array
* @description Reactive de-duplicated array. By default uses `Set` identity (`===`); an optional key of `T`, key extractor (both O(n)), or full comparator (O(n²)) customizes equality. The source array and its items may be reactive. First-seen insertion order is preserved.
*
* @param {MaybeRefOrGetter<MaybeRefOrGetter<T>[]>} list The source array (items can be reactive)
* @param {UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T} [comparator] A custom equality comparator, a key extractor, or a key of `T` to de-duplicate by
* @returns {UseArrayUniqueReturn<T>} A computed array containing only the first occurrence of each unique element
*
* @example
* const list = ref([1, 2, 2, 3, 3, 3]);
* const uniq = useArrayUnique(list); // [1, 2, 3]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
* const byId = useArrayUnique(list, 'id'); // [{ id: 1 }, { id: 2 }]
*
* @example
* const list = ref([{ id: 1 }, { id: 2 }, { id: 1 }]);
* const byKey = useArrayUnique(list, item => item.id); // [{ id: 1 }, { id: 2 }]
*
* @example
* const list = ref([1.1, 1.4, 2.2]);
* const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2]
*
* @since 0.0.15
*/
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: keyof T,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: UseArrayUniqueKeyFn<T>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator: UseArrayUniqueComparatorFn<T>,
): UseArrayUniqueReturn<T>;
export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
comparator?: UseArrayUniqueComparatorFn<T> | UseArrayUniqueKeyFn<T> | keyof T,
): UseArrayUniqueReturn<T> {
// Resolve the comparison strategy once, not on every recompute.
// Key of T (string | number | symbol) -> O(n) first-seen-wins key de-dup.
if (isString(comparator) || typeof comparator === 'symbol' || typeof comparator === 'number') {
const key = comparator as keyof T;
return computed<T[]>(() => uniqueByKey(resolve(list), element => element[key] as PropertyKey));
}
if (typeof comparator === 'function') {
// A unary key extractor stays O(n); a binary comparator falls back to O(n²)
// pairwise comparison (unavoidable for arbitrary equality). Branch on arity.
if (comparator.length <= 1) {
const extractor = comparator as UseArrayUniqueKeyFn<T>;
return computed<T[]>(() => uniqueByKey(resolve(list), extractor));
}
const compare = comparator as UseArrayUniqueComparatorFn<T>;
return computed<T[]>(() => {
const array = resolve(list);
const result: T[] = [];
for (const value of array) {
if (!result.some(kept => compare(value, kept, array)))
result.push(value);
}
return result;
});
}
// Default: identity (`===`) de-dup via stdlib unique's Set fast path.
return computed<T[]>(() => unique(resolve(list)));
}
/**
* Resolves the (possibly reactive) list and each (possibly reactive) item.
*/
function resolve<T>(list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>): T[] {
return toValue(list).map(element => toValue(element));
}
/**
* O(n) de-duplication that keeps the FIRST element seen per extracted key
* (matching VueUse's first-occurrence semantics). stdlib `unique` is
* last-write-wins per key, so we track seen keys in a Set here instead.
*/
function uniqueByKey<T>(array: T[], extractor: UseArrayUniqueKeyFn<T>): T[] {
const seen = new Set<PropertyKey>();
const result: T[] = [];
for (const element of array) {
const key = extractor(element);
if (seen.has(key))
continue;
seen.add(key);
result.push(element);
}
return result;
}
@@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest';
import { isReactive, nextTick, reactive, ref } from 'vue';
import type { Ref } from 'vue';
import { useSorted } from '.';
describe(useSorted, () => {
it('returns a sorted copy with the default numeric compare', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
});
it('does not mutate the source by default', () => {
const original = [3, 1, 2];
const source = ref(original);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
expect(source.value).toEqual([3, 1, 2]);
expect(sorted.value).not.toBe(source.value);
});
it('reacts to source changes', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
expect(sorted.value).toEqual([1, 2, 3]);
source.value = [9, 5, 7, 1];
expect(sorted.value).toEqual([1, 5, 7, 9]);
});
it('supports a custom compare function as the second argument', () => {
const source = ref([{ age: 30 }, { age: 18 }, { age: 25 }]);
const sorted = useSorted(source, (a, b) => a.age - b.age);
expect(sorted.value.map(u => u.age)).toEqual([18, 25, 30]);
});
it('supports descending order via compare function', () => {
const source = ref([1, 2, 3]);
const sorted = useSorted(source, (a, b) => b - a);
expect(sorted.value).toEqual([3, 2, 1]);
});
it('accepts an options object as the second argument', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source, { compareFn: (a, b) => b - a });
expect(sorted.value).toEqual([3, 2, 1]);
});
it('accepts a compare function plus an options object', () => {
const calls: number[] = [];
const sortFn = <T>(arr: T[], compareFn: (a: T, b: T) => number): T[] => {
calls.push(arr.length);
return [...arr].sort(compareFn);
};
const source = ref([3, 1, 2]);
const sorted = useSorted(source, (a, b) => a - b, { sortFn });
expect(sorted.value).toEqual([1, 2, 3]);
expect(calls.length).toBeGreaterThan(0);
});
it('is stable: equal elements keep their original relative order', () => {
const source = ref([
{ k: 1, id: 'a' },
{ k: 1, id: 'b' },
{ k: 0, id: 'c' },
{ k: 1, id: 'd' },
]);
const sorted = useSorted(source, (a, b) => a.k - b.k);
expect(sorted.value.map(x => x.id)).toEqual(['c', 'a', 'b', 'd']);
});
it('works with getter sources', () => {
const base = ref([5, 3, 4]);
const sorted = useSorted(() => base.value);
expect(sorted.value).toEqual([3, 4, 5]);
base.value = [2, 1];
expect(sorted.value).toEqual([1, 2]);
});
it('works with a plain (non-reactive) array source', () => {
const sorted = useSorted([3, 1, 2]);
expect(sorted.value).toEqual([1, 2, 3]);
});
it('handles empty and single-element arrays', () => {
expect(useSorted(ref<number[]>([])).value).toEqual([]);
expect(useSorted(ref([42])).value).toEqual([42]);
});
describe('dirty mode', () => {
it('sorts the source ref in place', async () => {
const source = ref([3, 1, 2]);
const result = useSorted(source, { dirty: true });
await nextTick();
expect(source.value).toEqual([1, 2, 3]);
expect(result).toBe(source);
});
it('re-sorts when the source changes', async () => {
const source = ref([3, 1, 2]);
useSorted(source, { dirty: true });
await nextTick();
expect(source.value).toEqual([1, 2, 3]);
source.value = [9, 4, 6];
await nextTick();
expect(source.value).toEqual([4, 6, 9]);
});
it('honors a custom compare function in dirty mode', async () => {
const source = ref([1, 2, 3]);
useSorted(source, (a, b) => b - a, { dirty: true });
await nextTick();
expect(source.value).toEqual([3, 2, 1]);
});
it('mutates a reactive array source in place via a getter', async () => {
const source = reactive([3, 1, 2]);
useSorted(() => source, { dirty: true });
await nextTick();
expect(isReactive(source)).toBeTruthy();
expect([...source]).toEqual([1, 2, 3]);
});
});
describe('writable result', () => {
it('writes back to the source ref when assigned (non-dirty)', () => {
const source = ref([3, 1, 2]);
const sorted = useSorted(source);
sorted.value = [10, 20];
expect(source.value).toEqual([10, 20]);
});
it('silently ignores writes when the source is a getter', () => {
const base = ref([3, 1, 2]);
const sorted = useSorted(() => base.value) as unknown as Ref<number[]>;
expect(() => {
sorted.value = [10, 20];
}).not.toThrow();
// getter source is unchanged
expect(base.value).toEqual([3, 1, 2]);
});
});
describe('SSR safety', () => {
it('does not touch any DOM global and works without a document', () => {
// useSorted is pure reactive computation; it must run identically in SSR.
const sorted = useSorted(ref([3, 1, 2]));
expect(sorted.value).toEqual([1, 2, 3]);
});
});
});
@@ -0,0 +1,151 @@
import { computed, isRef, toValue, watchEffect } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction } from '@robonen/stdlib';
export type UseSortedCompareFn<T = any>
= (a: T, b: T) => number;
export type UseSortedFn<T = any>
= (arr: T[], compareFn: UseSortedCompareFn<T>) => T[];
export interface UseSortedOptions<T = any> {
/**
* The sort algorithm to apply. Receives a copy of the array (or the source
* itself in `dirty` mode) and the resolved compare function.
*
* Defaults to a guaranteed-stable merge sort, so equal elements always keep
* their original relative order regardless of the JS engine.
*/
sortFn?: UseSortedFn<T>;
/**
* The compare function used to order two elements, matching the signature of
* `Array.prototype.sort`.
*
* @default (a, b) => a - b
*/
compareFn?: UseSortedCompareFn<T>;
/**
* Sort the source array in place instead of returning a sorted copy.
*
* When `true`, the returned ref is the source itself and its values are
* re-sorted whenever the source changes.
*
* @default false
*/
dirty?: boolean;
}
const defaultCompare: UseSortedCompareFn<number> = (a, b) => a - b;
/**
* Guaranteed-stable merge sort. Equal elements keep their original order on
* every engine, unlike the historically engine-dependent `Array.prototype.sort`.
*/
function stableSort<T>(array: T[], compareFn: UseSortedCompareFn<T>): T[] {
const length = array.length;
if (length < 2)
return array;
const middle = length >> 1;
const left = stableSort(array.slice(0, middle), compareFn);
const right = stableSort(array.slice(middle), compareFn);
const result: T[] = Array.from({ length });
let i = 0;
let l = 0;
let r = 0;
while (l < left.length && r < right.length) {
// Bounds are guaranteed by the loop condition; `!` drops the index-access undefined.
// `<= 0` keeps left (earlier) element first -> stability.
if (compareFn(left[l]!, right[r]!) <= 0)
result[i++] = left[l++]!;
else
result[i++] = right[r++]!;
}
while (l < left.length)
result[i++] = left[l++]!;
while (r < right.length)
result[i++] = right[r++]!;
return result;
}
const defaultSortFn: UseSortedFn = <T>(source: T[], compareFn: UseSortedCompareFn<T>): T[] => stableSort(source, compareFn);
/**
* @name useSorted
* @category Array
* @description Reactive, stable sorted copy of an array. Mirrors `Array.prototype.sort` but never mutates the source by default and guarantees stable ordering.
*
* @param {MaybeRefOrGetter<T[]>} source The source array (ref, getter, or plain array)
* @param {UseSortedCompareFn<T> | UseSortedOptions<T>} [compareFn] A compare function, or an options object
* @param {Omit<UseSortedOptions<T>, 'compareFn'>} [options] Extra options when the second argument is a compare function
* @returns {ComputedRef<T[]> | Ref<T[]>} A computed sorted copy (default), or the source ref when `dirty` is `true`
*
* @example
* const list = ref([3, 1, 2]);
* const sorted = useSorted(list); // [1, 2, 3]
*
* @example
* // custom compare function
* const users = ref([{ age: 30 }, { age: 18 }]);
* const byAge = useSorted(users, (a, b) => a.age - b.age);
*
* @example
* // sort the source in place
* const list = ref([3, 1, 2]);
* useSorted(list, { dirty: true });
* // list.value is now [1, 2, 3]
*
* @since 0.0.15
*/
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, options?: UseSortedOptions<T>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, options?: UseSortedOptions<T>): ComputedRef<T[]>;
export function useSorted<T = any>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): Ref<T[]>;
export function useSorted<T = any>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>, options?: Omit<UseSortedOptions<T>, 'compareFn'>): ComputedRef<T[]>;
export function useSorted<T = any>(
source: MaybeRefOrGetter<T[]>,
compareFnOrOptions?: UseSortedCompareFn<T> | UseSortedOptions<T>,
maybeOptions?: Omit<UseSortedOptions<T>, 'compareFn'>,
): ComputedRef<T[]> | Ref<T[]> {
let compareFn: UseSortedCompareFn<T> = defaultCompare as UseSortedCompareFn<T>;
let options: UseSortedOptions<T> = {};
if (isFunction(compareFnOrOptions)) {
compareFn = compareFnOrOptions;
options = maybeOptions ?? {};
}
else if (compareFnOrOptions) {
options = compareFnOrOptions;
compareFn = options.compareFn ?? (defaultCompare as UseSortedCompareFn<T>);
}
const {
dirty = false,
sortFn = defaultSortFn,
} = options;
if (!dirty) {
return computed<T[]>({
get: () => sortFn([...toValue(source)], compareFn),
set: (value) => {
if (isRef(source))
(source as Ref<T[]>).value = value;
},
});
}
watchEffect(() => {
const result = sortFn(toValue(source), compareFn);
if (isRef(source))
(source as Ref<T[]>).value = result;
else
(toValue(source)).splice(0, toValue(source).length, ...result);
});
return source as Ref<T[]>;
}
@@ -13,7 +13,7 @@ export interface BroadcastedRefOptions {
/** /**
* @name broadcastedRef * @name broadcastedRef
* @category Reactivity * @category Browser
* @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API * @description Creates a custom ref that syncs its value across browser tabs via the BroadcastChannel API
* *
* @param {string} key The channel key to use for broadcasting * @param {string} key The channel key to use for broadcasting
+19 -41
View File
@@ -1,58 +1,36 @@
export * from './onKeyStroke'; export * from './broadcastedRef';
export * from './useActiveElement'; export * from './useBreakpoints';
export * from './useBodyScrollLock';
export * from './useClickOutside';
export * from './useClipboard'; export * from './useClipboard';
export * from './useClipboardItems';
export * from './useCloseWatcher'; export * from './useCloseWatcher';
export * from './useColorMode'; export * from './useColorMode';
export * from './useDevicePixelRatio'; export * from './useCssVar';
export * from './useDocumentReadyState'; export * from './useDark';
export * from './useDocumentVisibility'; export * from './useDocumentPiP';
export * from './useDropZone';
export * from './useElementBounding';
export * from './useElementHover';
export * from './useElementSize';
export * from './useElementVisibility';
export * from './useEscapeKey';
export * from './useEventListener'; export * from './useEventListener';
export * from './useEyeDropper'; export * from './useEyeDropper';
export * from './useFavicon'; export * from './useFavicon';
export * from './useFileDialog'; export * from './useFileDialog';
export * from './useFocus'; export * from './useFileSystemAccess';
export * from './useFocusGuard';
export * from './useFocusWithin';
export * from './useFps';
export * from './useFullscreen'; export * from './useFullscreen';
export * from './useGeolocation'; export * from './useImage';
export * from './useIdle'; export * from './useLocalFonts';
export * from './useIntersectionObserver';
export * from './useIntervalFn';
export * from './useKeyModifier';
export * from './useMagicKeys';
export * from './useMediaQuery'; export * from './useMediaQuery';
export * from './useMouse';
export * from './useMousePressed';
export * from './useMutationObserver';
export * from './useNetwork';
export * from './useObjectUrl'; export * from './useObjectUrl';
export * from './useOnline';
export * from './usePageLeave';
export * from './usePermission'; export * from './usePermission';
export * from './usePointer';
export * from './usePreferredColorScheme'; export * from './usePreferredColorScheme';
export * from './usePreferredContrast';
export * from './usePreferredDark'; export * from './usePreferredDark';
export * from './useRafFn'; export * from './usePreferredLanguages';
export * from './useResizeObserver'; export * from './usePreferredReducedMotion';
export * from './useScreenOrientation'; export * from './usePreferredReducedTransparency';
export * from './useScroll'; export * from './useScriptTag';
export * from './useScrollLock';
export * from './useShare'; export * from './useShare';
export * from './useSupported'; export * from './useStyleTag';
export * from './useSwipe';
export * from './useTabLeader'; export * from './useTabLeader';
export * from './useTextSelection'; export * from './useTextareaAutosize';
export * from './useTitle'; export * from './useTitle';
export * from './useUrlSearchParams';
export * from './useVibrate'; export * from './useVibrate';
export * from './useWindowFocus'; export * from './useWakeLock';
export * from './useWindowScroll'; export * from './useWebNotification';
export * from './useWindowSize';
@@ -0,0 +1,343 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import {
breakpointsAntDesign,
breakpointsBootstrapV5,
breakpointsTailwind,
breakpointsVuetifyV3,
useBreakpoints,
} from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
}
/**
* A `matchMedia` stub backed by a mutable viewport width. It parses
* `(min-width: Npx)` / `(max-width: Npx)` (optionally joined by `and`) so each
* query evaluates against the current `width`. Calling `setWidth` re-dispatches
* `change` to every live MediaQueryList, mimicking a real viewport resize.
*/
function stubViewport(initialWidth: number) {
let width = initialWidth;
const lists = new Set<{ media: string; matches: boolean; listeners: Set<Listener> }>();
function toPx(value: string): number {
const n = Number.parseFloat(value);
return /(?:em|rem)$/i.test(value) ? n * 16 : n;
}
function evaluate(media: string): boolean {
return media.split(' and ').every((part) => {
const min = part.match(/min-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
const max = part.match(/max-width:\s*(-?\d+(?:\.\d+)?(?:px|r?em)?)/);
if (min) return width >= toPx(min[1]!);
if (max) return width <= toPx(max[1]!);
return false;
});
}
const matchMedia = vi.fn((media: string): StubMql => {
const entry = { media, matches: evaluate(media), listeners: new Set<Listener>() };
lists.add(entry);
return {
get matches() {
return entry.matches;
},
media,
addEventListener: (_: string, cb: Listener) => entry.listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => entry.listeners.delete(cb),
};
});
vi.stubGlobal('matchMedia', matchMedia);
return {
matchMedia,
setWidth(next: number) {
width = next;
for (const entry of lists) {
const matches = evaluate(entry.media);
if (matches !== entry.matches) {
entry.matches = matches;
for (const cb of entry.listeners) cb({ matches });
}
}
},
};
}
describe(useBreakpoints, () => {
beforeEach(() => {
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => vi.unstubAllGlobals());
it('exposes a reactive shortcut ref per breakpoint (min-width strategy)', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
// 800px: >= sm (640), >= md (768), < lg (1024)
expect(bp!.sm.value).toBeTruthy();
expect(bp!.md.value).toBeTruthy();
expect(bp!.lg.value).toBeFalsy();
scope.stop();
});
it('uses max-width semantics for shortcuts under the max-width strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
});
await nextTick();
// 800px: <= lg (1024) only; not <= sm/md
expect(bp!.sm.value).toBeFalsy();
expect(bp!.md.value).toBeFalsy();
expect(bp!.lg.value).toBeTruthy();
scope.stop();
});
it('reacts to viewport changes', async () => {
const vp = stubViewport(500);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, lg: 1024 });
});
await nextTick();
expect(bp!.greaterOrEqual('sm').value).toBeFalsy();
vp.setWidth(700);
await nextTick();
expect(bp!.greaterOrEqual('sm').value).toBeTruthy();
expect(bp!.greaterOrEqual('lg').value).toBeFalsy();
vp.setWidth(1100);
await nextTick();
expect(bp!.greaterOrEqual('lg').value).toBeTruthy();
scope.stop();
});
it('greater/smaller apply the strict (exclusive) delta', async () => {
// Exactly at the md breakpoint (768).
const vp = stubViewport(768);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'md'>>;
scope.run(() => {
bp = useBreakpoints({ md: 768 });
});
await nextTick();
// At exactly 768: greaterOrEqual true, greater false, smallerOrEqual true, smaller false.
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
expect(bp!.greater('md').value).toBeFalsy();
expect(bp!.smallerOrEqual('md').value).toBeTruthy();
expect(bp!.smaller('md').value).toBeFalsy();
vp.setWidth(900);
await nextTick();
expect(bp!.greater('md').value).toBeTruthy();
expect(bp!.smaller('md').value).toBeFalsy();
scope.stop();
});
it('between is half-open [a, b)', async () => {
const vp = stubViewport(768);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
// 768 is in [sm, lg) and in [md, lg); the upper bound (md) is exclusive in [sm, md).
expect(bp!.between('sm', 'lg').value).toBeTruthy();
expect(bp!.between('sm', 'md').value).toBeFalsy();
expect(bp!.between('md', 'lg').value).toBeTruthy();
scope.stop();
});
it('current() returns active breakpoints ordered small to large; active() picks per strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ lg: 1024, sm: 640, md: 768 });
});
await nextTick();
// 800px: sm and md active, sorted ascending.
expect(bp!.current().value).toEqual(['sm', 'md']);
// min-width strategy → largest active breakpoint.
expect(bp!.active().value).toBe('md');
scope.stop();
});
it('active() picks the smallest active breakpoint under max-width strategy', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 }, { strategy: 'max-width' });
});
await nextTick();
expect(bp!.active().value).toBe('lg');
scope.stop();
});
it('synchronous is* helpers read the current match', async () => {
stubViewport(800);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'md' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, md: 768, lg: 1024 });
});
await nextTick();
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
expect(bp!.isGreater('md')).toBeTruthy();
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
expect(bp!.isSmaller('sm')).toBeFalsy();
expect(bp!.isInBetween('sm', 'lg')).toBeTruthy();
expect(bp!.isInBetween('sm', 'md')).toBeFalsy();
scope.stop();
});
it('supports reactive breakpoint values', async () => {
const vp = stubViewport(700);
const threshold = ref(640);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'point'>>;
scope.run(() => {
bp = useBreakpoints({ point: threshold });
});
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
threshold.value = 900;
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeFalsy();
vp.setWidth(1000);
await nextTick();
expect(bp!.greaterOrEqual('point').value).toBeTruthy();
scope.stop();
});
it('handles string unit breakpoint values', async () => {
stubViewport(800); // 800px, 48em = 768px
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'md'>>;
scope.run(() => {
bp = useBreakpoints({ md: '48em' });
});
await nextTick();
expect(bp!.greaterOrEqual('md').value).toBeTruthy();
expect(bp!.isGreaterOrEqual('md')).toBeTruthy();
scope.stop();
});
describe('SSR / unsupported path', () => {
it('resolves width queries from ssrWidth when matchMedia is unavailable', () => {
// matchMedia stays undefined (beforeEach), window has no matchMedia path used by match().
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm' | 'lg'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640, lg: 1024 }, { ssrWidth: 800 });
});
// Synchronous helpers resolve against ssrWidth.
expect(bp!.isGreaterOrEqual('sm')).toBeTruthy();
expect(bp!.isGreaterOrEqual('lg')).toBeFalsy();
expect(bp!.isSmallerOrEqual('lg')).toBeTruthy();
scope.stop();
});
it('returns false for snapshot helpers with no window and no ssrWidth', () => {
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<'sm'>>;
scope.run(() => {
bp = useBreakpoints({ sm: 640 }, { window: undefined });
});
expect(bp!.isGreaterOrEqual('sm')).toBeFalsy();
expect(bp!.isSmallerOrEqual('sm')).toBeFalsy();
scope.stop();
});
it('does not throw when constructed without a window (SSR)', () => {
const scope = effectScope();
expect(() => {
scope.run(() => {
const bp = useBreakpoints(breakpointsTailwind, { window: undefined });
// Accessing reactive refs should be safe and default to false.
expect(bp.lg.value).toBeFalsy();
});
}).not.toThrow();
scope.stop();
});
});
describe('presets', () => {
it('exports the expected preset values', () => {
expect(breakpointsTailwind).toMatchObject({ sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536 });
expect(breakpointsBootstrapV5).toMatchObject({ xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400 });
expect(breakpointsAntDesign).toMatchObject({ xs: 480, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1600 });
expect(breakpointsVuetifyV3).toMatchObject({ xs: 0, sm: 600, md: 960, lg: 1280, xl: 1920, xxl: 2560 });
});
it('works with a preset', async () => {
stubViewport(1300);
const scope = effectScope();
let bp: ReturnType<typeof useBreakpoints<keyof typeof breakpointsTailwind & string>>;
scope.run(() => {
bp = useBreakpoints(breakpointsTailwind);
});
await nextTick();
expect(bp!.greaterOrEqual('xl').value).toBeTruthy();
expect(bp!.greaterOrEqual('2xl').value).toBeFalsy();
expect(bp!.active().value).toBe('xl');
scope.stop();
});
});
});
@@ -0,0 +1,266 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction, isNumber } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
/**
* A breakpoints map: name → viewport width. Numbers are treated as pixels;
* strings keep their unit (`"48em"`, `"30rem"`, `"1024px"`). Values may be
* reactive (refs or getters).
*/
export type Breakpoints<K extends string = string>
= Record<K, MaybeRefOrGetter<number | string>>;
/**
* Which edge a generated shortcut property (e.g. `breakpoints.lg`) reacts to.
*
* - `'min-width'` (mobile-first) — `lg` is `true` when the viewport is at least
* the `lg` width.
* - `'max-width'` (desktop-first) — `lg` is `true` when the viewport is at most
* the `lg` width.
*/
export type UseBreakpointsStrategy = 'min-width' | 'max-width';
export interface UseBreakpointsOptions extends ConfigurableWindow, Pick<UseMediaQueryOptions, 'ssrWidth'> {
/**
* The query strategy used by the generated shortcut properties.
*
* @default 'min-width'
*/
strategy?: UseBreakpointsStrategy;
}
export type UseBreakpointsReturn<K extends string = string>
= Record<K, ComputedRef<boolean>> & {
/** Reactive: viewport width is greater than or equal to breakpoint `k` (`min-width`). */
greaterOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is smaller than or equal to breakpoint `k` (`max-width`). */
smallerOrEqual: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is strictly greater than breakpoint `k`. */
greater: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is strictly smaller than breakpoint `k`. */
smaller: (k: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Reactive: viewport width is within `[a, b)`. */
between: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => ComputedRef<boolean>;
/** Snapshot: viewport width is strictly greater than breakpoint `k`. */
isGreater: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is greater than or equal to breakpoint `k`. */
isGreaterOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is strictly smaller than breakpoint `k`. */
isSmaller: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is smaller than or equal to breakpoint `k`. */
isSmallerOrEqual: (k: MaybeRefOrGetter<K>) => boolean;
/** Snapshot: viewport width is within `[a, b)`. */
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>) => boolean;
/** Reactive: all currently active breakpoints, ordered small → large. */
current: () => ComputedRef<K[]>;
/** Reactive: the single active breakpoint per `strategy` (largest for `min-width`, smallest for `max-width`), or `''` when none. */
active: () => ComputedRef<K | ''>;
};
/**
* Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a
* pixel number. `em`/`rem` use the conventional 16px root size.
*/
function pxValue(value: string): number {
const number = Number.parseFloat(value);
if (Number.isNaN(number))
return Number.NaN;
if (/(?:em|rem)\s*$/i.test(value))
return number * 16;
return number;
}
/**
* Add `delta` to the numeric portion of a CSS length, preserving its unit.
* Used to build the strict (`> / <`) variants from inclusive media queries via
* a small ±0.1 nudge.
*/
function increaseWithUnit(target: number | string, delta: number): number | string {
if (isNumber(target))
return target + delta;
const value = target.match(/^-?\d+(?:\.\d+)?/)?.[0] ?? '';
const unit = target.slice(value.length);
const result = Number.parseFloat(value) + delta;
if (Number.isNaN(result))
return target;
return result + unit;
}
/**
* @name useBreakpoints
* @category Browser
* @description Reactive viewport breakpoints derived from a breakpoints map.
* SSR-safe (resolves width queries from `ssrWidth` before `matchMedia` exists),
* reactive to breakpoint values, and built on a single `useMediaQuery` per
* comparison. Comes with presets: `breakpointsTailwind`, `breakpointsBootstrapV5`,
* `breakpointsAntDesign`, `breakpointsVuetifyV3`.
*
* @param {Breakpoints<K>} breakpoints The breakpoints map (`name → width`)
* @param {UseBreakpointsOptions} [options={}] Options (`strategy`, custom `window`, `ssrWidth`)
* @returns {UseBreakpointsReturn<K>} Shortcut refs per breakpoint plus comparison helpers
*
* @example
* const bp = useBreakpoints(breakpointsTailwind);
* const isDesktop = bp.greaterOrEqual('lg');
* const isMobile = bp.smaller('md');
* bp.lg; // ComputedRef<boolean> — true when viewport >= 1024px
*
* @example
* const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 });
* const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''>
*
* @since 0.0.15
*/
export function useBreakpoints<K extends string>(
breakpoints: Breakpoints<K>,
options: UseBreakpointsOptions = {},
): UseBreakpointsReturn<K> {
const { window = defaultWindow, strategy = 'min-width', ssrWidth } = options;
const mediaOptions: UseMediaQueryOptions = { window, ssrWidth };
const ssrSupport = isNumber(ssrWidth);
function getValue(k: MaybeRefOrGetter<K>, delta?: number): string {
let v = toValue(breakpoints[toValue(k)]);
if (delta !== undefined)
v = increaseWithUnit(v, delta);
return isNumber(v) ? `${v}px` : v;
}
// Synchronous (non-reactive) match for the `is*` snapshot helpers.
function match(edge: 'min' | 'max', size: string): boolean {
const supported = window && isFunction(window.matchMedia);
if (!supported)
return ssrSupport
? (edge === 'min' ? ssrWidth >= pxValue(size) : ssrWidth <= pxValue(size))
: false;
return window.matchMedia(`(${edge}-width: ${size})`).matches;
}
const greaterOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(k)})`, mediaOptions);
const smallerOrEqual = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(max-width: ${getValue(k)})`, mediaOptions);
const greater = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(k, 0.1)})`, mediaOptions);
const smaller = (k: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(max-width: ${getValue(k, -0.1)})`, mediaOptions);
const between = (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): ComputedRef<boolean> =>
useMediaQuery(() => `(min-width: ${getValue(a)}) and (max-width: ${getValue(b, -0.1)})`, mediaOptions);
const keys = Object.keys(breakpoints) as K[];
// Generated shortcut properties (`bp.lg`). Lazily created getters so we only
// spin up a `useMediaQuery` watcher for the breakpoints actually accessed.
const shortcuts = keys.reduce((acc, k) => {
Object.defineProperty(acc, k, {
get: () => strategy === 'min-width' ? greaterOrEqual(k) : smallerOrEqual(k),
enumerable: true,
configurable: true,
});
return acc;
}, {} as Record<K, ComputedRef<boolean>>);
function current(): ComputedRef<K[]> {
const points = keys
.map(k => [k, shortcuts[k], pxValue(getValue(k))] as const)
.sort((a, b) => a[2] - b[2]);
return computed(() => points.filter(([, matches]) => matches.value).map(([k]) => k));
}
return Object.assign(shortcuts, {
greaterOrEqual,
smallerOrEqual,
greater,
smaller,
between,
isGreater: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k, 0.1)),
isGreaterOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('min', getValue(k)),
isSmaller: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k, -0.1)),
isSmallerOrEqual: (k: MaybeRefOrGetter<K>): boolean => match('max', getValue(k)),
isInBetween: (a: MaybeRefOrGetter<K>, b: MaybeRefOrGetter<K>): boolean =>
match('min', getValue(a)) && match('max', getValue(b, -0.1)),
current,
active(): ComputedRef<K | ''> {
const bps = current();
return computed(() => bps.value.length === 0
? ''
: bps.value.at(strategy === 'min-width' ? -1 : 0)!);
},
});
}
/**
* Tailwind CSS default breakpoints.
*
* @see https://tailwindcss.com/docs/responsive-design
*/
export const breakpointsTailwind = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
};
/**
* Bootstrap v5 default breakpoints.
*
* @see https://getbootstrap.com/docs/5.0/layout/breakpoints/
*/
export const breakpointsBootstrapV5 = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1400,
};
/**
* Ant Design default breakpoints.
*
* @see https://ant.design/components/grid#col
*/
export const breakpointsAntDesign = {
xs: 480,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1600,
};
/**
* Vuetify v3 default breakpoints.
*
* @see https://vuetifyjs.com/en/features/display-and-platform/
*/
export const breakpointsVuetifyV3 = {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920,
xxl: 2560,
};
@@ -3,9 +3,9 @@ import type { MaybeRefOrGetter, Ref } from 'vue';
import { isString } from '@robonen/stdlib'; import { isString } from '@robonen/stdlib';
import { defaultNavigator } from '@/types'; import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener'; import { useEventListener } from '@/composables/browser/useEventListener';
import { useTimeoutFn } from '@/composables/utilities/useTimeoutFn'; import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
/** /**
* A value to copy: either a string or an (optionally async) getter that resolves to one. * A value to copy: either a string or an (optionally async) getter that resolves to one.
@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import type { UseClipboardItemsReturn } from '.';
import { useClipboardItems } from '.';
function makeItems(label: string): ClipboardItems {
// jsdom may lack ClipboardItem; a tagged plain object is enough for assertions.
return [{ types: [label] } as unknown as ClipboardItem];
}
function stubClipboard(readItems: ClipboardItems = makeItems('read')) {
const write = vi.fn(async () => {});
const read = vi.fn(async () => readItems);
const navigator = {
clipboard: { write, read },
} as unknown as Navigator;
return { navigator, write, read };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useClipboardItems, () => {
it('reports support when the clipboard API exists', () => {
const { navigator } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
expect(clip!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported without the clipboard API', () => {
const navigator = {} as unknown as Navigator;
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
expect(clip!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported when navigator is undefined (SSR)', () => {
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator: undefined });
});
expect(clip!.isSupported.value).toBeFalsy();
scope.stop();
});
it('copies items and sets copied flag', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('copy');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(items);
expect(write).toHaveBeenCalledWith(items);
expect(clip!.content.value).toBe(items);
expect(clip!.copied.value).toBeTruthy();
scope.stop();
});
it('copies the configured source when called without args', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('source');
const scope = effectScope();
let clip: any;
scope.run(() => {
clip = useClipboardItems({ navigator, source: items });
});
await clip.copy();
expect(write).toHaveBeenCalledWith(items);
scope.stop();
});
it('copies a value resolved from an async getter', async () => {
const { navigator, write } = stubClipboard();
const items = makeItems('lazy');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(async () => items);
expect(write).toHaveBeenCalledWith(items);
expect(clip!.content.value).toBe(items);
scope.stop();
});
it('skips when an async getter resolves to undefined', async () => {
const { navigator, write } = stubClipboard();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(async () => undefined);
expect(write).not.toHaveBeenCalled();
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('exposes copyPending around an in-flight async copy', async () => {
const { navigator } = stubClipboard();
const items = makeItems('done');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
let release: (v: ClipboardItems) => void = () => {};
const promise = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
release = resolve;
}));
expect(clip!.copyPending.value).toBeTruthy();
release(items);
await promise;
expect(clip!.copyPending.value).toBeFalsy();
expect(clip!.content.value).toBe(items);
scope.stop();
});
it('ignores a stale async copy superseded by a newer one', async () => {
const { navigator, write } = stubClipboard();
const fastItems = makeItems('fast');
const slowItems = makeItems('slow');
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
let releaseSlow: (v: ClipboardItems) => void = () => {};
const slow = clip!.copy(() => new Promise<ClipboardItems>((resolve) => {
releaseSlow = resolve;
}));
const fast = clip!.copy(async () => fastItems);
await fast;
releaseSlow(slowItems);
await slow;
expect(clip!.content.value).toBe(fastItems);
expect(write).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledWith(fastItems);
scope.stop();
});
it('does nothing when unsupported', async () => {
const navigator = {} as unknown as Navigator;
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.copy(makeItems('x'));
expect(clip!.copied.value).toBeFalsy();
scope.stop();
});
it('reads the clipboard via read()', async () => {
const readItems = makeItems('from-clipboard');
const { navigator, read } = stubClipboard(readItems);
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator });
});
await clip!.read();
expect(read).toHaveBeenCalled();
expect(clip!.content.value).toBe(readItems);
scope.stop();
});
it('syncs content on copy/cut events when read is enabled', async () => {
const readItems = makeItems('synced');
const { navigator, read } = stubClipboard(readItems);
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator, read: true });
});
globalThis.dispatchEvent(new Event('copy'));
await nextTick();
await Promise.resolve();
expect(read).toHaveBeenCalled();
expect(clip!.content.value).toBe(readItems);
scope.stop();
});
it('routes a rejected write to onError instead of throwing', async () => {
const error = new Error('denied');
const write = vi.fn(async () => {
throw error;
});
const navigator = {
clipboard: { write, read: vi.fn() },
} as unknown as Navigator;
const onError = vi.fn();
const scope = effectScope();
let clip: UseClipboardItemsReturn<false>;
scope.run(() => {
clip = useClipboardItems({ navigator, onError });
});
await clip!.copy(makeItems('boom'));
expect(onError).toHaveBeenCalledWith(error);
expect(clip!.copied.value).toBeFalsy();
expect(clip!.copyPending.value).toBeFalsy();
scope.stop();
});
});
@@ -0,0 +1,185 @@
import { shallowReadonly, shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue';
import { isFunction, noop } from '@robonen/stdlib';
import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useTimeoutFn } from '@/composables/animation/useTimeoutFn';
/**
* A value to copy: either concrete `ClipboardItems` or an (optionally async)
* getter that resolves to them.
*/
export type ClipboardItemsValue
= | ClipboardItems
| (() => Promise<ClipboardItems | undefined> | ClipboardItems | undefined);
export interface UseClipboardItemsOptions<Source> extends ConfigurableNavigator {
/**
* Sync `content` with the system clipboard by listening to copy/cut events
*
* @default false
*/
read?: boolean;
/**
* Default source value to copy when `copy()` is called without an argument
*/
source?: Source;
/**
* Milliseconds the `copied` flag stays `true` after a copy
*
* @default 1500
*/
copiedDuring?: number;
/**
* Called when a read/write rejects, instead of throwing
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseClipboardItemsReturn<Optional extends boolean> {
/**
* Whether the async Clipboard API (with `ClipboardItem`) is available
*/
isSupported: Readonly<Ref<boolean>>;
/**
* The current clipboard items (kept in sync when `read` is enabled)
*/
content: Readonly<Ref<ClipboardItems>>;
/**
* `true` for `copiedDuring` ms after a successful copy
*/
copied: Readonly<Ref<boolean>>;
/**
* `true` while an async `copy()` is in flight
*/
copyPending: Readonly<Ref<boolean>>;
/**
* Copy clipboard items to the system clipboard
*/
copy: Optional extends true
? (content?: ClipboardItemsValue) => Promise<void>
: (content: ClipboardItemsValue) => Promise<void>;
/**
* Manually read the system clipboard into `content`
*/
read: () => Promise<void>;
}
/**
* @name useClipboardItems
* @category Browser
* @description Reactive async Clipboard API with rich `ClipboardItem` support
* (read/write images, HTML, and arbitrary MIME types — not just text).
* SSR-safe; uses passive `copy`/`cut` listeners and guards stale async writes.
*
* @param {UseClipboardItemsOptions} [options={}] Options
* @returns {UseClipboardItemsReturn} `isSupported`, `content`, `copied`, `copyPending`, `copy`, and `read`
*
* @example
* const { content, copy, copied, isSupported } = useClipboardItems();
* copy([new ClipboardItem({ 'text/plain': new Blob(['hello'], { type: 'text/plain' }) })]);
*
* @example
* // Copy a lazily/asynchronously resolved value, kept in sync with the system clipboard
* const { content } = useClipboardItems({ read: true });
* copy(async () => buildClipboardItems());
*
* @since 0.0.15
*/
export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>;
export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>;
export function useClipboardItems(
options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems> | undefined> = {},
): UseClipboardItemsReturn<boolean> {
const {
navigator = defaultNavigator,
read = false,
source,
copiedDuring = 1500,
onError = noop,
} = options;
const isSupported = useSupported(() => navigator && 'clipboard' in navigator);
const content = shallowRef<ClipboardItems>([]);
const copied = shallowRef(false);
const copyPending = shallowRef(false);
// Guards against a slow async copy clobbering the result of a newer one
let lastResolveId = 0;
const timeout = useTimeoutFn(() => {
copied.value = false;
}, copiedDuring, { immediate: false });
async function updateContent(): Promise<void> {
if (!isSupported.value)
return;
try {
content.value = await navigator!.clipboard.read();
}
catch (error) {
onError(error);
}
}
if (isSupported.value && read)
useEventListener(['copy', 'cut'], updateContent, { passive: true });
async function copy(value: ClipboardItemsValue | undefined = toValue(source)): Promise<void> {
if (!isSupported.value || value === null || value === undefined)
return;
copyPending.value = true;
try {
let resolved: ClipboardItems | undefined;
if (isFunction(value)) {
const currentId = ++lastResolveId;
resolved = await value();
// Drop a stale async resolution superseded by a newer copy
if (resolved === null || resolved === undefined || currentId !== lastResolveId)
return;
}
else {
resolved = value;
}
await navigator!.clipboard.write(resolved);
content.value = resolved;
copied.value = true;
timeout.start();
}
catch (error) {
onError(error);
}
finally {
copyPending.value = false;
}
}
return {
isSupported,
content: shallowReadonly(content),
copied: shallowReadonly(copied),
copyPending: shallowReadonly(copyPending),
copy,
read: updateContent,
};
}
@@ -3,7 +3,7 @@ import { noop } from '@robonen/stdlib';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { defaultWindow } from '@/types'; import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types'; import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener'; import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
@@ -186,12 +186,12 @@ export function useColorMode<T extends string = BasicColorMode>(
let attributeToChange: { key: string; value: string } | null = null; let attributeToChange: { key: string; value: string } | null = null;
if (attr === 'class') { if (attr === 'class') {
const next = value.split(/\s/g); const next = new Set(value.split(/\s/g));
// Toggle only the classes this composable owns (derived from `modes`), // Toggle only the classes this composable owns (derived from `modes`),
// so unrelated classes on the element are left untouched. // so unrelated classes on the element are left untouched.
for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) { for (const owned of Object.values<string>(modes).flatMap(mode => (mode || '').split(/\s/g)).filter(Boolean)) {
if (next.includes(owned)) if (next.has(owned))
classesToAdd.add(owned); classesToAdd.add(owned);
else else
classesToRemove.add(owned); classesToRemove.add(owned);
@@ -0,0 +1,234 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useCssVar } from '.';
let mutationInstances: Array<{ cb: MutationCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
class StubMutationObserver {
observe = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
cb: MutationCallback;
constructor(cb: MutationCallback) {
this.cb = cb;
mutationInstances.push(this);
}
}
describe(useCssVar, () => {
afterEach(() => {
vi.unstubAllGlobals();
mutationInstances = [];
});
it('reads the existing custom property from the target', () => {
const el = document.createElement('div');
el.style.setProperty('--color', 'red');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
expect(color!.value).toBe('red');
scope.stop();
});
it('writes the property to the element when set', async () => {
const el = document.createElement('div');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
color!.value = 'blue';
await nextTick();
expect(el.style.getPropertyValue('--color')).toBe('blue');
expect(color!.value).toBe('blue');
scope.stop();
});
it('removes the property when set to null', () => {
const el = document.createElement('div');
el.style.setProperty('--color', 'green');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--color', el);
});
color!.value = null;
expect(el.style.getPropertyValue('--color')).toBe('');
expect(color!.value).toBeNull();
scope.stop();
});
it('falls back to initialValue when the property is unset', () => {
const el = document.createElement('div');
document.body.appendChild(el);
const scope = effectScope();
let color: ReturnType<typeof useCssVar>;
scope.run(() => {
color = useCssVar('--missing', el, { initialValue: 'gray' });
});
expect(color!.value).toBe('gray');
scope.stop();
});
it('defaults the target to document.documentElement', () => {
document.documentElement.style.setProperty('--root-var', 'rootval');
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--root-var');
});
expect(v!.value).toBe('rootval');
v!.value = 'changed';
expect(document.documentElement.style.getPropertyValue('--root-var')).toBe('changed');
document.documentElement.style.removeProperty('--root-var');
scope.stop();
});
it('removes the old property and re-reads when the prop name changes', async () => {
const el = document.createElement('div');
el.style.setProperty('--a', 'one');
el.style.setProperty('--b', 'two');
document.body.appendChild(el);
const prop = ref('--a');
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar(prop, el);
});
expect(v!.value).toBe('one');
prop.value = '--b';
await nextTick();
expect(el.style.getPropertyValue('--a')).toBe('');
expect(v!.value).toBe('two');
scope.stop();
});
it('re-reads when a reactive target changes', async () => {
const a = document.createElement('div');
a.style.setProperty('--c', 'fromA');
const b = document.createElement('div');
b.style.setProperty('--c', 'fromB');
document.body.append(a, b);
const target = ref<HTMLElement>(a);
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--c', target);
});
expect(v!.value).toBe('fromA');
target.value = b;
await nextTick();
expect(v!.value).toBe('fromB');
scope.stop();
});
it('attaches a MutationObserver only when observe is true', () => {
vi.stubGlobal('MutationObserver', StubMutationObserver);
const el = document.createElement('div');
document.body.appendChild(el);
const scopeOff = effectScope();
scopeOff.run(() => useCssVar('--x', el));
expect(mutationInstances).toHaveLength(0);
scopeOff.stop();
const scopeOn = effectScope();
scopeOn.run(() => useCssVar('--x', el, { observe: true }));
expect(mutationInstances).toHaveLength(1);
expect(mutationInstances[0]!.observe).toHaveBeenCalledWith(
el,
expect.objectContaining({ attributeFilter: ['style', 'class'] }),
);
scopeOn.stop();
});
it('updates the ref when an observed mutation fires', () => {
vi.stubGlobal('MutationObserver', StubMutationObserver);
const el = document.createElement('div');
el.style.setProperty('--y', 'initial');
document.body.appendChild(el);
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--y', el, { observe: true });
});
expect(v!.value).toBe('initial');
// Simulate an external change followed by a mutation record.
el.style.setProperty('--y', 'external');
mutationInstances[0]!.cb([{ type: 'attributes' } as MutationRecord], mutationInstances[0] as unknown as MutationObserver);
expect(v!.value).toBe('external');
scope.stop();
});
it('does not throw and keeps initialValue under SSR (no window)', () => {
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--ssr', null, {
initialValue: 'fallback',
window: undefined as unknown as Window,
});
});
expect(v!.value).toBe('fallback');
// Writing is a no-op on the (missing) element but still updates the store.
v!.value = 'next';
expect(v!.value).toBe('next');
scope.stop();
});
it('uses a custom window from options for getComputedStyle', () => {
const el = document.createElement('div');
document.body.appendChild(el);
const getComputedStyle = vi.fn(() => ({
getPropertyValue: () => ' spaced ',
})) as unknown as Window['getComputedStyle'];
const fakeWindow = {
getComputedStyle,
document: { documentElement: el },
} as unknown as Window;
const scope = effectScope();
let v: ReturnType<typeof useCssVar>;
scope.run(() => {
v = useCssVar('--z', el, { window: fakeWindow });
});
expect(getComputedStyle).toHaveBeenCalledWith(el);
expect(v!.value).toBe('spaced');
scope.stop();
});
});
@@ -0,0 +1,112 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, WritableComputedRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useMutationObserver } from '@/composables/elements/useMutationObserver';
export interface UseCssVarOptions extends ConfigurableWindow {
/**
* Value used before the variable resolves (and the fallback when the
* computed value is empty).
*/
initialValue?: string;
/**
* Watch the target with a `MutationObserver` (filtered to `style`/`class`)
* so the ref reflects external changes to the variable.
*
* @default false
*/
observe?: boolean;
}
export interface UseCssVarReturn extends WritableComputedRef<string | null | undefined> {}
/**
* @name useCssVar
* @category Browser
* @description Read and write a CSS custom property on an element as a reactive ref.
* Defaults to `document.documentElement`. Set `observe` to react to external
* changes via a `MutationObserver`.
*
* @param {MaybeRefOrGetter<string | null | undefined>} prop The CSS variable name (e.g. `--color`)
* @param {MaybeComputedElementRef} [target] Element to read/write the variable on (defaults to `documentElement`)
* @param {UseCssVarOptions} [options={}] `initialValue`, `observe`, and a configurable `window`
* @returns {UseCssVarReturn} A writable ref; reading returns the current value, writing updates the property
*
* @example
* const color = useCssVar('--color', el);
* color.value = 'red';
*
* @example
* const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true });
*
* @since 0.0.15
*/
export function useCssVar(
prop: MaybeRefOrGetter<string | null | undefined>,
target?: MaybeComputedElementRef,
options: UseCssVarOptions = {},
): UseCssVarReturn {
const { window = defaultWindow, initialValue, observe = false } = options;
// Backing store: only mutated on explicit reads / observed changes,
// so consumers reading `.value` never pay for `getComputedStyle`.
const store = shallowRef<string | null | undefined>(initialValue);
const elRef: ComputedRef<HTMLElement | SVGElement | undefined> = computed(
() => (unrefElement(target) as HTMLElement | SVGElement | undefined) ?? window?.document?.documentElement,
);
const read = (): void => {
const el = elRef.value;
const key = toValue(prop);
if (!el || !window || !key)
return;
const value = window.getComputedStyle(el).getPropertyValue(key)?.trim();
store.value = value || store.value || initialValue;
};
const write = (value: string | null | undefined): void => {
const el = elRef.value;
const key = toValue(prop);
store.value = value;
if (!el?.style || !key)
return;
if (value === null || value === undefined)
el.style.removeProperty(key);
else
el.style.setProperty(key, value);
};
if (observe) {
useMutationObserver(elRef, read, {
attributeFilter: ['style', 'class'],
window,
});
}
// Single watcher: when the element or prop changes, drop the old custom
// property and re-read the current value.
watch(
() => [elRef.value, toValue(prop)] as const,
([el, key], old) => {
const [oldEl, oldKey] = old ?? [];
if (oldEl?.style && oldKey && (oldEl !== el || oldKey !== key))
oldEl.style.removeProperty(oldKey);
read();
},
{ immediate: true },
);
return computed<string | null | undefined>({
get: () => store.value,
set: write,
});
}
@@ -0,0 +1,330 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDark } from '.';
type Listener = (event: { matches: boolean }) => void;
interface StubMql {
readonly matches: boolean;
media: string;
addEventListener: (type: string, cb: Listener) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: (value: boolean) => void;
}
function makeMql(initialMatches: boolean, media = ''): StubMql {
const listeners = new Set<Listener>();
let matches = initialMatches;
return {
get matches() {
return matches;
},
media,
addEventListener: (_: string, cb: Listener) => listeners.add(cb),
removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
dispatch(value: boolean) {
matches = value;
for (const cb of listeners) cb({ matches: value });
},
};
}
/**
* Build a stub `window` that reuses the real jsdom `document` (so DOM updates
* applied to `<html>` are observable) but with a controllable `matchMedia` for
* `prefers-color-scheme: dark`, an isolated in-memory `localStorage`, and a
* `getComputedStyle` shim for the transition-disabling reflow.
*/
function makeWindow(prefersDark: StubMql) {
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const win = {
document: globalThis.document,
matchMedia: vi.fn((query: string) =>
query.includes('dark') ? prefersDark : makeMql(false, query)),
localStorage: storage,
getComputedStyle: () => ({ opacity: '1' }),
dispatchEvent: () => true,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
return { win, storage, map };
}
function reset() {
document.documentElement.className = '';
document.documentElement.removeAttribute('data-theme');
}
describe(useDark, () => {
beforeEach(() => {
reset();
// Ensure module-captured defaultWindow.matchMedia is undefined so the
// composable must use the injected window.
vi.stubGlobal('matchMedia', undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
reset();
});
it('reflects the system preference in auto mode (dark)', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('reflects the system preference in auto mode (light)', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
// Default valueLight is '' so no light class is applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('writing true while the system prefers light applies the dark class', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('writing true while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(true);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
// Start from an explicit light value so the store is not already auto.
isDark = useDark({ window: win });
});
await nextTick();
isDark!.value = false;
await nextTick();
expect(storage.getItem('vuetools-color-scheme')).toBe('light');
// System prefers dark, so requesting dark should resolve to 'auto'.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('writing false while the system prefers dark falls back to auto', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
// System prefers light, so requesting light resolves to 'auto'.
isDark!.value = false;
await nextTick();
expect(isDark!.value).toBeFalsy();
expect(storage.getItem('vuetools-color-scheme')).toBe('auto');
scope.stop();
});
it('reacts to system preference changes while in auto mode', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win });
});
await nextTick();
expect(isDark!.value).toBeFalsy();
prefersDark.dispatch(true);
await nextTick();
expect(isDark!.value).toBeTruthy();
expect(document.documentElement.classList.contains('dark')).toBeTruthy();
scope.stop();
});
it('honours custom valueDark / valueLight on a custom attribute', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({
window: win,
attribute: 'data-theme',
valueDark: 'night',
valueLight: 'day',
});
});
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('day');
isDark!.value = true;
await nextTick();
expect(document.documentElement.getAttribute('data-theme')).toBe('night');
scope.stop();
});
it('persists to a custom storageKey', async () => {
const prefersDark = makeMql(false);
const { win, storage } = makeWindow(prefersDark);
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storageKey: 'my-dark' });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(storage.getItem('my-dark')).toBe('dark');
expect(storage.getItem('vuetools-color-scheme')).toBeNull();
scope.stop();
});
it('uses a custom storage backend', async () => {
const prefersDark = makeMql(false);
const { win } = makeWindow(prefersDark);
const map = new Map<string, string>();
const storage: Storage = {
getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
setItem: (key: string, value: string) => { map.set(key, String(value)); },
removeItem: (key: string) => { map.delete(key); },
clear: () => map.clear(),
key: (index: number) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
scope.run(() => {
isDark = useDark({ window: win, storage });
});
await nextTick();
isDark!.value = true;
await nextTick();
expect(map.get('vuetools-color-scheme')).toBe('dark');
scope.stop();
});
it('invokes a custom onChanged handler with the boolean state', async () => {
const prefersDark = makeMql(true);
const { win } = makeWindow(prefersDark);
const onChanged = vi.fn();
const scope = effectScope();
scope.run(() => {
useDark({ window: win, onChanged });
});
await nextTick();
expect(onChanged).toHaveBeenCalled();
const [boolValue, handler, mode] = onChanged.mock.calls[0]!;
expect(boolValue).toBeTruthy();
expect(typeof handler).toBe('function');
expect(mode).toBe('dark');
// Default handler suppressed: no class applied.
expect(document.documentElement.classList.contains('dark')).toBeFalsy();
scope.stop();
});
it('does not throw on the SSR/unsupported path (no window)', async () => {
const scope = effectScope();
let isDark: ReturnType<typeof useDark>;
expect(() => {
scope.run(() => {
isDark = useDark({ window: undefined });
});
}).not.toThrow();
await nextTick();
// System detection unavailable -> defaults to light -> isDark false.
expect(isDark!.value).toBeFalsy();
// Still writable in memory.
isDark!.value = true;
await nextTick();
expect(isDark!.value).toBeTruthy();
scope.stop();
});
});
@@ -0,0 +1,97 @@
import { computed } from 'vue';
import type { WritableComputedRef } from 'vue';
import { useColorMode } from '@/composables/browser/useColorMode';
import type { BasicColorSchema, UseColorModeOptions } from '@/composables/browser/useColorMode';
export interface UseDarkOptions extends Omit<UseColorModeOptions<BasicColorSchema>, 'modes' | 'onChanged'> {
/**
* Value applied to the target element when `isDark` is `true`.
*
* @default 'dark'
*/
valueDark?: string;
/**
* Value applied to the target element when `isDark` is `false`.
*
* @default ''
*/
valueLight?: string;
/**
* Custom handler called whenever the resolved mode changes. When specified,
* the default DOM update is overridden (call `defaultHandler` to keep it).
*
* @default undefined
*/
onChanged?: (isDark: boolean, defaultHandler: (mode: BasicColorSchema) => void, mode: BasicColorSchema) => void;
}
export type UseDarkReturn = WritableComputedRef<boolean>;
/**
* @name useDark
* @category Browser
* @description Reactive dark mode boolean with system detection and storage
* persistence, built on `useColorMode`. Writing `false` while the system
* already prefers light (or `true` while it prefers dark) falls back to
* `'auto'`, so the mode keeps tracking the OS preference.
*
* @param {UseDarkOptions} [options={}] Options
* @returns {UseDarkReturn} A writable boolean ref; `true` when dark mode is active
*
* @example
* const isDark = useDark();
* isDark.value = true;
*
* @example
* // Toggle a data attribute instead of a class
* const isDark = useDark({
* attribute: 'data-theme',
* valueDark: 'dark',
* valueLight: 'light',
* });
*
* @example
* import { useToggle } from '@/composables/state/useToggle';
*
* const isDark = useDark();
* const toggleDark = useToggle(isDark);
*
* @since 0.0.15
*/
export function useDark(options: UseDarkOptions = {}): UseDarkReturn {
const {
valueDark = 'dark',
valueLight = '',
} = options;
const mode = useColorMode({
...options,
onChanged: (resolved, defaultHandler) => {
if (options.onChanged)
options.onChanged(resolved === 'dark', defaultHandler, resolved);
else
defaultHandler(resolved);
},
modes: {
dark: valueDark,
light: valueLight,
},
});
const isDark = computed<boolean>({
get() {
return mode.state.value === 'dark';
},
set(value) {
const next = value ? 'dark' : 'light';
// When the requested state already matches the system preference, fall
// back to `'auto'` so the mode keeps following the OS going forward.
mode.value = mode.system.value === next ? 'auto' : next;
},
});
return isDark;
}
@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useDocumentPiP } from '.';
import type { DocumentPictureInPictureOptions } from '.';
/**
* Build a fake Picture-in-Picture `Window` that records its listeners and
* `close()` call. Passed through options so it reaches the import-time-captured
* `defaultWindow` substitute (see test gotcha).
*/
function createPipWindow() {
const listeners = new Map<string, EventListener>();
const pip = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners.set(type, listener);
}),
close: vi.fn(),
} as unknown as Window;
function firePagehide() {
listeners.get('pagehide')?.(new Event('pagehide'));
}
return { pip, firePagehide, close: pip.close as ReturnType<typeof vi.fn> };
}
function createWindow(pip: Window) {
const requestWindow = vi.fn(async (_options?: DocumentPictureInPictureOptions) => pip);
const win = {
documentPictureInPicture: { window: null, requestWindow },
} as unknown as Window & typeof globalThis;
return { window: win, requestWindow };
}
describe(useDocumentPiP, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('reports supported when documentPictureInPicture exists on window', () => {
const scope = effectScope();
const { pip } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
expect(result!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when the API is absent', () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: win });
});
expect(result!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is SSR safe when window is undefined', async () => {
const scope = effectScope();
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: undefined as unknown as Window });
});
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.open()).resolves.toBeUndefined();
scope.stop();
});
it('opens a PiP window, tracks it, and forwards options', async () => {
const scope = effectScope();
const { pip } = createPipWindow();
const { window, requestWindow } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
const returned = await result!.open({ width: 320, height: 240 });
await nextTick();
expect(requestWindow).toHaveBeenCalledWith({ width: 320, height: 240 });
expect(returned).toBe(pip);
expect(result!.pipWindow.value).toBe(pip);
expect(result!.isOpen.value).toBeTruthy();
scope.stop();
});
it('clears the reference when the PiP window emits pagehide', async () => {
const scope = effectScope();
const { pip, firePagehide } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
expect(result!.isOpen.value).toBeTruthy();
firePagehide();
await nextTick();
expect(result!.pipWindow.value).toBeNull();
expect(result!.isOpen.value).toBeFalsy();
scope.stop();
});
it('close() closes the window and clears state', async () => {
const scope = effectScope();
const { pip, close } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
result!.close();
expect(close).toHaveBeenCalledTimes(1);
expect(result!.pipWindow.value).toBeNull();
scope.stop();
});
it('closes the PiP window when the scope is disposed', async () => {
const scope = effectScope();
const { pip, close } = createPipWindow();
const { window } = createWindow(pip);
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window });
});
await result!.open();
scope.stop();
expect(close).toHaveBeenCalledTimes(1);
});
it('stores the error and invokes onError when open() rejects', async () => {
const scope = effectScope();
const error = new DOMException('gesture required', 'NotAllowedError');
const requestWindow = vi.fn(async () => {
throw error;
});
const win = {
documentPictureInPicture: { window: null, requestWindow },
} as unknown as Window & typeof globalThis;
const onError = vi.fn();
let result: ReturnType<typeof useDocumentPiP>;
scope.run(() => {
result = useDocumentPiP({ window: win, onError });
});
await expect(result!.open()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(error);
expect(result!.error.value).toBe(error);
expect(result!.isOpen.value).toBeFalsy();
scope.stop();
});
});
@@ -0,0 +1,169 @@
import { computed, shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface DocumentPictureInPictureOptions {
/**
* The initial width of the Picture-in-Picture window, in pixels.
*/
width?: number;
/**
* The initial height of the Picture-in-Picture window, in pixels.
*/
height?: number;
/**
* Hide the "back to tab" button in the Picture-in-Picture window.
*
* @default false
*/
disallowReturnToOpener?: boolean;
/**
* Open the window in its default position/size rather than reusing the last one.
*
* @default false
*/
preferInitialWindowPlacement?: boolean;
}
interface DocumentPictureInPicture {
readonly window: Window | null;
requestWindow: (options?: DocumentPictureInPictureOptions) => Promise<Window>;
}
interface WindowWithDocumentPiP {
documentPictureInPicture: DocumentPictureInPicture;
}
export interface UseDocumentPiPOptions extends ConfigurableWindow {
/**
* Called when `open()` rejects (e.g. not triggered by a user gesture) instead
* of throwing. The same value is also stored in the returned `error` ref.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseDocumentPiPReturn {
/**
* Whether the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) is supported
*/
isSupported: ComputedRef<boolean>;
/**
* The active Picture-in-Picture `Window`, or `null` when none is open
*/
pipWindow: ShallowRef<Window | null>;
/**
* Whether a Picture-in-Picture window is currently open
*/
isOpen: ComputedRef<boolean>;
/**
* The last error thrown by `open()`, or `null`
*/
error: ShallowRef<unknown | null>;
/**
* Open a Picture-in-Picture window. Must be called from a user gesture.
* Resolves with the new `Window`, or `undefined` when unsupported.
*/
open: (pipOptions?: DocumentPictureInPictureOptions) => Promise<Window | undefined>;
/**
* Close the active Picture-in-Picture window, if any
*/
close: () => void;
}
/**
* @name useDocumentPiP
* @category Browser
* @description Reactive wrapper around the [Document Picture-in-Picture API](https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture) for rendering arbitrary DOM in an always-on-top window.
*
* @param {UseDocumentPiPOptions} [options={}] Options
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
* @param {Window} [options.window=defaultWindow] Custom `window` instance
* @returns {UseDocumentPiPReturn} `isSupported`, `pipWindow`, `isOpen`, `error`, `open()`, and `close()`
*
* @example
* const { isSupported, pipWindow, open } = useDocumentPiP();
* async function popOut(content: HTMLElement) {
* const win = await open({ width: 320, height: 240 });
* win?.document.body.append(content);
* }
*
* @example
* // Move a player into the PiP window and track open state
* const { isOpen, pipWindow, open, close } = useDocumentPiP();
* watchEffect(() => {
* if (pipWindow.value)
* pipWindow.value.document.body.append(playerEl);
* });
*
* @since 0.0.15
*/
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
const {
window = defaultWindow,
onError = noop,
} = options;
const isSupported = useSupported(() => !!window && 'documentPictureInPicture' in window);
const pipWindow = shallowRef<Window | null>(null);
const error = shallowRef<unknown | null>(null);
const isOpen = computed<boolean>(() => pipWindow.value !== null);
function handleClose(): void {
pipWindow.value = null;
}
async function open(pipOptions?: DocumentPictureInPictureOptions): Promise<Window | undefined> {
if (!isSupported.value || !window)
return undefined;
error.value = null;
try {
const controller = (window as unknown as WindowWithDocumentPiP).documentPictureInPicture;
const pip = await controller.requestWindow(pipOptions);
// The PiP window closing (user or programmatic) clears our reference.
pip.addEventListener('pagehide', handleClose, { once: true });
pipWindow.value = pip;
return pip;
}
catch (err) {
error.value = err;
onError(err);
return undefined;
}
}
function close(): void {
pipWindow.value?.close();
pipWindow.value = null;
}
tryOnScopeDispose(close);
return {
isSupported,
pipWindow,
isOpen,
error,
open,
close,
};
}
@@ -2,7 +2,7 @@ import { shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue'; import type { ComputedRef, ShallowRef } from 'vue';
import { defaultWindow } from '@/types'; import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types'; import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
export interface EyeDropperOpenOptions { export interface EyeDropperOpenOptions {
/** /**
@@ -0,0 +1,314 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import type { FileSystemFileHandle, UseFileSystemAccessReturn } from '.';
import { useFileSystemAccess } from '.';
interface WritableSpy {
write: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
}
function makeFile(contents = 'hello world', name = 'demo.txt', type = 'text/plain'): File {
const file = {
name,
type,
size: contents.length,
lastModified: 1234,
text: vi.fn(async () => contents),
arrayBuffer: vi.fn(async () => new ArrayBuffer(contents.length)),
};
return file as unknown as File;
}
function makeHandle(file: File): { handle: FileSystemFileHandle; writable: WritableSpy } {
const writable: WritableSpy = {
write: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
const handle = {
getFile: vi.fn(async () => file),
createWritable: vi.fn(async () => writable),
} as unknown as FileSystemFileHandle;
return { handle, writable };
}
function stubWindow(handle?: FileSystemFileHandle) {
const showOpenFilePicker = vi.fn(async () => (handle ? [handle] : []));
const showSaveFilePicker = vi.fn(async () => handle);
const window = {
showOpenFilePicker,
showSaveFilePicker,
} as unknown as Window;
return { window, showOpenFilePicker, showSaveFilePicker };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe(useFileSystemAccess, () => {
it('reports support when the picker APIs exist', () => {
const { window } = stubWindow();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeTruthy();
scope.stop();
});
it('is not supported without the picker APIs', () => {
const window = {} as unknown as Window;
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
expect(fsa!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is not supported and is a no-op under SSR (no window)', async () => {
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window: undefined });
});
expect(fsa!.isSupported.value).toBeFalsy();
await expect(fsa!.open()).resolves.toBeUndefined();
await expect(fsa!.save()).resolves.toBeUndefined();
expect(fsa!.fileName.value).toBe('');
expect(fsa!.fileSize.value).toBe(0);
expect(fsa!.data.value).toBeUndefined();
scope.stop();
});
it('opens a file and reads it as text by default', async () => {
const file = makeFile('content');
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledOnce();
expect(fsa!.data.value).toBe('content');
expect(fsa!.file.value).toBe(file);
expect(fsa!.fileName.value).toBe('demo.txt');
expect(fsa!.fileMIME.value).toBe('text/plain');
expect(fsa!.fileSize.value).toBe(7);
expect(fsa!.fileLastModified.value).toBe(1234);
scope.stop();
});
it('reads as ArrayBuffer when dataType is ArrayBuffer', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<ArrayBuffer>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'ArrayBuffer' });
});
await fsa!.open();
expect(fsa!.data.value).toBeInstanceOf(ArrayBuffer);
expect(file.arrayBuffer).toHaveBeenCalled();
scope.stop();
});
it('exposes the File itself when dataType is Blob', async () => {
const file = makeFile('abc');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<Blob>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Blob' });
});
await fsa!.open();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('passes types and excludeAcceptAllOption to the open picker', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const types = [{ description: 'text', accept: { 'text/plain': ['.txt'] } }];
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, types, excludeAcceptAllOption: true });
});
await fsa!.open();
expect(showOpenFilePicker).toHaveBeenCalledWith({ types, excludeAcceptAllOption: true });
scope.stop();
});
it('lets per-call open options override the defaults', async () => {
const file = makeFile();
const { handle } = makeHandle(file);
const { window, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, excludeAcceptAllOption: false });
});
await fsa!.open({ excludeAcceptAllOption: true });
expect(showOpenFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: true });
scope.stop();
});
it('creates a new empty handle and clears prior data', async () => {
const file = makeFile('');
const { handle } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window });
});
await fsa!.create({ suggestedName: 'new.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'new.txt' });
expect(fsa!.file.value).toBe(file);
scope.stop();
});
it('save writes current data to the existing handle', async () => {
const file = makeFile('original');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
fsa!.data.value = 'edited';
await fsa!.save();
expect(writable.write).toHaveBeenCalledWith('edited');
expect(writable.close).toHaveBeenCalledOnce();
scope.stop();
});
it('save falls back to saveAs when there is no handle', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker, showOpenFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'fresh';
await fsa!.save();
expect(showOpenFilePicker).not.toHaveBeenCalled();
expect(showSaveFilePicker).toHaveBeenCalledOnce();
expect(writable.write).toHaveBeenCalledWith('fresh');
scope.stop();
});
it('saveAs requests a new handle and writes data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window, showSaveFilePicker } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
fsa!.data.value = 'payload';
await fsa!.saveAs({ suggestedName: 'out.txt' });
expect(showSaveFilePicker).toHaveBeenCalledWith({ types: undefined, excludeAcceptAllOption: undefined, suggestedName: 'out.txt' });
expect(writable.write).toHaveBeenCalledWith('payload');
scope.stop();
});
it('does not write when there is no data', async () => {
const file = makeFile('');
const { handle, writable } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.saveAs();
expect(writable.write).not.toHaveBeenCalled();
scope.stop();
});
it('updateData re-reads the current file', async () => {
const file = makeFile('v1');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const scope = effectScope();
let fsa: UseFileSystemAccessReturn<string>;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType: 'Text' });
});
await fsa!.open();
expect(fsa!.data.value).toBe('v1');
(file.text as ReturnType<typeof vi.fn>).mockResolvedValueOnce('v2');
await fsa!.updateData();
expect(fsa!.data.value).toBe('v2');
scope.stop();
});
it('re-reads data when a reactive dataType changes', async () => {
const file = makeFile('reactive');
const { handle } = makeHandle(file);
const { window } = stubWindow(handle);
const dataType = ref<'Text' | 'Blob'>('Text');
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, dataType });
});
await fsa!.open();
expect(fsa!.data.value).toBe('reactive');
dataType.value = 'Blob';
await nextTick();
await Promise.resolve();
expect(fsa!.data.value).toBe(file);
scope.stop();
});
it('routes picker errors to onError instead of throwing', async () => {
const abort = new DOMException('cancelled', 'AbortError');
const window = {
showOpenFilePicker: vi.fn(async () => { throw abort; }),
showSaveFilePicker: vi.fn(async () => { throw abort; }),
} as unknown as Window;
const onError = vi.fn();
const scope = effectScope();
let fsa: UseFileSystemAccessReturn;
scope.run(() => {
fsa = useFileSystemAccess({ window, onError });
});
await expect(fsa!.open()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(abort);
scope.stop();
});
});
@@ -0,0 +1,332 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
/**
* `window.showOpenFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#parameters
*/
export interface FileSystemAccessShowOpenFileOptions {
multiple?: boolean;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* `window.showSaveFilePicker` parameters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#parameters
*/
export interface FileSystemAccessShowSaveFileOptions {
suggestedName?: string;
types?: Array<{
description?: string;
accept: Record<string, string[]>;
}>;
excludeAcceptAllOption?: boolean;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream/write
*/
export interface FileSystemWritableFileStreamWrite {
(data: string | BufferSource | Blob): Promise<void>;
(options: { type: 'write'; position: number; data: string | BufferSource | Blob }): Promise<void>;
(options: { type: 'seek'; position: number }): Promise<void>;
(options: { type: 'truncate'; size: number }): Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemWritableFileStream
*/
export interface FileSystemWritableFileStream extends WritableStream {
write: FileSystemWritableFileStreamWrite;
seek: (position: number) => Promise<void>;
truncate: (size: number) => Promise<void>;
}
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
*/
export interface FileSystemFileHandle {
getFile: () => Promise<File>;
createWritable: () => Promise<FileSystemWritableFileStream>;
}
/**
* A `window` augmented with the File System Access API entry points.
*/
export type FileSystemAccessWindow
= Window & {
showSaveFilePicker: (options: FileSystemAccessShowSaveFileOptions) => Promise<FileSystemFileHandle>;
showOpenFilePicker: (options: FileSystemAccessShowOpenFileOptions) => Promise<FileSystemFileHandle[]>;
};
/**
* The supported file data types.
*/
export type UseFileSystemAccessDataType = 'Text' | 'ArrayBuffer' | 'Blob';
/**
* Picker options shared between open/create/save operations.
*/
export type UseFileSystemAccessCommonOptions
= Pick<FileSystemAccessShowOpenFileOptions, 'types' | 'excludeAcceptAllOption'>;
/**
* Picker options accepted by save-style operations.
*/
export type UseFileSystemAccessShowSaveFileOptions
= Pick<FileSystemAccessShowSaveFileOptions, 'suggestedName'> & UseFileSystemAccessCommonOptions;
export type UseFileSystemAccessOptions
= ConfigurableWindow & UseFileSystemAccessCommonOptions & {
/**
* How the file contents are read into `data`.
*
* @default 'Text'
*/
dataType?: MaybeRefOrGetter<UseFileSystemAccessDataType>;
/**
* Called when a picker or file operation fails.
*
* User-cancelled pickers reject with an `AbortError`; route them here to
* keep them out of the global unhandled-rejection channel.
*
* @default noop
*/
onError?: (error: unknown) => void;
};
export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
/**
* Whether the File System Access API is available.
*/
isSupported: ComputedRef<boolean>;
/**
* The current file contents, read according to `dataType`.
*/
data: ShallowRef<T | undefined>;
/**
* The currently bound `File`, or `undefined` when no file is open.
*/
file: ShallowRef<File | undefined>;
/**
* The current file name (empty string when no file is open).
*/
fileName: ComputedRef<string>;
/**
* The current file MIME type (empty string when no file is open).
*/
fileMIME: ComputedRef<string>;
/**
* The current file size in bytes (`0` when no file is open).
*/
fileSize: ComputedRef<number>;
/**
* The current file's last-modified timestamp (`0` when no file is open).
*/
fileLastModified: ComputedRef<number>;
/**
* Show the open-file picker and load the chosen file.
*/
open: (options?: UseFileSystemAccessCommonOptions) => Promise<void>;
/**
* Show the save-file picker to create a new, empty file handle.
*/
create: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Write `data` back to the current handle (falls back to `saveAs` when none).
*/
save: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Show the save-file picker, then write `data` to the chosen handle.
*/
saveAs: (options?: UseFileSystemAccessShowSaveFileOptions) => Promise<void>;
/**
* Re-read `data` (and metadata) from the current handle.
*/
updateData: () => Promise<void>;
}
/**
* @name useFileSystemAccess
* @category Browser
* @description Create, read, and write local files via the File System Access API.
*
* @param {UseFileSystemAccessOptions} [options={}] Options including `dataType`, `types`, `excludeAcceptAllOption`, and `onError`
* @returns {UseFileSystemAccessReturn} `isSupported`, `data`, `file`, `fileName`, `fileMIME`, `fileSize`, `fileLastModified`, `open`, `create`, `save`, `saveAs`, `updateData`
*
* @example
* const { isSupported, data, open, save } = useFileSystemAccess({ dataType: 'Text' });
* await open();
* data.value += '\nappended';
* await save();
*
* @example
* // Read raw bytes
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
*
* @since 0.0.15
*/
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'ArrayBuffer' }): UseFileSystemAccessReturn<ArrayBuffer>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Blob' }): UseFileSystemAccessReturn<Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(
options: UseFileSystemAccessOptions = {},
): UseFileSystemAccessReturn<string | ArrayBuffer | Blob> {
const {
window: win = defaultWindow,
dataType = 'Text',
types,
excludeAcceptAllOption,
onError = noop,
} = options;
const fsWindow = win as FileSystemAccessWindow | undefined;
const isSupported = useSupported(() => fsWindow && 'showSaveFilePicker' in fsWindow && 'showOpenFilePicker' in fsWindow);
const fileHandle = shallowRef<FileSystemFileHandle>();
const data = shallowRef<string | ArrayBuffer | Blob>();
const file = shallowRef<File>();
const fileName = computed(() => file.value?.name ?? '');
const fileMIME = computed(() => file.value?.type ?? '');
const fileSize = computed(() => file.value?.size ?? 0);
const fileLastModified = computed(() => file.value?.lastModified ?? 0);
// Resolve the picker defaults once per call rather than spreading the full
// options bag (which carries `window`/`onError`) into the native picker.
function pickerDefaults(): UseFileSystemAccessCommonOptions {
return { types, excludeAcceptAllOption };
}
async function updateFile(): Promise<void> {
file.value = await fileHandle.value?.getFile();
}
async function updateData(): Promise<void> {
await updateFile();
const type = toValue(dataType);
if (type === 'Text')
data.value = await file.value?.text();
else if (type === 'ArrayBuffer')
data.value = await file.value?.arrayBuffer();
else if (type === 'Blob')
data.value = file.value;
}
async function writeData(): Promise<void> {
if (!fileHandle.value || data.value === undefined || data.value === null)
return;
const stream = await fileHandle.value.createWritable();
await stream.write(data.value);
await stream.close();
}
async function open(_options: UseFileSystemAccessCommonOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
const [handle] = await fsWindow.showOpenFilePicker({ ...pickerDefaults(), ..._options });
if (!handle)
return;
fileHandle.value = handle;
await updateData();
}
catch (error) {
onError(error);
}
}
async function create(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
data.value = undefined;
await updateData();
}
catch (error) {
onError(error);
}
}
async function saveAs(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value || !fsWindow)
return;
try {
fileHandle.value = await fsWindow.showSaveFilePicker({ ...pickerDefaults(), ..._options });
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
async function save(_options: UseFileSystemAccessShowSaveFileOptions = {}): Promise<void> {
if (!isSupported.value)
return;
if (!fileHandle.value)
return saveAs(_options);
try {
await writeData();
await updateFile();
}
catch (error) {
onError(error);
}
}
// Re-read with the new strategy whenever `dataType` changes; skip the redundant
// initial run since nothing is open yet.
watch(() => toValue(dataType), () => {
if (fileHandle.value)
void updateData();
});
return {
isSupported,
data,
file,
fileName,
fileMIME,
fileSize,
fileLastModified,
open,
create,
save,
saveAs,
updateData,
};
}
@@ -6,7 +6,7 @@ import { defaultDocument } from '@/types';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement'; import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement'; import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener'; import { useEventListener } from '@/composables/browser/useEventListener';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted'; import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
@@ -0,0 +1,254 @@
import { effectScope, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useImage } from '.';
interface FakeImage {
src: string;
srcset: string;
sizes: string;
alt: string;
className: string;
loading: string;
crossOrigin: string | null;
referrerPolicy: string;
width: number;
height: number;
decoding: string;
fetchPriority: string;
isMap: boolean;
useMap: string;
onload: (() => void) | null;
onerror: ((err: unknown) => void) | null;
}
let lastImage: FakeImage | undefined;
// Decides whether the next constructed image "loads" or "errors".
let shouldFail = false;
function createImage(): FakeImage {
const img: FakeImage = {
src: '',
srcset: '',
sizes: '',
alt: '',
className: '',
loading: '',
crossOrigin: null,
referrerPolicy: '',
width: 0,
height: 0,
decoding: '',
fetchPriority: '',
isMap: false,
useMap: '',
onload: null,
onerror: null,
};
lastImage = img;
// Mimic the browser firing load/error asynchronously after src is set.
queueMicrotask(() => {
if (shouldFail)
img.onerror?.(new Error('load-error'));
else
img.onload?.();
});
return img;
}
function createFakeWindow(): Window {
const Image = function Image(): FakeImage {
return createImage();
} as unknown as new () => HTMLImageElement;
return { Image } as unknown as Window;
}
describe(useImage, () => {
let window: Window;
beforeEach(() => {
lastImage = undefined;
shouldFail = false;
window = createFakeWindow();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads an image and exposes the element as state', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window });
});
expect(result.isLoading.value).toBeTruthy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBe(null);
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeTruthy();
expect(result.error.value).toBe(null);
expect(result.state.value).toBe(lastImage);
expect(lastImage?.src).toBe('/cat.png');
scope.stop();
});
it('applies the provided image attributes', async () => {
const scope = effectScope();
scope.run(() => {
useImage(
{
src: '/cat.png',
srcset: '/cat-2x.png 2x',
sizes: '100vw',
alt: 'a cat',
class: 'rounded',
loading: 'lazy',
crossorigin: 'anonymous',
referrerPolicy: 'no-referrer',
width: 320,
height: 240,
decoding: 'async',
fetchPriority: 'high',
ismap: true,
usemap: '#map',
},
{ window },
);
});
await nextTick();
await nextTick();
expect(lastImage).toBeDefined();
expect(lastImage!.src).toBe('/cat.png');
expect(lastImage!.srcset).toBe('/cat-2x.png 2x');
expect(lastImage!.sizes).toBe('100vw');
expect(lastImage!.alt).toBe('a cat');
expect(lastImage!.className).toBe('rounded');
expect(lastImage!.loading).toBe('lazy');
expect(lastImage!.crossOrigin).toBe('anonymous');
expect(lastImage!.referrerPolicy).toBe('no-referrer');
expect(lastImage!.width).toBe(320);
expect(lastImage!.height).toBe(240);
expect(lastImage!.decoding).toBe('async');
expect(lastImage!.fetchPriority).toBe('high');
expect(lastImage!.isMap).toBeTruthy();
expect(lastImage!.useMap).toBe('#map');
scope.stop();
});
it('captures load errors', async () => {
shouldFail = true;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/missing.png' }, { window });
});
await nextTick();
await nextTick();
expect(result.isLoading.value).toBeFalsy();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(result.state.value).toBe(undefined);
scope.stop();
});
it('reloads when a reactive source changes', async () => {
const src = ref('/a.png');
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage(() => ({ src: src.value }), { window });
});
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/a.png');
expect(result.isReady.value).toBeTruthy();
src.value = '/b.png';
await nextTick();
// resetOnExecute clears state and flips loading back on
expect(result.isLoading.value).toBeTruthy();
expect(result.state.value).toBe(undefined);
await nextTick();
await nextTick();
expect(lastImage?.src).toBe('/b.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('does not set up a watcher for a plain options object', async () => {
const watchSpy = vi.fn();
const scope = effectScope();
scope.run(() => {
const result = useImage({ src: '/static.png' }, { window });
// execute is the only thing a reload would call; spy after initial run
result.execute = watchSpy;
});
await nextTick();
await nextTick();
expect(watchSpy).not.toHaveBeenCalled();
scope.stop();
});
it('does not call execute immediately when immediate is false', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window, immediate: false });
});
expect(result.isLoading.value).toBeFalsy();
expect(lastImage).toBeUndefined();
await result.execute();
expect(lastImage?.src).toBe('/cat.png');
expect(result.isReady.value).toBeTruthy();
scope.stop();
});
it('rejects when no Image constructor is available (SSR path)', async () => {
const onError = vi.fn();
// A window without an Image constructor stands in for a non-DOM environment.
const ssrWindow = {} as unknown as Window;
const scope = effectScope();
let result!: ReturnType<typeof useImage>;
scope.run(() => {
result = useImage({ src: '/cat.png' }, { window: ssrWindow, onError });
});
await nextTick();
await nextTick();
expect(result.isReady.value).toBeFalsy();
expect(result.error.value).toBeInstanceOf(Error);
expect(onError).toHaveBeenCalledTimes(1);
scope.stop();
});
});
@@ -0,0 +1,144 @@
import { isRef, toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { isFunction } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useAsyncState } from '@/composables/state/useAsyncState';
import type { UseAsyncStateOptions, UseAsyncStateReturn } from '@/composables/state/useAsyncState';
export interface UseImageOptions {
/** Address of the resource */
src: string;
/** Images to use in different situations, e.g. high-resolution displays, small monitors, etc. */
srcset?: string;
/** Image sizes for different page layouts */
sizes?: string;
/** Image alternative information */
alt?: string;
/** Image classes */
class?: string;
/** Image loading strategy */
loading?: HTMLImageElement['loading'];
/** Image CORS settings */
crossorigin?: string;
/** Referrer policy for fetch — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy */
referrerPolicy?: HTMLImageElement['referrerPolicy'];
/** Image width */
width?: HTMLImageElement['width'];
/** Image height */
height?: HTMLImageElement['height'];
/** Image decoding hint — https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#decoding */
decoding?: HTMLImageElement['decoding'];
/** Relative priority hint for fetching the image */
fetchPriority?: HTMLImageElement['fetchPriority'];
/** Whether the image is a server-side image map */
ismap?: HTMLImageElement['isMap'];
/** Partial URL (starting with #) of an image map associated with the element */
usemap?: HTMLImageElement['useMap'];
}
export interface UseImageAsyncStateOptions
extends UseAsyncStateOptions<true, HTMLImageElement | undefined>, ConfigurableWindow {}
export type UseImageReturn = UseAsyncStateReturn<HTMLImageElement | undefined, any[], true>;
interface LoadImageContext {
window?: Window;
}
function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
// `Image` is a global constructor on `typeof globalThis`, not the `Window` interface.
const ImageCtor = (ctx.window as (Window & typeof globalThis) | undefined)?.Image;
if (!ImageCtor) {
reject(new Error('useImage: no Image constructor available (are you running on the server?)'));
return;
}
const img = new ImageCtor();
const {
src,
srcset,
sizes,
alt,
class: className,
loading,
crossorigin,
referrerPolicy,
width,
height,
decoding,
fetchPriority,
ismap,
usemap,
} = options;
if (alt !== undefined) img.alt = alt;
if (className !== undefined) img.className = className;
if (loading !== undefined) img.loading = loading;
if (crossorigin !== undefined) img.crossOrigin = crossorigin;
if (referrerPolicy !== undefined) img.referrerPolicy = referrerPolicy;
if (width !== undefined) img.width = width;
if (height !== undefined) img.height = height;
if (decoding !== undefined) img.decoding = decoding;
if (fetchPriority !== undefined) img.fetchPriority = fetchPriority;
if (ismap !== undefined) img.isMap = ismap;
if (usemap !== undefined) img.useMap = usemap;
// Setting srcset/sizes before src lets the browser pick the right candidate up-front.
if (sizes !== undefined) img.sizes = sizes;
if (srcset !== undefined) img.srcset = srcset;
img.src = src;
img.onload = () => resolve(img);
img.onerror = reject;
});
}
/**
* @name useImage
* @category Browser
* @description Reactively load an image in the browser; await the result to render it or show a fallback.
*
* @param {MaybeRefOrGetter<UseImageOptions>} options Image attributes (as used on the `<img>` tag); pass a ref/getter to reload reactively
* @param {UseImageAsyncStateOptions} [asyncStateOptions={}] `useAsyncState` options (`delay`, `immediate`, `onError`, …) plus a configurable `window`
* @returns {UseImageReturn} `useAsyncState`-shaped `{ isLoading, isReady, error, state, execute, … }` for an `HTMLImageElement`
*
* @example
* const { isLoading, error, state: image } = useImage({ src: '/cat.png' });
*
* @example
* // Reactive source: reloads whenever `src` changes
* const src = ref('/a.png');
* const { state } = useImage(() => ({ src: src.value, alt: 'photo' }));
*
* @since 0.0.15
*/
export function useImage(
options: MaybeRefOrGetter<UseImageOptions>,
asyncStateOptions: UseImageAsyncStateOptions = {},
): UseImageReturn {
const { window = defaultWindow, ...stateOptions } = asyncStateOptions;
const state = useAsyncState<HTMLImageElement | undefined>(
() => loadImage(toValue(options), { window }),
undefined,
{
resetOnExecute: true,
...stateOptions,
},
);
// A plain (non-ref, non-getter) options object can never change, so we skip
// the watcher entirely — no needless deep traversal on every tick.
if (isRef(options) || isFunction(options)) {
watch(
() => toValue(options),
() => state.execute(stateOptions.delay),
{ deep: true },
);
}
return state;
}
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useLocalFonts } from '.';
import type { FontData, QueryLocalFontsOptions } from '.';
function makeFont(overrides: Partial<FontData> = {}): FontData {
return {
postscriptName: 'Arial-BoldMT',
fullName: 'Arial Bold',
family: 'Arial',
style: 'Bold',
blob: async () => new Blob(),
...overrides,
};
}
/**
* Build a fake `window` exposing `queryLocalFonts`. Passed through options so it
* reaches the import-time-captured `defaultWindow` substitute (see test gotcha).
*/
function createWindow(fonts: FontData[] = [makeFont()]) {
const queryLocalFonts = vi.fn(async (_options?: QueryLocalFontsOptions) => fonts);
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
return { window: win, queryLocalFonts };
}
describe(useLocalFonts, () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('reports supported when queryLocalFonts exists on window', () => {
const scope = effectScope();
const { window } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
expect(result!.isSupported.value).toBeTruthy();
scope.stop();
});
it('reports unsupported when the API is absent', () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: win });
});
expect(result!.isSupported.value).toBeFalsy();
scope.stop();
});
it('is SSR safe when window is undefined', async () => {
const scope = effectScope();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: undefined as unknown as Window });
});
expect(result!.isSupported.value).toBeFalsy();
await expect(result!.query()).resolves.toBeUndefined();
scope.stop();
});
it('defaults fonts to an empty array', () => {
const scope = effectScope();
const { window } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
expect(result!.fonts.value).toEqual([]);
scope.stop();
});
it('populates fonts and returns the list on a successful query', async () => {
const scope = effectScope();
const fonts = [makeFont(), makeFont({ postscriptName: 'Times', fullName: 'Times New Roman' })];
const { window, queryLocalFonts } = createWindow(fonts);
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
const returned = await result!.query();
expect(queryLocalFonts).toHaveBeenCalledTimes(1);
expect(returned).toEqual(fonts);
expect(result!.fonts.value).toEqual(fonts);
scope.stop();
});
it('forwards query options to the native API', async () => {
const scope = effectScope();
const { window, queryLocalFonts } = createWindow();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window });
});
await result!.query({ postscriptNames: ['Arial-BoldMT'] });
expect(queryLocalFonts).toHaveBeenCalledWith({ postscriptNames: ['Arial-BoldMT'] });
scope.stop();
});
it('stores the error and invokes onError when the query rejects', async () => {
const scope = effectScope();
const error = new DOMException('denied', 'NotAllowedError');
const queryLocalFonts = vi.fn(async () => {
throw error;
});
const win = { queryLocalFonts } as unknown as Window & typeof globalThis;
const onError = vi.fn();
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: win, onError });
});
await expect(result!.query()).resolves.toBeUndefined();
expect(onError).toHaveBeenCalledWith(error);
expect(result!.error.value).toBe(error);
expect(result!.fonts.value).toEqual([]);
scope.stop();
});
it('returns undefined and does not call the API when unsupported', async () => {
const scope = effectScope();
const win = {} as unknown as Window & typeof globalThis;
let result: ReturnType<typeof useLocalFonts>;
scope.run(() => {
result = useLocalFonts({ window: win });
});
await expect(result!.query()).resolves.toBeUndefined();
scope.stop();
});
});
@@ -0,0 +1,158 @@
import { shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
/**
* A single font face exposed by the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/FontData).
*/
export interface FontData {
/**
* The PostScript name of the font (e.g. `"Arial-BoldMT"`)
*/
readonly postscriptName: string;
/**
* The full, human-readable name of the font (e.g. `"Arial Bold"`)
*/
readonly fullName: string;
/**
* The font family (e.g. `"Arial"`)
*/
readonly family: string;
/**
* The font style/subfamily (e.g. `"Bold"`)
*/
readonly style: string;
/**
* Resolve the raw font file as a `Blob` (the full SFNT binary)
*/
blob: () => Promise<Blob>;
}
export interface QueryLocalFontsOptions {
/**
* Restrict the results to the fonts whose PostScript names appear in this list.
*/
postscriptNames?: string[];
}
interface WindowWithLocalFonts {
queryLocalFonts: (options?: QueryLocalFontsOptions) => Promise<FontData[]>;
}
export interface UseLocalFontsOptions extends ConfigurableWindow {
/**
* Query the local fonts immediately on mount. The Local Font Access API
* requires the `local-fonts` permission (which may prompt the user), so this
* is disabled by default — call `query()` from a user gesture instead.
*
* @default false
*/
immediate?: boolean;
/**
* Called when a query rejects (e.g. the permission is denied) instead of
* throwing. The same value is also stored in the returned `error` ref.
*
* @default noop
*/
onError?: (error: unknown) => void;
}
export interface UseLocalFontsReturn {
/**
* Whether the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) is supported
*/
isSupported: ComputedRef<boolean>;
/**
* The fonts returned by the most recent successful `query()`
*/
fonts: ShallowRef<FontData[]>;
/**
* The last error thrown by `query()`, or `null`
*/
error: ShallowRef<unknown | null>;
/**
* Enumerate the locally installed fonts. Resolves with the font list, or
* `undefined` when the API is unsupported. Pass `postscriptNames` to filter.
*/
query: (queryOptions?: QueryLocalFontsOptions) => Promise<FontData[] | undefined>;
}
/**
* @name useLocalFonts
* @category Browser
* @description Reactive wrapper around the [Local Font Access API](https://developer.mozilla.org/en-US/docs/Web/API/Local_Font_Access_API) for enumerating the user's locally installed fonts.
*
* @param {UseLocalFontsOptions} [options={}] Options
* @param {boolean} [options.immediate=false] Query immediately on mount (requires the `local-fonts` permission)
* @param {Function} [options.onError=noop] Error callback invoked instead of throwing
* @param {Window} [options.window=defaultWindow] Custom `window` instance
* @returns {UseLocalFontsReturn} `isSupported`, `fonts`, `error`, and `query()`
*
* @example
* const { isSupported, fonts, query } = useLocalFonts();
* // Call from a click handler so the permission prompt is allowed
* async function pickFonts() {
* await query();
* console.log(fonts.value.map(font => font.fullName));
* }
*
* @example
* // Query only specific fonts by PostScript name
* const { fonts, query } = useLocalFonts();
* await query({ postscriptNames: ['Arial-BoldMT'] });
*
* @since 0.0.15
*/
export function useLocalFonts(options: UseLocalFontsOptions = {}): UseLocalFontsReturn {
const {
window = defaultWindow,
immediate = false,
onError = noop,
} = options;
const isSupported = useSupported(() => !!window && 'queryLocalFonts' in window);
const fonts = shallowRef<FontData[]>([]);
const error = shallowRef<unknown | null>(null);
async function query(queryOptions?: QueryLocalFontsOptions): Promise<FontData[] | undefined> {
if (!isSupported.value || !window)
return undefined;
error.value = null;
try {
const result = await (window as unknown as WindowWithLocalFonts).queryLocalFonts(queryOptions);
fonts.value = result;
return result;
}
catch (err) {
error.value = err;
onError(err);
return undefined;
}
}
if (immediate)
tryOnMounted(() => query());
return {
isSupported,
fonts,
error,
query,
};
}
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isFunction, isNumber } from '@robonen/stdlib'; import { isFunction, isNumber } from '@robonen/stdlib';
import { defaultWindow } from '@/types'; import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types'; import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener'; import { useEventListener } from '@/composables/browser/useEventListener';
export interface UseMediaQueryOptions extends ConfigurableWindow { export interface UseMediaQueryOptions extends ConfigurableWindow {
@@ -3,7 +3,7 @@ import type { Ref, ShallowRef } from 'vue';
import { isString } from '@robonen/stdlib'; import { isString } from '@robonen/stdlib';
import { defaultNavigator } from '@/types'; import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener'; import { useEventListener } from '@/composables/browser/useEventListener';
/** /**
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredContrast } from '.';
function stubMatchMedia(matching: string): void {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes(matching),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
}
describe(usePreferredContrast, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it.each([
['more'],
['less'],
['custom'],
['no-preference'],
] as const)('resolves the "%s" contrast preference', async (preference) => {
stubMatchMedia(`prefers-contrast: ${preference}`);
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe(preference);
scope.stop();
});
it('prioritizes "more" over the other preferences', async () => {
// Match everything; the resolution order must win.
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: true,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe('more');
scope.stop();
});
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
// matchMedia is undefined (stubbed in beforeEach).
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast();
});
await nextTick();
expect(contrast!.value).toBe('no-preference');
scope.stop();
});
it('honors a custom ssrContrast fallback when unsupported', async () => {
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast({ ssrContrast: 'more' });
});
await nextTick();
expect(contrast!.value).toBe('more');
scope.stop();
});
it('returns "no-preference" with no window provided', async () => {
const scope = effectScope();
let contrast: ReturnType<typeof usePreferredContrast>;
scope.run(() => {
contrast = usePreferredContrast({ window: undefined });
});
await nextTick();
expect(contrast!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,61 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
export type ContrastType
= 'more' | 'less' | 'custom' | 'no-preference';
export interface UsePreferredContrastOptions extends UseMediaQueryOptions {
/**
* The contrast preference assumed during SSR (and the first client render),
* before `window.matchMedia` is available, to avoid hydration flicker.
*
* @default 'no-preference'
*/
ssrContrast?: ContrastType;
}
/**
* @name usePreferredContrast
* @category Browser
* @description Reactive `prefers-contrast` media query, resolving to the user's
* preferred contrast level. SSR-safe with an optional SSR fallback value.
*
* @param {UsePreferredContrastOptions} [options={}] Options (custom `window`, `ssrContrast`)
* @returns {ComputedRef<ContrastType>} Readonly ref of the preferred contrast: `'more' | 'less' | 'custom' | 'no-preference'`
*
* @example
* const contrast = usePreferredContrast();
*
* @example
* // Provide an SSR fallback to avoid hydration flicker
* const contrast = usePreferredContrast({ ssrContrast: 'more' });
*
* @since 0.0.15
*/
export function usePreferredContrast(
options: UsePreferredContrastOptions = {},
): ComputedRef<ContrastType> {
const { ssrContrast = 'no-preference', ...mediaOptions } = options;
const isMore = useMediaQuery('(prefers-contrast: more)', mediaOptions);
const isLess = useMediaQuery('(prefers-contrast: less)', mediaOptions);
const isCustom = useMediaQuery('(prefers-contrast: custom)', mediaOptions);
const isNoPreference = useMediaQuery('(prefers-contrast: no-preference)', mediaOptions);
return computed<ContrastType>(() => {
if (isMore.value)
return 'more';
if (isLess.value)
return 'less';
if (isCustom.value)
return 'custom';
// When no `prefers-contrast` query matches we're either on a browser that
// does not report a preference, or rendering on the server. Distinguish the
// explicit `no-preference` match from the unknown/SSR case via the fallback.
if (isNoPreference.value)
return 'no-preference';
return ssrContrast;
});
}
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { usePreferredLanguages } from '.';
import type * as Types from '@/types';
type Listener = (event: Event) => void;
interface StubWindow {
navigator: { languages: readonly string[] };
addEventListener: (type: string, cb: Listener, options?: unknown) => void;
removeEventListener: (type: string, cb: Listener) => void;
dispatch: () => void;
listenerCount: () => number;
lastOptions: () => unknown;
}
/**
* Build a minimal stub `window` whose `navigator.languages` is mutable and that
* records its `languagechange` listeners so we can simulate the browser event.
*/
function makeWindow(initial: string[]): StubWindow {
const listeners = new Set<Listener>();
let languages: readonly string[] = initial;
let captured: unknown;
const stub: StubWindow = {
navigator: {
get languages() {
return languages;
},
},
addEventListener(type, cb, options) {
if (type === 'languagechange') {
listeners.add(cb);
captured = options;
}
},
removeEventListener(type, cb) {
if (type === 'languagechange') listeners.delete(cb);
},
dispatch() {
for (const cb of listeners) cb(new Event('languagechange'));
},
listenerCount: () => listeners.size,
lastOptions: () => captured,
};
// Allow tests to mutate languages before dispatching.
Object.defineProperty(stub, 'setLanguages', {
value: (next: string[]) => { languages = next; },
});
return stub;
}
describe(usePreferredLanguages, () => {
it('reads the initial navigator.languages', () => {
const win = makeWindow(['en-US', 'en']);
const scope = effectScope();
let langs: ReturnType<typeof usePreferredLanguages>;
scope.run(() => {
langs = usePreferredLanguages({ window: win as unknown as Window });
});
expect(langs!.value).toEqual(['en-US', 'en']);
scope.stop();
});
it('updates on languagechange', () => {
const win = makeWindow(['en']);
const scope = effectScope();
let langs: ReturnType<typeof usePreferredLanguages>;
scope.run(() => {
langs = usePreferredLanguages({ window: win as unknown as Window });
});
(win as unknown as { setLanguages: (n: string[]) => void }).setLanguages(['fr-FR', 'fr', 'en']);
win.dispatch();
expect(langs!.value).toEqual(['fr-FR', 'fr', 'en']);
scope.stop();
});
it('registers the listener with a passive option', () => {
const win = makeWindow(['en']);
const scope = effectScope();
scope.run(() => {
usePreferredLanguages({ window: win as unknown as Window });
});
expect(win.listenerCount()).toBe(1);
expect(win.lastOptions()).toEqual({ passive: true });
scope.stop();
});
it('removes the listener when the scope is disposed', () => {
const win = makeWindow(['en']);
const scope = effectScope();
scope.run(() => {
usePreferredLanguages({ window: win as unknown as Window });
});
expect(win.listenerCount()).toBe(1);
scope.stop();
expect(win.listenerCount()).toBe(0);
});
it('falls back to ["en"] when no window is available (SSR)', async () => {
// `defaultWindow` is import-time captured, so override it via a module mock
// to simulate a server environment where no window exists.
vi.resetModules();
vi.doMock('@/types', async () => {
const actual = await vi.importActual<typeof Types>('@/types');
return { ...actual, defaultWindow: undefined };
});
const { usePreferredLanguages: ssrUsePreferredLanguages } = await import('.');
const scope = effectScope();
let langs: ReturnType<typeof ssrUsePreferredLanguages>;
scope.run(() => {
langs = ssrUsePreferredLanguages();
});
expect(langs!.value).toEqual(['en']);
scope.stop();
vi.doUnmock('@/types');
vi.resetModules();
});
});
@@ -0,0 +1,43 @@
import type { ShallowRef } from 'vue';
import { shallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
/**
* @name usePreferredLanguages
* @category Browser
* @description Reactive `navigator.languages`. Tracks the user's preferred languages and
* updates automatically whenever the browser emits a `languagechange` event.
*
* Falls back to `['en']` during SSR or when no `window` is available, so the returned
* value is always a non-empty array.
*
* @param {ConfigurableWindow} [options={}] Options
* @returns {ShallowRef<readonly string[]>} Reactive list of the user's preferred languages
*
* @example
* const languages = usePreferredLanguages();
* // -> ['en-US', 'en', 'fr']
*
* @example
* // Pass a custom window (e.g. an iframe)
* const languages = usePreferredLanguages({ window: iframe.contentWindow });
*
* @since 0.0.15
*/
export function usePreferredLanguages(options: ConfigurableWindow = {}): ShallowRef<readonly string[]> {
const { window = defaultWindow } = options;
if (!window)
return shallowRef<readonly string[]>(['en']);
const navigator = window.navigator;
const value = shallowRef<readonly string[]>(navigator.languages);
useEventListener(window, 'languagechange', () => {
value.value = navigator.languages;
}, { passive: true });
return value;
}
@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredReducedMotion } from '.';
describe(usePreferredReducedMotion, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('resolves to "reduce" when the query matches', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes('reduce'),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('reduce');
scope.stop();
});
it('resolves to "no-preference" when the query does not match', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: false,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
it('reacts to media query changes', async () => {
const listeners = new Set<(event: { matches: boolean }) => void>();
let currentMatches = false;
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
get matches() {
return currentMatches;
},
media,
addEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.add(handler),
removeEventListener: (_: string, handler: (event: { matches: boolean }) => void) => listeners.delete(handler),
})));
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
currentMatches = true;
listeners.forEach(handler => handler({ matches: true }));
await nextTick();
expect(motion!.value).toBe('reduce');
scope.stop();
});
it('falls back to "no-preference" when matchMedia is unsupported (SSR)', async () => {
// matchMedia is left undefined from beforeEach.
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion();
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
it('returns "no-preference" when window is explicitly undefined (SSR)', async () => {
const scope = effectScope();
let motion: ReturnType<typeof usePreferredReducedMotion>;
scope.run(() => {
motion = usePreferredReducedMotion({ window: undefined });
});
await nextTick();
expect(motion!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,41 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
import type { UseMediaQueryOptions } from '@/composables/browser/useMediaQuery';
export type ReducedMotionType
= | 'reduce'
| 'no-preference';
export type UsePreferredReducedMotionOptions = UseMediaQueryOptions;
export type UsePreferredReducedMotionReturn = ComputedRef<ReducedMotionType>;
/**
* @name usePreferredReducedMotion
* @category Browser
* @description Reactive `prefers-reduced-motion` media query, resolving to
* `'reduce'` when the user requests reduced motion and `'no-preference'`
* otherwise. SSR-safe via {@link useMediaQuery}.
*
* @param {UsePreferredReducedMotionOptions} [options={}] Options (custom `window`)
* @returns {UsePreferredReducedMotionReturn} Readonly ref of the current motion preference
*
* @example
* const motion = usePreferredReducedMotion();
* // motion.value === 'reduce' | 'no-preference'
*
* @example
* watchEffect(() => {
* transitionDuration.value = motion.value === 'reduce' ? 0 : 200;
* });
*
* @since 0.0.15
*/
export function usePreferredReducedMotion(
options: UsePreferredReducedMotionOptions = {},
): UsePreferredReducedMotionReturn {
const isReduced = useMediaQuery('(prefers-reduced-motion: reduce)', options);
return computed<ReducedMotionType>(() => isReduced.value ? 'reduce' : 'no-preference');
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { usePreferredReducedTransparency } from '.';
describe(usePreferredReducedTransparency, () => {
beforeEach(() => vi.stubGlobal('matchMedia', undefined));
afterEach(() => vi.unstubAllGlobals());
it('resolves to "reduce" when the media query matches', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: media.includes('reduce'),
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency();
});
await nextTick();
expect(transparency!.value).toBe('reduce');
scope.stop();
});
it('resolves to "no-preference" when the media query does not match', async () => {
vi.stubGlobal('matchMedia', vi.fn((media: string) => ({
matches: false,
media,
addEventListener: () => {},
removeEventListener: () => {},
})));
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency();
});
await nextTick();
expect(transparency!.value).toBe('no-preference');
scope.stop();
});
it('defaults to "no-preference" when matchMedia is unsupported (SSR)', async () => {
const scope = effectScope();
let transparency: ReturnType<typeof usePreferredReducedTransparency>;
scope.run(() => {
transparency = usePreferredReducedTransparency({ window: undefined });
});
await nextTick();
expect(transparency!.value).toBe('no-preference');
scope.stop();
});
});
@@ -0,0 +1,30 @@
import { computed } from 'vue';
import type { ComputedRef } from 'vue';
import type { ConfigurableWindow } from '@/types';
import { useMediaQuery } from '@/composables/browser/useMediaQuery';
export type ReducedTransparencyType
= 'reduce' | 'no-preference';
/**
* @name usePreferredReducedTransparency
* @category Browser
* @description Reactive `prefers-reduced-transparency` media query, resolving to
* `'reduce'` or `'no-preference'`. SSR-safe (defaults to `'no-preference'`).
*
* @param {ConfigurableWindow} [options={}] Options (custom `window`)
* @returns {ComputedRef<ReducedTransparencyType>} Readonly ref of the user's transparency preference
*
* @example
* const transparency = usePreferredReducedTransparency();
* // transparency.value === 'reduce' | 'no-preference'
*
* @since 0.0.15
*/
export function usePreferredReducedTransparency(
options: ConfigurableWindow = {},
): ComputedRef<ReducedTransparencyType> {
const isReduced = useMediaQuery('(prefers-reduced-transparency: reduce)', options);
return computed<ReducedTransparencyType>(() => isReduced.value ? 'reduce' : 'no-preference');
}
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope } from 'vue';
import { useScriptTag } from '.';
const SRC = 'https://example.com/sdk.js';
function flushAll(): void {
// Fire `load` on every script tag currently in the head so awaited promises resolve.
document.head.querySelectorAll('script').forEach((el) => {
el.setAttribute('data-loaded', 'true');
el.dispatchEvent(new Event('load'));
});
}
describe(useScriptTag, () => {
afterEach(() => {
document.head.querySelectorAll('script').forEach(el => el.remove());
vi.unstubAllGlobals();
});
it('injects a script tag with the configured attributes', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, {
manual: true,
type: 'module',
defer: true,
crossOrigin: 'anonymous',
referrerPolicy: 'no-referrer',
noModule: true,
nonce: 'abc123',
attrs: { 'data-test': 'yes', id: 'my-script' },
});
});
const promise = result.load();
const el = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`)!;
expect(el).toBeInstanceOf(HTMLScriptElement);
expect(el.type).toBe('module');
expect(el.defer).toBeTruthy();
expect(el.crossOrigin).toBe('anonymous');
expect(el.referrerPolicy).toBe('no-referrer');
expect(el.noModule).toBeTruthy();
expect(el.nonce).toBe('abc123');
expect(el.getAttribute('data-test')).toBe('yes');
expect(el.getAttribute('id')).toBe('my-script');
expect(el.parentElement).toBe(document.head);
flushAll();
await promise;
expect(result.scriptTag.value).toBe(el);
scope.stop();
});
it('defaults to async and text/javascript', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
const promise = result.load(false);
const el = await promise as HTMLScriptElement;
expect(el.async).toBeTruthy();
expect(el.type).toBe('text/javascript');
scope.stop();
});
it('calls onLoaded and resolves with the element when the script loads', async () => {
const onLoaded = vi.fn();
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, onLoaded, { manual: true });
});
const promise = result.load();
flushAll();
const el = await promise;
expect(onLoaded).toHaveBeenCalledTimes(1);
expect(onLoaded).toHaveBeenCalledWith(el);
expect(result.scriptTag.value).toBe(el);
scope.stop();
});
it('resolves immediately when waitForScriptLoad is false', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
const el = await result.load(false);
expect(el).toBeInstanceOf(HTMLScriptElement);
expect(result.scriptTag.value).toBe(el);
scope.stop();
});
it('de-duplicates concurrent load calls into a single promise', () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
const a = result.load();
const b = result.load();
expect(a).toBe(b);
expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1);
scope.stop();
});
it('reuses an existing already-loaded script tag', async () => {
// Pre-existing, already-loaded tag in the DOM.
const existing = document.createElement('script');
existing.src = SRC;
existing.setAttribute('data-loaded', 'true');
document.head.appendChild(existing);
const onLoaded = vi.fn();
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, onLoaded, { manual: true });
});
const el = await result.load();
expect(el).toBe(existing);
expect(result.scriptTag.value).toBe(existing);
// Only the original tag is present (no duplicate appended).
expect(document.querySelectorAll(`script[src="${SRC}"]`)).toHaveLength(1);
// onLoaded should not fire for the short-circuit path.
expect(onLoaded).not.toHaveBeenCalled();
scope.stop();
});
it('unload removes the tag and clears the ref', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
await result.load(false);
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
result.unload();
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
expect(result.scriptTag.value).toBeNull();
scope.stop();
});
it('unload resets the load promise so the script can be re-loaded', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
const first = result.load();
result.unload();
const second = result.load();
expect(first).not.toBe(second);
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
scope.stop();
});
it('removes the tag on scope dispose (non-manual)', () => {
const scope = effectScope();
scope.run(() => {
useScriptTag(SRC);
});
// Outside a component instance, tryOnMounted runs synchronously -> tag injected.
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
scope.stop();
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
});
it('manual mode does not auto-load on mount', () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
expect(result.scriptTag.value).toBeNull();
scope.stop();
});
it('immediate: false defers loading until load() is called', () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { immediate: false });
});
expect(document.querySelector(`script[src="${SRC}"]`)).toBeNull();
result.load(false);
expect(document.querySelector(`script[src="${SRC}"]`)).not.toBeNull();
scope.stop();
});
it('rejects when the script errors', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
scope.run(() => {
result = useScriptTag(SRC, undefined, { manual: true });
});
const promise = result.load();
const el = document.querySelector<HTMLScriptElement>(`script[src="${SRC}"]`)!;
el.dispatchEvent(new Event('error'));
await expect(promise).rejects.toBeInstanceOf(Event);
scope.stop();
});
it('SSR / unsupported path: resolves false and never throws when document is absent', async () => {
// Note: `defaultDocument` is import-time captured and the destructuring default
// `= defaultDocument` only triggers for `undefined`, so we force a falsy document
// (mirroring a real SSR environment where `defaultDocument` itself is undefined).
const scope = effectScope();
let result!: ReturnType<typeof useScriptTag>;
expect(() => {
scope.run(() => {
result = useScriptTag(SRC, undefined, { document: null as unknown as Document, manual: true });
});
}).not.toThrow();
// eslint-disable-next-line vitest/prefer-to-be-falsy -- assert the exact boolean `false`, not any falsy value
await expect(result.load()).resolves.toBe(false);
expect(() => result.unload()).not.toThrow();
expect(result.scriptTag.value).toBeNull();
scope.stop();
});
it('returns the documented shape', () => {
const result = useScriptTag(SRC, undefined, { manual: true });
expect(result.scriptTag.value).toBeNull();
expect(typeof result.load).toBe('function');
expect(typeof result.unload).toBe('function');
});
});
@@ -0,0 +1,254 @@
import { shallowRef, toValue } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import { noop } from '@robonen/stdlib';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { useEventListener } from '@/composables/browser/useEventListener';
export type ScriptReferrerPolicy
= | 'no-referrer'
| 'no-referrer-when-downgrade'
| 'origin'
| 'origin-when-cross-origin'
| 'same-origin'
| 'strict-origin'
| 'strict-origin-when-cross-origin'
| 'unsafe-url';
export interface UseScriptTagOptions extends ConfigurableDocument {
/**
* Load the script immediately on mount.
*
* @default true
*/
immediate?: boolean;
/**
* Add the `async` attribute to the script tag.
*
* @default true
*/
async?: boolean;
/**
* Script `type` attribute.
*
* @default 'text/javascript'
*/
type?: string;
/**
* Take manual control of the timing of loading and unloading. When `true`,
* the script is neither loaded on mount nor unloaded on scope dispose — call
* `load()` / `unload()` yourself.
*
* @default false
*/
manual?: boolean;
/**
* CORS setting for the script tag.
*/
crossOrigin?: 'anonymous' | 'use-credentials';
/**
* Referrer policy for the script request.
*/
referrerPolicy?: ScriptReferrerPolicy;
/**
* Add the `nomodule` attribute, so the script is skipped by browsers that
* support ES modules.
*/
noModule?: boolean;
/**
* Add the `defer` attribute to the script tag.
*/
defer?: boolean;
/**
* Nonce value for CSP (Content Security Policy).
*
* @default undefined
*/
nonce?: string;
/**
* Custom attributes applied to the script tag via `setAttribute`.
*
* @default {}
*/
attrs?: Record<string, string>;
}
export interface UseScriptTagReturn {
/**
* Reactive reference to the underlying `<script>` element, or `null` when not loaded.
*/
scriptTag: ShallowRef<HTMLScriptElement | null>;
/**
* Load the script into the document `<head>`.
*
* @param waitForScriptLoad Resolve once the `load` event fires (default `true`)
* rather than immediately after appending the element to the DOM.
*/
load: (waitForScriptLoad?: boolean) => Promise<HTMLScriptElement | boolean>;
/**
* Remove the `<script>` element from the document `<head>`.
*/
unload: () => void;
}
/**
* @name useScriptTag
* @category Browser
* @description Dynamically inject and manage a `<script>` tag. The returned
* `load`/`unload` controls append the element to the document `<head>` (reusing
* an existing tag with the same `src`) and resolve once the script has loaded.
* Loading is de-duplicated, listeners are passive, and everything is SSR-safe.
*
* @param {MaybeRefOrGetter<string>} src Reactive source URL for the script
* @param {(el: HTMLScriptElement) => void} [onLoaded=noop] Called when the script finishes loading
* @param {UseScriptTagOptions} [options={}] Options
* @param {boolean} [options.immediate=true] Load the script on mount
* @param {boolean} [options.async=true] Add the `async` attribute
* @param {string} [options.type='text/javascript'] Script `type` attribute
* @param {boolean} [options.manual=false] Take manual control of load/unload timing
* @param {'anonymous' | 'use-credentials'} [options.crossOrigin] CORS setting
* @param {ScriptReferrerPolicy} [options.referrerPolicy] Referrer policy
* @param {boolean} [options.noModule] Add the `nomodule` attribute
* @param {boolean} [options.defer] Add the `defer` attribute
* @param {string} [options.nonce] CSP nonce value
* @param {Record<string, string>} [options.attrs={}] Custom attributes for the tag
* @param {Document} [options.document=defaultDocument] Custom document instance
* @returns {UseScriptTagReturn} `{ scriptTag, load, unload }`
*
* @example
* const { scriptTag, load, unload } = useScriptTag(
* 'https://example.com/sdk.js',
* (el) => console.log('loaded', el),
* );
*
* @example
* // Manual control
* const { load, unload } = useScriptTag('https://example.com/a.js', undefined, { manual: true });
* await load();
* unload();
*
* @since 0.0.15
*/
export function useScriptTag(
src: MaybeRefOrGetter<string>,
onLoaded: (el: HTMLScriptElement) => void = noop,
options: UseScriptTagOptions = {},
): UseScriptTagReturn {
const {
immediate = true,
manual = false,
type = 'text/javascript',
async = true,
crossOrigin,
referrerPolicy,
noModule,
defer,
document = defaultDocument,
attrs = {},
nonce,
} = options;
const scriptTag = shallowRef<HTMLScriptElement | null>(null);
let _promise: Promise<HTMLScriptElement | boolean> | null = null;
const loadScript = (waitForScriptLoad: boolean): Promise<HTMLScriptElement | boolean> =>
new Promise<HTMLScriptElement | boolean>((resolve, reject) => {
const resolveWithElement = (el: HTMLScriptElement): void => {
scriptTag.value = el;
resolve(el);
};
// SSR / unsupported: no document to append to.
if (!document) {
resolve(false);
return;
}
const url = toValue(src);
let shouldAppend = false;
let el = document.querySelector<HTMLScriptElement>(`script[src="${url}"]`);
if (!el) {
el = document.createElement('script');
el.type = type;
el.async = async;
el.src = url;
if (defer) el.defer = defer;
if (crossOrigin) el.crossOrigin = crossOrigin;
if (noModule) el.noModule = noModule;
if (referrerPolicy) el.referrerPolicy = referrerPolicy;
if (nonce) el.nonce = nonce;
for (const [name, value] of Object.entries(attrs))
el.setAttribute(name, value);
shouldAppend = true;
}
else if (el.hasAttribute('data-loaded')) {
// Already loaded — short-circuit.
resolveWithElement(el);
return;
}
const listenerOptions = { passive: true } as const;
useEventListener(el, 'error', event => reject(event), listenerOptions);
useEventListener(el, 'abort', event => reject(event), listenerOptions);
useEventListener(el, 'load', () => {
el!.setAttribute('data-loaded', 'true');
onLoaded(el!);
resolveWithElement(el!);
}, listenerOptions);
if (shouldAppend)
el = document.head.appendChild(el);
if (!waitForScriptLoad)
resolveWithElement(el);
});
const load = (waitForScriptLoad = true): Promise<HTMLScriptElement | boolean> => {
if (!_promise)
_promise = loadScript(waitForScriptLoad);
return _promise;
};
const unload = (): void => {
if (!document) return;
_promise = null;
scriptTag.value = null;
const el = document.querySelector<HTMLScriptElement>(`script[src="${toValue(src)}"]`);
if (el)
document.head.removeChild(el);
};
if (immediate && !manual)
tryOnMounted(load);
if (!manual)
tryOnScopeDispose(unload);
return {
scriptTag,
load,
unload,
};
}
@@ -2,7 +2,7 @@ import { toValue } from 'vue';
import type { MaybeRefOrGetter, Ref } from 'vue'; import type { MaybeRefOrGetter, Ref } from 'vue';
import { defaultNavigator } from '@/types'; import { defaultNavigator } from '@/types';
import type { ConfigurableNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
export interface UseShareOptions { export interface UseShareOptions {
/** /**
@@ -0,0 +1,176 @@
import { afterEach, describe, expect, it } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useStyleTag } from '.';
describe(useStyleTag, () => {
afterEach(() => {
// Clean up any leaked style tags between tests.
document.head.querySelectorAll('style[id^="vuetools_styletag_"]').forEach(el => el.remove());
document.head.querySelectorAll('style#shared, style#manual, style#mediaq, style#csp').forEach(el => el.remove());
});
it('injects a style tag with the given css', () => {
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag('body { color: red }');
});
const el = document.getElementById(result.id) as HTMLStyleElement;
expect(el).toBeInstanceOf(HTMLStyleElement);
expect(el.textContent).toBe('body { color: red }');
expect(el.parentElement).toBe(document.head);
expect(result.isLoaded.value).toBeTruthy();
scope.stop();
});
it('updates the stylesheet when css ref changes', async () => {
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag('a { color: red }');
});
result.css.value = 'a { color: blue }';
await nextTick();
const el = document.getElementById(result.id) as HTMLStyleElement;
expect(el.textContent).toBe('a { color: blue }');
scope.stop();
});
it('accepts a ref / getter as the css source', async () => {
const scope = effectScope();
const source = ref('p { margin: 0 }');
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag(source);
});
const el = document.getElementById(result.id) as HTMLStyleElement;
expect(el.textContent).toBe('p { margin: 0 }');
source.value = 'p { margin: 1px }';
await nextTick();
expect(el.textContent).toBe('p { margin: 1px }');
scope.stop();
});
it('applies media and nonce attributes', () => {
const scope = effectScope();
scope.run(() => {
useStyleTag('.q {}', { id: 'mediaq', media: 'screen and (max-width: 600px)' });
useStyleTag('.c {}', { id: 'csp', nonce: 'abc123' });
});
const mediaEl = document.getElementById('mediaq') as HTMLStyleElement;
const cspEl = document.getElementById('csp') as HTMLStyleElement;
expect(mediaEl.media).toBe('screen and (max-width: 600px)');
expect(cspEl.nonce).toBe('abc123');
scope.stop();
});
it('removes the tag on scope dispose', () => {
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag('.x {}');
});
expect(document.getElementById(result.id)).not.toBeNull();
scope.stop();
expect(document.getElementById(result.id)).toBeNull();
});
it('reference-counts a shared id and only removes when last is unloaded', () => {
const a = useStyleTag('.shared { color: red }', { id: 'shared', manual: true });
const b = useStyleTag('.shared { color: red }', { id: 'shared', manual: true });
a.load();
b.load();
expect(document.getElementById('shared')).not.toBeNull();
a.unload();
// Still present — b holds a reference.
expect(document.getElementById('shared')).not.toBeNull();
expect(b.isLoaded.value).toBeTruthy();
b.unload();
expect(document.getElementById('shared')).toBeNull();
});
it('manual mode does not auto-load', () => {
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag('.manual {}', { id: 'manual', manual: true });
});
expect(result.isLoaded.value).toBeFalsy();
expect(document.getElementById('manual')).toBeNull();
result.load();
expect(result.isLoaded.value).toBeTruthy();
expect(document.getElementById('manual')).not.toBeNull();
result.unload();
scope.stop();
});
it('load is idempotent (does not double reference-count)', () => {
const result = useStyleTag('.dup {}', { manual: true });
result.load();
result.load();
result.load();
// A single unload removes it because the ref count was incremented only once.
result.unload();
expect(document.getElementById(result.id)).toBeNull();
});
it('immediate: false defers loading until load() is called outside a component', () => {
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
scope.run(() => {
result = useStyleTag('.imm {}', { immediate: false });
});
// Without a component instance, tryOnMounted runs synchronously; immediate: false
// means load is not registered, so nothing is injected yet.
expect(result.isLoaded.value).toBeFalsy();
result.load();
expect(result.isLoaded.value).toBeTruthy();
scope.stop();
});
it('is SSR-safe / unsupported path: never throws when document is absent', () => {
// Passing `document: undefined` exercises the configurable-document fallback
// path; in a real SSR environment `defaultDocument` is itself undefined and
// `load`/`unload` early-return. We assert the construction never throws and a
// stable id is still produced.
const scope = effectScope();
let result!: ReturnType<typeof useStyleTag>;
expect(() => {
scope.run(() => {
result = useStyleTag('.ssr {}', { document: undefined });
});
}).not.toThrow();
expect(result.id).toMatch(/^vuetools_styletag_/);
scope.stop();
});
it('returns the documented shape', () => {
const result = useStyleTag('.shape {}', { manual: true });
expect(typeof result.id).toBe('string');
expect(typeof result.css.value).toBe('string');
expect(typeof result.load).toBe('function');
expect(typeof result.unload).toBe('function');
expect(typeof result.isLoaded.value).toBe('boolean');
});
});
@@ -0,0 +1,185 @@
import { shallowReadonly, shallowRef, toRef, watch } from 'vue';
import type { MaybeRefOrGetter, ShallowRef } from 'vue';
import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseStyleTagOptions extends ConfigurableDocument {
/**
* Media query applied to the `<style>` element (e.g. `'screen and (max-width: 600px)'`).
*/
media?: string;
/**
* Load the style immediately on mount.
*
* @default true
*/
immediate?: boolean;
/**
* Take manual control of the timing of loading and unloading. When `true`,
* the style is neither loaded on mount nor unloaded on scope dispose — call
* `load()` / `unload()` yourself.
*
* @default false
*/
manual?: boolean;
/**
* DOM `id` of the `<style>` tag. Sharing an `id` across instances reuses the
* same element and reference-counts it.
*
* @default auto-incremented (`vuetools_styletag_N`)
*/
id?: string;
/**
* Nonce value for CSP (Content Security Policy).
*
* @default undefined
*/
nonce?: string;
}
export interface UseStyleTagReturn {
/**
* DOM `id` of the injected `<style>` tag.
*/
id: string;
/**
* Reactive, writable CSS text content of the tag.
*/
css: ShallowRef<string>;
/**
* Inject the `<style>` tag into `<head>` (or reuse an existing one).
*/
load: () => void;
/**
* Remove the `<style>` tag from `<head>` when no other instance references it.
*/
unload: () => void;
/**
* Whether the tag is currently loaded.
*/
isLoaded: Readonly<ShallowRef<boolean>>;
}
let _id = 0;
const _refCount = new WeakMap<HTMLStyleElement, number>();
/**
* @name useStyleTag
* @category Browser
* @description Inject a reactive `<style>` tag into the document `<head>`. The
* CSS is a writable ref — assigning to it updates the live stylesheet. Multiple
* instances sharing an `id` reuse a single element via reference counting, and
* everything is SSR-safe.
*
* @param {MaybeRefOrGetter<string>} css Reactive CSS source for the tag
* @param {UseStyleTagOptions} [options={}] Options
* @param {string} [options.media] Media query applied to the tag
* @param {boolean} [options.immediate=true] Load the style on mount
* @param {boolean} [options.manual=false] Take manual control of load/unload timing
* @param {string} [options.id] DOM id of the tag (default auto-incremented)
* @param {string} [options.nonce] CSP nonce value
* @param {Document} [options.document=defaultDocument] Custom document instance
* @returns {UseStyleTagReturn} `{ id, css, load, unload, isLoaded }`
*
* @example
* const { css, isLoaded } = useStyleTag('body { color: red }');
* css.value = 'body { color: blue }';
*
* @example
* // Manual control
* const { load, unload } = useStyleTag('.a { color: red }', { manual: true });
* load();
* unload();
*
* @since 0.0.15
*/
export function useStyleTag(
css: MaybeRefOrGetter<string>,
options: UseStyleTagOptions = {},
): UseStyleTagReturn {
const {
document = defaultDocument,
immediate = true,
manual = false,
id = `vuetools_styletag_${++_id}`,
media,
nonce,
} = options;
const cssRef = toRef(css);
const isLoaded = shallowRef(false);
let stop = (): void => {};
const load = (): void => {
if (!document) return;
const el = (document.getElementById(id) ?? document.createElement('style')) as HTMLStyleElement;
if (!el.isConnected) {
el.id = id;
if (nonce) el.nonce = nonce;
if (media) el.media = media;
document.head.appendChild(el);
}
if (isLoaded.value) return;
_refCount.set(el, (_refCount.get(el) ?? 0) + 1);
stop = watch(
cssRef,
(value) => {
el.textContent = value;
},
{ immediate: true },
);
isLoaded.value = true;
};
const unload = (): void => {
if (!document || !isLoaded.value) return;
stop();
stop = (): void => {};
const el = document.getElementById(id) as HTMLStyleElement | null;
if (el) {
const count = (_refCount.get(el) ?? 1) - 1;
if (count <= 0) {
_refCount.delete(el);
document.head.removeChild(el);
}
else {
_refCount.set(el, count);
}
}
isLoaded.value = false;
};
if (immediate && !manual)
tryOnMounted(load);
if (!manual)
tryOnScopeDispose(unload);
return {
id,
css: cssRef,
load,
unload,
isLoaded: shallowReadonly(isLoaded),
};
}
@@ -1,6 +1,6 @@
import { readonly, ref } from 'vue'; import { readonly, ref } from 'vue';
import type { ComputedRef, DeepReadonly, Ref } from 'vue'; import type { ComputedRef, DeepReadonly, Ref } from 'vue';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseTabLeaderOptions { export interface UseTabLeaderOptions {
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { useTextareaAutosize } from '.';
let instances: Array<{ cb: ResizeObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = [];
class StubResizeObserver {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
cb: ResizeObserverCallback;
constructor(cb: ResizeObserverCallback) {
this.cb = cb;
instances.push(this);
}
}
// jsdom returns 0 for scrollHeight; force a deterministic value
function makeTextarea(scrollHeight = 42): HTMLTextAreaElement {
const el = document.createElement('textarea');
Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => scrollHeight });
return el;
}
// A window whose rAF runs synchronously so resize callbacks are observable
function syncWindow(): Window {
return {
requestAnimationFrame: (fn: FrameRequestCallback) => {
fn(0);
return 1;
},
} as unknown as Window;
}
describe(useTextareaAutosize, () => {
beforeEach(() => {
instances = [];
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
it('returns textarea, input, and triggerResize', () => {
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize();
});
expect(result.textarea.value).toBeUndefined();
expect(result.input.value).toBe('');
expect(typeof result.triggerResize).toBe('function');
scope.stop();
});
it('seeds input and element from options', () => {
const el = makeTextarea();
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el, input: 'hello' });
});
expect(result.textarea.value).toBe(el);
expect(result.input.value).toBe('hello');
scope.stop();
});
it('sizes the textarea height to its scrollHeight on resize', () => {
const el = makeTextarea(80);
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el });
});
result.triggerResize();
expect(el.style.height).toBe('80px');
scope.stop();
});
it('clamps the height to maxHeight', () => {
const el = makeTextarea(500);
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el, maxHeight: 200 });
});
result.triggerResize();
expect(el.style.height).toBe('200px');
scope.stop();
});
it('applies the configured styleProp', () => {
const el = makeTextarea(64);
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el, styleProp: 'minHeight' });
});
result.triggerResize();
expect(el.style.minHeight).toBe('64px');
scope.stop();
});
it('sizes the styleTarget instead of the textarea when provided', () => {
const el = makeTextarea(70);
const wrapper = document.createElement('div');
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el, styleTarget: wrapper });
});
result.triggerResize();
expect(wrapper.style.height).toBe('70px');
expect(el.style.height).toBe('');
scope.stop();
});
it('resizes on textarea input event', async () => {
const el = makeTextarea(90);
const scope = effectScope();
scope.run(() => useTextareaAutosize({ element: el }));
await nextTick();
el.style.height = '';
el.dispatchEvent(new Event('input'));
expect(el.style.height).toBe('90px');
scope.stop();
});
it('resizes when the bound input ref changes', async () => {
const el = makeTextarea(55);
const input = ref('a');
const scope = effectScope();
scope.run(() => useTextareaAutosize({ element: el, input }));
await nextTick();
el.style.height = '';
input.value = 'a longer value';
// post-flush watch schedules nextTick(triggerResize): flush both
await nextTick();
await nextTick();
expect(el.style.height).toBe('55px');
scope.stop();
});
it('invokes onResize when the content height changes', async () => {
const el = makeTextarea(40);
const onResize = vi.fn();
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ element: el, onResize });
});
await nextTick();
onResize.mockClear();
result.triggerResize();
// height unchanged -> no extra onResize
expect(onResize).not.toHaveBeenCalled();
scope.stop();
});
it('observes the textarea via ResizeObserver and resizes on width change', async () => {
const el = makeTextarea(60);
const scope = effectScope();
scope.run(() => useTextareaAutosize({ element: el, window: syncWindow() }));
await nextTick();
expect(instances).toHaveLength(1);
expect(instances[0]!.observe).toHaveBeenCalledWith(el, undefined);
el.style.height = '';
instances[0]!.cb([{ contentRect: { width: 123 } } as ResizeObserverEntry], instances[0] as unknown as ResizeObserver);
expect(el.style.height).toBe('60px');
scope.stop();
});
it('ignores ResizeObserver callbacks with an unchanged width', async () => {
const el = makeTextarea(60);
const scope = effectScope();
scope.run(() => useTextareaAutosize({ element: el, window: syncWindow() }));
await nextTick();
const entry = [{ contentRect: { width: 200 } } as ResizeObserverEntry];
instances[0]!.cb(entry, instances[0] as unknown as ResizeObserver);
el.style.height = '';
// same width again -> should not re-size
instances[0]!.cb(entry, instances[0] as unknown as ResizeObserver);
expect(el.style.height).toBe('');
scope.stop();
});
it('resizes on a custom watch source', async () => {
const el = makeTextarea(33);
const dep = ref(0);
const scope = effectScope();
scope.run(() => useTextareaAutosize({ element: el, watch: dep }));
await nextTick();
el.style.height = '';
dep.value = 1;
await nextTick();
expect(el.style.height).toBe('33px');
scope.stop();
});
it('triggerResize is a no-op without a textarea (SSR / unmounted path)', () => {
const scope = effectScope();
let result!: ReturnType<typeof useTextareaAutosize>;
scope.run(() => {
result = useTextareaAutosize({ window: undefined });
});
// No textarea bound: must not throw
expect(() => result.triggerResize()).not.toThrow();
expect(instances).toHaveLength(0);
scope.stop();
});
it('does not throw when constructed with no DOM globals supplied via options', () => {
const scope = effectScope();
expect(() => {
scope.run(() => useTextareaAutosize({ window: undefined }));
}).not.toThrow();
scope.stop();
});
});
@@ -0,0 +1,178 @@
import type { MaybeRef, MultiWatchSources, Ref, WatchSource } from 'vue';
import { nextTick, toRef, toValue, watch } from 'vue';
import type { VoidFunction } from '@robonen/stdlib';
import { noop } from '@robonen/stdlib';
import type { ConfigurableWindow } from '@/types';
import { defaultWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { useResizeObserver } from '@/composables/elements/useResizeObserver';
export interface UseTextareaAutosizeOptions extends ConfigurableWindow {
/**
* The textarea element to autosize. May also be bound via the returned `textarea` ref
*/
element?: MaybeRef<HTMLTextAreaElement | undefined | null>;
/**
* The textarea content. May also be bound via the returned `input` ref
*/
input?: MaybeRef<string>;
/**
* Cap the resulting height (in pixels). Beyond this the textarea scrolls
*/
maxHeight?: number;
/**
* Additional reactive sources that should trigger a resize when they change
*/
watch?: WatchSource | MultiWatchSources;
/**
* Invoked after each resize once the textarea height settles
*
* @default noop
*/
onResize?: VoidFunction;
/**
* Apply the computed height to this element instead of the textarea itself.
* Useful when the textarea is wrapped (e.g. for a CSS grid auto-grow trick)
*/
styleTarget?: MaybeRef<HTMLElement | undefined | null>;
/**
* Which style property carries the computed height
*
* @default 'height'
*/
styleProp?: 'height' | 'minHeight';
}
export interface UseTextareaAutosizeReturn {
/**
* The textarea element being autosized. Bind it with `ref` in the template
*/
textarea: Ref<HTMLTextAreaElement | undefined | null>;
/**
* Two-way bound textarea content
*/
input: Ref<string>;
/**
* Force a resize on demand (e.g. after a programmatic value change)
*/
triggerResize: VoidFunction;
}
/**
* @name useTextareaAutosize
* @category Browser
* @description Auto-resizes a `<textarea>` to fit its content. Reacts to user
* input, programmatic content changes, and element resize. Reuses
* `useEventListener` for a passive, auto-cleaned `input` listener and
* `useResizeObserver` to re-measure when the textarea's width changes (so
* reflowed text is re-fitted). SSR safe.
*
* @param {UseTextareaAutosizeOptions} [options={}] Options
* @returns {UseTextareaAutosizeReturn} `textarea`, `input`, and `triggerResize`
*
* @example
* const { textarea, input } = useTextareaAutosize();
* // <textarea ref="textarea" v-model="input" />
*
* @example
* const { textarea, input, triggerResize } = useTextareaAutosize({ maxHeight: 320 });
*
* @since 0.0.15
*/
export function useTextareaAutosize(options: UseTextareaAutosizeOptions = {}): UseTextareaAutosizeReturn {
const {
window = defaultWindow,
maxHeight,
styleProp = 'height',
onResize = noop,
} = options;
const textarea = toRef(options.element) as Ref<HTMLTextAreaElement | undefined | null>;
const input = toRef(options.input ?? '') as Ref<string>;
// Cached, non-reactive — only used to skip redundant resizes on height-only
// ResizeObserver callbacks (we resize on width changes, not our own height edits)
let lastWidth = -1;
let lastScrollHeight = -1;
const triggerResize: VoidFunction = () => {
const el = textarea.value;
if (!el)
return;
const target = (toValue(options.styleTarget) ?? el) as HTMLElement;
// Collapse to a single line so scrollHeight reports the true content height
el.style[styleProp] = '1px';
const scrollHeight = el.scrollHeight;
const height = maxHeight !== undefined ? Math.min(scrollHeight, maxHeight) : scrollHeight;
if (target === el)
el.style[styleProp] = `${height}px`;
else {
// Restore the textarea to its natural value and size the wrapper instead
el.style[styleProp] = '';
target.style[styleProp] = `${height}px`;
}
if (scrollHeight !== lastScrollHeight) {
lastScrollHeight = scrollHeight;
onResize();
}
};
// React to programmatic content changes and to the element binding itself.
// flush 'post' guarantees the DOM (and v-model'd value) is updated before we measure
watch(
[input, textarea],
() => nextTick(triggerResize),
{ immediate: true, flush: 'post' },
);
// React to direct user typing without waiting for the bound model to round-trip.
// Passive: we never call preventDefault here
useEventListener(textarea, 'input', triggerResize, { passive: true });
// React to width changes (font/box reflow). Ignore pure height deltas — those
// are usually our own edits and would otherwise loop
useResizeObserver(textarea, (entries) => {
const entry = entries[0];
if (!entry)
return;
const width = entry.contentRect.width;
if (width === lastWidth)
return;
const run = () => {
lastWidth = width;
triggerResize();
};
if (window && typeof window.requestAnimationFrame === 'function')
window.requestAnimationFrame(run);
else
run();
});
if (options.watch)
watch(options.watch, triggerResize, { immediate: true, deep: true });
return {
textarea,
input,
triggerResize,
};
}
@@ -3,7 +3,7 @@ import type { ComputedRef, MaybeRef, MaybeRefOrGetter, Ref } from 'vue';
import { isFunction, isString } from '@robonen/stdlib'; import { isFunction, isString } from '@robonen/stdlib';
import { defaultDocument } from '@/types'; import { defaultDocument } from '@/types';
import type { ConfigurableDocument } from '@/types'; import type { ConfigurableDocument } from '@/types';
import { useMutationObserver } from '@/composables/browser/useMutationObserver'; import { useMutationObserver } from '@/composables/elements/useMutationObserver';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
export interface UseTitleOptionsBase extends ConfigurableDocument { export interface UseTitleOptionsBase extends ConfigurableDocument {
@@ -0,0 +1,293 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useUrlSearchParams } from '.';
import type { UrlSearchParamsMode, UseUrlSearchParamsOptions } from '.';
function mockWindow(href: string): Window {
const url = new URL(href);
const location = {
get pathname() {
return url.pathname;
},
get search() {
return url.search;
},
get hash() {
return url.hash;
},
};
const listeners = new Map<string, Set<(ev: any) => void>>();
const win = {
location,
document: { title: 'test' },
history: {
state: null as any,
replaceState(state: any, _title: string, nextUrl: string) {
this.state = state;
const resolved = new URL(nextUrl, url.origin);
url.pathname = resolved.pathname;
url.search = resolved.search;
url.hash = resolved.hash;
},
pushState(state: any, _title: string, nextUrl: string) {
this.state = state;
const resolved = new URL(nextUrl, url.origin);
url.pathname = resolved.pathname;
url.search = resolved.search;
url.hash = resolved.hash;
},
},
addEventListener(type: string, listener: (ev: any) => void) {
if (!listeners.has(type))
listeners.set(type, new Set());
listeners.get(type)!.add(listener);
},
removeEventListener(type: string, listener: (ev: any) => void) {
listeners.get(type)?.delete(listener);
},
dispatch(type: string) {
listeners.get(type)?.forEach(fn => fn({ type }));
},
// Allow tests to simulate external URL changes
navigate(nextHref: string) {
const resolved = new URL(nextHref, url.origin);
url.pathname = resolved.pathname;
url.search = resolved.search;
url.hash = resolved.hash;
},
currentSearch() {
return url.search;
},
currentHash() {
return url.hash;
},
};
return win as unknown as Window & {
dispatch: (type: string) => void;
navigate: (href: string) => void;
currentSearch: () => string;
currentHash: () => string;
};
}
function run<T extends Record<string, any>>(
mode: UrlSearchParamsMode,
options: UseUrlSearchParamsOptions<T>,
): { scope: ReturnType<typeof effectScope>; params: T } {
const scope = effectScope();
let params!: T;
scope.run(() => {
params = useUrlSearchParams<T>(mode, options);
});
return { scope, params };
}
describe(useUrlSearchParams, () => {
let scopes: Array<ReturnType<typeof effectScope>> = [];
beforeEach(() => {
scopes = [];
});
afterEach(() => {
scopes.forEach(scope => scope.stop());
});
function track(scope: ReturnType<typeof effectScope>) {
scopes.push(scope);
return scope;
}
it('reads existing history params', () => {
const window = mockWindow('http://localhost/?foo=bar&count=1');
const { scope, params } = run('history', { window });
track(scope);
expect(params.foo).toBe('bar');
expect(params.count).toBe('1');
});
it('decodes repeated keys into arrays', () => {
const window = mockWindow('http://localhost/?id=1&id=2&id=3');
const { scope, params } = run<{ id: string[] }>('history', { window });
track(scope);
expect(params.id).toEqual(['1', '2', '3']);
});
it('writes back to the URL when the state mutates', async () => {
const window = mockWindow('http://localhost/') as any;
const { scope, params } = run('history', { window });
track(scope);
params.foo = 'bar';
await nextTick();
expect(window.currentSearch()).toBe('?foo=bar');
});
it('writes array values as repeated keys', async () => {
const window = mockWindow('http://localhost/') as any;
const { scope, params } = run<{ id: string[] }>('history', { window });
track(scope);
params.id = ['1', '2'];
await nextTick();
expect(window.currentSearch()).toBe('?id=1&id=2');
});
it('seeds initialValue when the URL has no params', () => {
const window = mockWindow('http://localhost/');
const { scope, params } = run('history', { window, initialValue: { tab: 'home' } });
track(scope);
expect(params.tab).toBe('home');
});
it('ignores initialValue when the URL already has params', () => {
const window = mockWindow('http://localhost/?tab=settings');
const { scope, params } = run('history', { window, initialValue: { tab: 'home' } });
track(scope);
expect(params.tab).toBe('settings');
});
it('removes nullish values by default', async () => {
const window = mockWindow('http://localhost/?foo=bar') as any;
const { scope, params } = run<{ foo: any }>('history', { window });
track(scope);
params.foo = null;
await nextTick();
expect(window.currentSearch()).toBe('');
});
it('keeps falsy values unless removeFalsyValues is set', async () => {
const windowKeep = mockWindow('http://localhost/') as any;
const kept = run<{ foo: any }>('history', { window: windowKeep });
track(kept.scope);
kept.params.foo = '';
await nextTick();
expect(windowKeep.currentSearch()).toBe('?foo=');
const windowDrop = mockWindow('http://localhost/') as any;
const dropped = run<{ foo: any }>('history', { window: windowDrop, removeFalsyValues: true });
track(dropped.scope);
dropped.params.foo = '';
await nextTick();
expect(windowDrop.currentSearch()).toBe('');
});
it('syncs state when popstate fires', () => {
const window = mockWindow('http://localhost/?foo=bar') as any;
const { scope, params } = run('history', { window });
track(scope);
expect(params.foo).toBe('bar');
window.navigate('http://localhost/?foo=baz&extra=1');
window.dispatch('popstate');
expect(params.foo).toBe('baz');
expect(params.extra).toBe('1');
});
it('does not write back when write is disabled', () => {
const window = mockWindow('http://localhost/?foo=bar') as any;
const { scope, params } = run('history', { window, write: false });
track(scope);
window.navigate('http://localhost/?foo=changed');
window.dispatch('popstate');
// write:false short-circuits onChanged, so state is not updated from the event
expect(params.foo).toBe('bar');
});
it('reads hash mode params', () => {
const window = mockWindow('http://localhost/#/page?foo=bar');
const { scope, params } = run('hash', { window });
track(scope);
expect(params.foo).toBe('bar');
});
it('writes hash mode params while preserving the hash path', async () => {
const window = mockWindow('http://localhost/#/page') as any;
const { scope, params } = run('hash', { window });
track(scope);
params.foo = 'bar';
await nextTick();
expect(window.currentHash()).toBe('#/page?foo=bar');
});
it('reads hash-params mode', () => {
const window = mockWindow('http://localhost/#foo=bar&count=2');
const { scope, params } = run('hash-params', { window });
track(scope);
expect(params.foo).toBe('bar');
expect(params.count).toBe('2');
});
it('writes hash-params mode', async () => {
const window = mockWindow('http://localhost/') as any;
const { scope, params } = run('hash-params', { window });
track(scope);
params.foo = 'bar';
await nextTick();
expect(window.currentHash()).toBe('#foo=bar');
});
it('uses pushState when writeMode is push', async () => {
const window = mockWindow('http://localhost/') as any;
let pushed = 0;
const originalPush = window.history.pushState.bind(window.history);
window.history.pushState = function (state: any, title: string, url: string) {
pushed++;
return originalPush(state, title, url);
};
const { scope, params } = run('history', { window, writeMode: 'push' });
track(scope);
params.foo = 'bar';
await nextTick();
expect(pushed).toBe(1);
expect(window.currentSearch()).toBe('?foo=bar');
});
it('applies a custom stringify', async () => {
const window = mockWindow('http://localhost/') as any;
const { scope, params } = run('history', {
window,
stringify: p => p.toString().toUpperCase(),
});
track(scope);
params.foo = 'bar';
await nextTick();
expect(window.currentSearch()).toBe('?FOO=BAR');
});
it('removes deleted keys from the URL', async () => {
const window = mockWindow('http://localhost/?a=1&b=2') as any;
const { scope, params } = run<{ a?: string; b?: string }>('history', { window });
track(scope);
delete params.b;
await nextTick();
expect(window.currentSearch()).toBe('?a=1');
});
it('returns a reactive seeded object when window is unavailable (SSR)', () => {
const scope = effectScope();
track(scope);
let params!: { foo: string };
scope.run(() => {
params = useUrlSearchParams<{ foo: string }>('history', {
window: undefined,
initialValue: { foo: 'bar' },
});
});
expect(params.foo).toBe('bar');
// Mutations are still reactive even without a window
params.foo = 'baz';
expect(params.foo).toBe('baz');
});
});
@@ -0,0 +1,212 @@
import { nextTick, reactive } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useEventListener } from '@/composables/browser/useEventListener';
import { watchPausable } from '@/composables/watch/watchPausable';
export type UrlSearchParamsMode = 'history' | 'hash' | 'hash-params';
export type UrlParams = Record<string, string[] | string>;
export interface UseUrlSearchParamsOptions<T> extends ConfigurableWindow {
/**
* Remove keys whose value is `null` or `undefined` from the URL.
*
* @default true
*/
removeNullishValues?: boolean;
/**
* Remove keys whose value is falsy (`''`, `0`, `false`, `null`, `undefined`) from the URL.
*
* @default false
*/
removeFalsyValues?: boolean;
/**
* Seed values applied when the URL has no params on initialization.
*
* @default {}
*/
initialValue?: T;
/**
* Write changes back to the URL when the reactive state mutates.
*
* @default true
*/
write?: boolean;
/**
* History method used when writing back to the URL.
* - `replace` rewrites the current entry via `history.replaceState`
* - `push` adds a new entry via `history.pushState`
*
* @default 'replace'
*/
writeMode?: 'replace' | 'push';
/**
* Serialize the params into a query string. Override to control ordering or encoding.
*
* @default (params) => params.toString()
*/
stringify?: (params: URLSearchParams) => string;
}
export type UseUrlSearchParamsReturn<T extends Record<string, any> = UrlParams>
= T;
/**
* @name useUrlSearchParams
* @category Browser
* @description Reactive `URLSearchParams` exposed as a plain reactive object. Reads from and
* (optionally) writes back to the URL using the `history`, `hash`, or `hash-params` location source.
* Listens for `popstate`/`hashchange` with passive listeners and pauses its own writer while syncing
* to avoid feedback loops. SSR-safe: returns the seeded reactive object when no `window` is available.
*
* @param {UrlSearchParamsMode} [mode='history'] Where the params live: `history` (`?a=1`), `hash` (`#/path?a=1`), or `hash-params` (`#a=1`)
* @param {UseUrlSearchParamsOptions<T>} [options={}] Behavior options
* @returns {UseUrlSearchParamsReturn<T>} A reactive object mirroring the URL params; mutate it to update the URL
*
* @example
* const params = useUrlSearchParams('history');
* params.foo = 'bar'; // -> ?foo=bar
*
* @example
* // Hash mode with seeded values and push-history writes
* const params = useUrlSearchParams('hash', {
* initialValue: { tab: 'home' },
* writeMode: 'push',
* });
*
* @example
* // Repeated keys decode to arrays
* const params = useUrlSearchParams<{ ids: string[] }>('history');
* params.ids = ['1', '2']; // -> ?ids=1&ids=2
*
* @since 0.0.15
*/
export function useUrlSearchParams<T extends Record<string, any> = UrlParams>(
mode: UrlSearchParamsMode = 'history',
options: UseUrlSearchParamsOptions<T> = {},
): UseUrlSearchParamsReturn<T> {
const {
initialValue = {} as T,
removeNullishValues = true,
removeFalsyValues = false,
write: enableWrite = true,
writeMode = 'replace',
window = defaultWindow,
stringify = (params: URLSearchParams) => params.toString(),
} = options;
if (!window)
return reactive({ ...initialValue }) as UseUrlSearchParamsReturn<T>;
const state = reactive<Record<string, any>>({});
const getRawParams = (): string => {
if (mode === 'history')
return window.location.search || '';
if (mode === 'hash') {
const hash = window.location.hash || '';
const index = hash.indexOf('?');
return index > 0 ? hash.slice(index) : '';
}
return (window.location.hash || '').replace(/^#/, '');
};
const constructQuery = (params: URLSearchParams): string => {
const stringified = stringify(params);
if (mode === 'history')
return `${stringified ? `?${stringified}` : ''}${window.location.hash || ''}`;
if (mode === 'hash-params')
return `${window.location.search || ''}${stringified ? `#${stringified}` : ''}`;
const hash = window.location.hash || '#';
const index = hash.indexOf('?');
if (index > 0)
return `${window.location.search || ''}${hash.slice(0, index)}${stringified ? `?${stringified}` : ''}`;
return `${window.location.search || ''}${hash}${stringified ? `?${stringified}` : ''}`;
};
const read = (): URLSearchParams => new URLSearchParams(getRawParams());
const updateState = (params: URLSearchParams): void => {
const unusedKeys = new Set(Object.keys(state));
for (const key of params.keys()) {
const valuesForKey = params.getAll(key);
state[key] = valuesForKey.length > 1 ? valuesForKey : (params.get(key) || '');
unusedKeys.delete(key);
}
for (const key of unusedKeys)
delete state[key];
};
const serializeState = (): URLSearchParams => {
const params = new URLSearchParams('');
for (const key of Object.keys(state)) {
const value = state[key];
if (Array.isArray(value))
value.forEach(item => params.append(key, item));
else if (removeNullishValues && (value === null || value === undefined))
params.delete(key);
else if (removeFalsyValues && !value)
params.delete(key);
else
params.set(key, value);
}
return params;
};
const write = (params: URLSearchParams, shouldUpdateState: boolean, shouldWriteHistory = true): void => {
pause();
if (shouldUpdateState)
updateState(params);
const url = window.location.pathname + constructQuery(params);
if (writeMode === 'replace')
window.history.replaceState(window.history.state, window.document.title, url);
else if (shouldWriteHistory)
window.history.pushState(window.history.state, window.document.title, url);
nextTick(() => resume());
};
const { pause, resume } = watchPausable(
state,
() => write(serializeState(), false),
{ deep: true, initialState: 'paused' },
);
const onChanged = (): void => {
if (!enableWrite)
return;
write(read(), true, false);
};
const listenerOptions = { passive: true };
useEventListener(window, 'popstate', onChanged, listenerOptions);
if (mode !== 'history')
useEventListener(window, 'hashchange', onChanged, listenerOptions);
const initial = read();
if (!initial.keys().next().done)
updateState(initial);
else
Object.assign(state, initialValue);
resume();
return state as UseUrlSearchParamsReturn<T>;
}
@@ -3,9 +3,9 @@ import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import type { Arrayable } from '@robonen/stdlib'; import type { Arrayable } from '@robonen/stdlib';
import type { ConfigurableNavigator } from '@/types'; import type { ConfigurableNavigator } from '@/types';
import { defaultNavigator } from '@/types'; import { defaultNavigator } from '@/types';
import { useSupported } from '@/composables/browser/useSupported'; import { useSupported } from '@/composables/utilities/useSupported';
import { useIntervalFn } from '@/composables/browser/useIntervalFn'; import { useIntervalFn } from '@/composables/animation/useIntervalFn';
import type { UseIntervalFnReturn } from '@/composables/browser/useIntervalFn'; import type { UseIntervalFnReturn } from '@/composables/animation/useIntervalFn';
export interface UseVibrateOptions extends ConfigurableNavigator { export interface UseVibrateOptions extends ConfigurableNavigator {
/** /**
@@ -85,7 +85,7 @@ export function useVibrate(options: UseVibrateOptions = {}): UseVibrateReturn {
navigator = defaultNavigator, navigator = defaultNavigator,
} = options; } = options;
const isSupported = useSupported(() => typeof navigator !== 'undefined' && !!navigator && 'vibrate' in navigator); const isSupported = useSupported(() => navigator !== undefined && !!navigator && 'vibrate' in navigator);
const patternRef = toRef(pattern); const patternRef = toRef(pattern);
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick } from 'vue';
import { useWakeLock } from '.';
import type { WakeLockSentinel, WakeLockType } from '.';
afterEach(() => {
vi.unstubAllGlobals();
});
interface FakeSentinel extends WakeLockSentinel {
dispatchRelease: () => void;
}
function createFakeSentinel(type: WakeLockType): FakeSentinel {
const target = new EventTarget();
const sentinel = target as unknown as FakeSentinel;
sentinel.type = type;
sentinel.released = false;
sentinel.release = vi.fn(async () => {
sentinel.released = true;
});
sentinel.dispatchRelease = () => {
sentinel.released = true;
target.dispatchEvent(new Event('release'));
};
return sentinel;
}
interface FakeEnv {
navigator: Navigator;
request: ReturnType<typeof vi.fn>;
sentinels: FakeSentinel[];
}
function createFakeNavigator(): FakeEnv {
const sentinels: FakeSentinel[] = [];
const request = vi.fn(async (type: WakeLockType) => {
const sentinel = createFakeSentinel(type);
sentinels.push(sentinel);
return sentinel;
});
const navigator = { wakeLock: { request } } as unknown as Navigator;
return { navigator, request, sentinels };
}
function createFakeDocument(state: DocumentVisibilityState = 'visible') {
const target = new EventTarget();
const doc = {
visibilityState: state,
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
} as unknown as Document & { visibilityState: DocumentVisibilityState };
const setVisibility = (next: DocumentVisibilityState) => {
doc.visibilityState = next;
target.dispatchEvent(new Event('visibilitychange'));
};
return { document: doc, setVisibility };
}
describe(useWakeLock, () => {
it('reports supported when navigator.wakeLock exists', () => {
const { navigator } = createFakeNavigator();
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator });
});
expect(api!.isSupported.value).toBeTruthy();
expect(api!.isActive.value).toBeFalsy();
expect(api!.sentinel.value).toBeNull();
scope.stop();
});
it('reports unsupported when wakeLock is missing', () => {
const navigator = {} as Navigator;
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator });
});
expect(api!.isSupported.value).toBeFalsy();
scope.stop();
});
it('requests a wake lock and becomes active', async () => {
const { navigator, request } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
expect(request).toHaveBeenCalledWith('screen');
expect(api!.sentinel.value).not.toBeNull();
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('releases the wake lock and clears the sentinel', async () => {
const { navigator, sentinels } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
await api!.release();
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
expect(api!.sentinel.value).toBeNull();
expect(api!.isActive.value).toBeFalsy();
scope.stop();
});
it('forceRequest releases the previous lock before acquiring a new one', async () => {
const { navigator, sentinels, request } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.forceRequest('screen');
await api!.forceRequest('screen');
expect(request).toHaveBeenCalledTimes(2);
expect(sentinels[0]!.release).toHaveBeenCalledTimes(1);
expect(api!.sentinel.value).toBe(sentinels[1]);
scope.stop();
});
it('defers the request while hidden and re-acquires on visible', async () => {
const { navigator, request } = createFakeNavigator();
const { document, setVisibility } = createFakeDocument('hidden');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
// Hidden: nothing acquired yet
expect(request).not.toHaveBeenCalled();
expect(api!.sentinel.value).toBeNull();
setVisibility('visible');
await nextTick();
await nextTick();
expect(request).toHaveBeenCalledWith('screen');
expect(api!.sentinel.value).not.toBeNull();
expect(api!.isActive.value).toBeTruthy();
scope.stop();
});
it('re-acquires after the browser releases the lock on the next visible transition', async () => {
const { navigator, sentinels, request } = createFakeNavigator();
const { document, setVisibility } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
expect(request).toHaveBeenCalledTimes(1);
// Browser auto-releases the sentinel (e.g. the tab was hidden)
setVisibility('hidden');
await nextTick();
sentinels[0]!.dispatchRelease();
await nextTick();
// Becoming visible again should re-acquire
setVisibility('visible');
await nextTick();
await nextTick();
expect(request).toHaveBeenCalledTimes(2);
scope.stop();
});
it('is SSR-safe with no navigator/document', async () => {
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator: undefined, document: undefined });
});
expect(api!.isSupported.value).toBeFalsy();
// Should not throw on the unsupported path
await api!.request('screen');
expect(api!.sentinel.value).toBeNull();
expect(api!.isActive.value).toBeFalsy();
await api!.release();
scope.stop();
});
it('releases on scope dispose', async () => {
const { navigator, sentinels } = createFakeNavigator();
const { document } = createFakeDocument('visible');
const scope = effectScope();
let api: ReturnType<typeof useWakeLock>;
scope.run(() => {
api = useWakeLock({ navigator, document });
});
await api!.request('screen');
scope.stop();
await nextTick();
expect(sentinels[0]!.release).toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More