177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
/**
|
||
* Transient UI state — toasts plus the persistent notification feed shown in the
|
||
* top-bar bell. `notify()` raises a transient toast and records a notification
|
||
* entry (so uploads, errors and successes accumulate in the bell). Component-local
|
||
* state is preferred; only state that must cross component boundaries lives here.
|
||
*/
|
||
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
|
||
|
||
export interface Toast {
|
||
id: number;
|
||
message: string;
|
||
kind: ToastKind;
|
||
}
|
||
|
||
export interface Notification {
|
||
id: number;
|
||
message: string;
|
||
kind: ToastKind;
|
||
at: number;
|
||
read: boolean;
|
||
/** 0–100 while an operation is in progress; undefined for plain notifications. */
|
||
progress?: number;
|
||
/** Optional icon-registry name override (defaults derived from kind). */
|
||
icon?: string;
|
||
/** Current per-file label (e.g. the filename being uploaded). */
|
||
currentFile?: string;
|
||
/** Files finished so far in the batch (for the "N / M files" counter). */
|
||
completed?: number;
|
||
/** Total files in the batch (for the "N / M files" counter). */
|
||
total?: number;
|
||
}
|
||
|
||
/** Announce a message to the matching ARIA live region (errors are assertive). */
|
||
function announce(message: string, assertive = false): void {
|
||
const msg = message.trim();
|
||
if (!msg || typeof document === 'undefined' || !document.body) return;
|
||
const id = assertive ? 'a11y-live-assertive' : 'a11y-live-polite';
|
||
let region = document.getElementById(id);
|
||
if (!region) {
|
||
region = document.createElement('div');
|
||
region.id = id;
|
||
region.className = 'sr-only';
|
||
region.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
|
||
region.setAttribute('aria-atomic', 'true');
|
||
region.setAttribute('role', assertive ? 'alert' : 'status');
|
||
document.body.appendChild(region);
|
||
}
|
||
// Clear first, then set next frame so repeats register as a change.
|
||
region.textContent = '';
|
||
const target = region;
|
||
if (typeof requestAnimationFrame !== 'undefined') {
|
||
requestAnimationFrame(() => (target.textContent = msg));
|
||
} else {
|
||
target.textContent = msg;
|
||
}
|
||
}
|
||
|
||
class UiStore {
|
||
toasts = $state<Toast[]>([]);
|
||
notifications = $state<Notification[]>([]);
|
||
#seq = 0;
|
||
|
||
/**
|
||
* Bumped to request the bell panel auto-open (e.g. on upload start) and to
|
||
* trigger the bell "ring" animation. AppShell watches this token.
|
||
*/
|
||
bellPing = $state(0);
|
||
|
||
unread = $derived(this.notifications.filter((n) => !n.read).length);
|
||
|
||
/** Unread count clamped for the badge — caps at "99+" like the original. */
|
||
unreadBadge = $derived(this.unread > 99 ? '99+' : String(this.unread));
|
||
|
||
/**
|
||
* Raise a toast and record a notification. `at` is stamped from the clock at
|
||
* call time; pass `record: false` for purely transient messages.
|
||
*/
|
||
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
|
||
const id = ++this.#seq;
|
||
this.toasts = [...this.toasts, { id, message, kind }];
|
||
if (record) {
|
||
this.notifications = [
|
||
{ id, message, kind, at: Date.now(), read: false },
|
||
...this.notifications
|
||
];
|
||
}
|
||
announce(message, kind === 'error');
|
||
if (timeoutMs > 0 && typeof setTimeout !== 'undefined') {
|
||
setTimeout(() => this.dismiss(id), timeoutMs);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
dismiss(id: number): void {
|
||
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||
}
|
||
|
||
/** Request the bell panel to open and play its ring animation. */
|
||
ringBell(): void {
|
||
this.bellPing++;
|
||
}
|
||
|
||
/**
|
||
* Begin a progress notification (e.g. an upload). Pass `total` to show the
|
||
* "N / M files" counter. Opens the bell, rings it, and announces the start.
|
||
*/
|
||
startProgress(message: string, icon = 'cloud-upload-alt', total?: number): number {
|
||
const id = ++this.#seq;
|
||
this.notifications = [
|
||
{
|
||
id,
|
||
message,
|
||
kind: 'info',
|
||
at: Date.now(),
|
||
read: false,
|
||
progress: 0,
|
||
icon,
|
||
...(total !== undefined ? { total, completed: 0 } : {})
|
||
},
|
||
...this.notifications
|
||
];
|
||
this.ringBell();
|
||
announce(message);
|
||
return id;
|
||
}
|
||
|
||
/** Update the percentage (0–100) of an in-flight progress notification. */
|
||
updateProgress(
|
||
id: number,
|
||
progress: number,
|
||
message?: string,
|
||
extra?: { currentFile?: string; completed?: number }
|
||
): void {
|
||
this.notifications = this.notifications.map((n) =>
|
||
n.id === id
|
||
? {
|
||
...n,
|
||
progress,
|
||
...(message ? { message } : {}),
|
||
...(extra?.currentFile !== undefined ? { currentFile: extra.currentFile } : {}),
|
||
...(extra?.completed !== undefined ? { completed: extra.completed } : {})
|
||
}
|
||
: n
|
||
);
|
||
}
|
||
|
||
/** Resolve a progress notification into a final success/error entry. */
|
||
finishProgress(id: number, message: string, kind: ToastKind = 'success'): void {
|
||
this.notifications = this.notifications.map((n) =>
|
||
n.id === id
|
||
? {
|
||
...n,
|
||
message,
|
||
kind,
|
||
progress: undefined,
|
||
currentFile: undefined,
|
||
at: Date.now()
|
||
}
|
||
: n
|
||
);
|
||
this.toasts = [...this.toasts, { id: ++this.#seq, message, kind }];
|
||
announce(message, kind === 'error');
|
||
const tid = this.#seq;
|
||
if (typeof setTimeout !== 'undefined') setTimeout(() => this.dismiss(tid), 4000);
|
||
}
|
||
|
||
markNotificationsRead(): void {
|
||
this.notifications = this.notifications.map((n) => (n.read ? n : { ...n, read: true }));
|
||
}
|
||
|
||
clearNotifications(): void {
|
||
this.notifications = [];
|
||
}
|
||
}
|
||
|
||
export const ui = new UiStore();
|