visual continunity
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Transient UI state — toasts now; cross-component dialog targets are added as
|
||||
* the views that need them land (Phases 2–4). Component-local state is preferred;
|
||||
* only state that must cross component boundaries belongs here.
|
||||
* 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';
|
||||
|
||||
@@ -11,13 +12,79 @@ export interface Toast {
|
||||
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;
|
||||
|
||||
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000): number {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -27,6 +94,83 @@ class UiStore {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user