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
@@ -0,0 +1,293 @@
import { shallowRef } from 'vue';
import type { ComputedRef, ShallowRef } from 'vue';
import { defaultWindow } from '@/types';
import type { ConfigurableWindow } from '@/types';
import { useSupported } from '@/composables/utilities/useSupported';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
import { createEventHook } from '@/composables/utilities/createEventHook';
import type { EventHookOn } from '@/composables/utilities/createEventHook';
/**
* Per-notification options mirroring the `Notification` constructor's
* `NotificationOptions`, plus the `title` argument folded in for convenience.
*/
export interface WebNotificationOptions {
/**
* The title shown at the top of the notification.
*/
title?: string;
/**
* The body text displayed below the title.
*/
body?: string;
/**
* Text direction.
*
* @default 'auto'
*/
dir?: 'auto' | 'ltr' | 'rtl';
/**
* BCP 47 language tag for the notification's content.
*/
lang?: string;
/**
* An identifying tag. Notifications sharing a tag replace one another.
*/
tag?: string;
/**
* URL of an icon to display.
*/
icon?: string;
/**
* Whether to re-alert the user when a notification replaces an older one
* with the same `tag`.
*
* @default false
*/
renotify?: boolean;
/**
* Keep the notification active until the user interacts with it instead of
* auto-dismissing.
*
* @default false
*/
requireInteraction?: boolean;
/**
* Whether the notification is silent (no sound/vibration).
*
* @default false
*/
silent?: boolean;
/**
* Vibration pattern (in milliseconds) for devices that support it.
*/
vibrate?: number[];
}
export interface UseWebNotificationOptions extends WebNotificationOptions, ConfigurableWindow {
/**
* Request permission on mount (when supported and not yet granted).
*
* @default true
*/
requestPermissions?: boolean;
}
export interface UseWebNotificationReturn {
/**
* Whether the Notification API is supported in the current environment.
*/
isSupported: ComputedRef<boolean>;
/**
* The currently displayed `Notification`, or `null` when none is shown.
*/
notification: ShallowRef<Notification | null>;
/**
* Whether notification permission has been granted.
*/
permissionGranted: ShallowRef<boolean>;
/**
* Display a notification, optionally overriding the default options for this
* call. Resolves with the created `Notification`, or `undefined` when
* unsupported or permission is not granted.
*/
show: (overrides?: WebNotificationOptions) => Promise<Notification | undefined>;
/**
* Close the currently displayed notification (if any).
*/
close: () => void;
/**
* Request permission if it has not yet been granted (and was not denied).
* Resolves with the resulting granted state, or `undefined` when unsupported.
*/
ensurePermissionGranted: () => Promise<boolean | undefined>;
/**
* Register a listener fired when the notification is clicked.
*/
onClick: EventHookOn<Event>;
/**
* Register a listener fired when the notification is shown.
*/
onShow: EventHookOn<Event>;
/**
* Register a listener fired when the notification errors.
*/
onError: EventHookOn<Event>;
/**
* Register a listener fired when the notification is closed.
*/
onClose: EventHookOn<Event>;
}
/**
* @name useWebNotification
* @category Browser
* @description Reactive, SSR-safe wrapper around the Web Notification API with
* permission handling and `onClick`/`onShow`/`onError`/`onClose` event hooks.
*
* @param {UseWebNotificationOptions} [options={}] Default notification options plus behavior flags
* @returns {UseWebNotificationReturn} `isSupported`, `notification`, `permissionGranted`, `show`, `close`, `ensurePermissionGranted`, and the `onClick`/`onShow`/`onError`/`onClose` hooks
*
* @example
* const { show, isSupported, onClick } = useWebNotification({
* title: 'Hello',
* body: 'You have a new message',
* icon: '/icon.png',
* });
* onClick(() => console.log('clicked'));
* if (isSupported.value)
* show();
*
* @example
* // Override options per call
* const { show } = useWebNotification();
* show({ title: 'Override', body: 'Per-call body' });
*
* @since 0.0.15
*/
export function useWebNotification(
options: UseWebNotificationOptions = {},
): UseWebNotificationReturn {
const {
window = defaultWindow,
requestPermissions = true,
} = options;
// The constructor lives on the resolved window so tests/iframes can supply
// their own; falling back to a globally available `Notification` keeps the
// common (no-window-override) case working.
const getNotificationCtor = (): typeof Notification | undefined => {
if (window && 'Notification' in window)
return (window as Window & { Notification: typeof Notification }).Notification;
return undefined;
};
const isSupported = useSupported(() => {
const Ctor = getNotificationCtor();
if (!Ctor)
return false;
// Already granted means the constructor is definitely usable.
if (Ctor.permission === 'granted')
return true;
// Some environments expose `Notification` but throw on construction
// (e.g. Android Chrome). Probe once to confirm it is truly usable.
try {
const probe = new Ctor('');
probe.onshow = () => probe.close();
}
catch (error) {
if ((error as Error).name === 'TypeError')
return false;
}
return true;
});
const notification = shallowRef<Notification | null>(null);
const permissionGranted = shallowRef(
isSupported.value && getNotificationCtor()?.permission === 'granted',
);
const { on: onClick, trigger: clickTrigger } = createEventHook<Event>();
const { on: onShow, trigger: showTrigger } = createEventHook<Event>();
const { on: onError, trigger: errorTrigger } = createEventHook<Event>();
const { on: onClose, trigger: closeTrigger } = createEventHook<Event>();
const ensurePermissionGranted = async (): Promise<boolean | undefined> => {
if (!isSupported.value)
return undefined;
const Ctor = getNotificationCtor()!;
if (!permissionGranted.value && Ctor.permission !== 'denied') {
const result = await Ctor.requestPermission();
if (result === 'granted')
permissionGranted.value = true;
}
return permissionGranted.value;
};
const close = (): void => {
if (notification.value)
notification.value.close();
notification.value = null;
};
const show = async (
overrides?: WebNotificationOptions,
): Promise<Notification | undefined> => {
if (!isSupported.value || !permissionGranted.value)
return undefined;
const Ctor = getNotificationCtor()!;
const merged: WebNotificationOptions = { ...options, ...overrides };
const created = new Ctor(merged.title ?? '', merged);
created.onclick = clickTrigger;
created.onshow = showTrigger;
created.onerror = errorTrigger;
created.onclose = closeTrigger;
notification.value = created;
return created;
};
if (requestPermissions)
tryOnMounted(ensurePermissionGranted);
tryOnScopeDispose(close);
// Close the active notification when the tab becomes visible again — the
// user is already looking at the page, so the notification is redundant.
if (window) {
useEventListener(
window.document,
'visibilitychange',
() => {
if (window.document.visibilityState === 'visible')
close();
},
{ passive: true },
);
}
return {
isSupported,
notification,
permissionGranted,
show,
close,
ensurePermissionGranted,
onClick,
onShow,
onError,
onClose,
};
}