92 lines
3.9 KiB
JavaScript
92 lines
3.9 KiB
JavaScript
/** The shell's shared toast (`#workspace-toast`, role="status"), used by every workspace page. */
|
|
export const TOAST_MS = 6500;
|
|
/** A toast with a link stays longer, so a keyboard user can reach it (D57, WCAG 2.2.1). */
|
|
export const LINK_TOAST_MS = 20000;
|
|
let toastTimer;
|
|
// Stops waiting for the pointer or focus to leave a toast that a newer one replaced.
|
|
let releaseWait = null;
|
|
// The toast and the page element it lives in when no modal is open.
|
|
let toastElement = null;
|
|
let toastHome = null;
|
|
|
|
/**
|
|
* The open modal the user is in. With several, the top layer decides, not page order (a viewer can sit earlier in the
|
|
* page than the dialog it opened from): only the top one can be hit, the rest being inert.
|
|
*/
|
|
function topModal() {
|
|
const modals = [...document.querySelectorAll('dialog')].filter(dialog => dialog.matches(':modal'));
|
|
if (modals.length < 2) return modals[0] ?? null;
|
|
const hit = document.elementFromPoint?.(window.innerWidth / 2, window.innerHeight / 2)?.closest('dialog');
|
|
return modals.includes(hit) ? hit : modals.at(-1);
|
|
}
|
|
|
|
/**
|
|
* #533: a modal covers and inerts the rest of the page, so the toast shows inside the topmost one (D176) and
|
|
* follows it out when it closes. Called by that dialog's `close` too; the same listener is only ever added once.
|
|
*/
|
|
function placeToast() {
|
|
const toast = toastElement;
|
|
const parent = toast.parentNode;
|
|
if (parent && parent.localName !== 'dialog') toastHome = parent;
|
|
const modal = topModal();
|
|
const host = modal ?? toastHome;
|
|
if (host && parent !== host) host.append(toast);
|
|
modal?.addEventListener('close', placeToast);
|
|
}
|
|
|
|
/** Whether the pointer or keyboard focus rests on the toast. */
|
|
const held = toast => typeof toast.matches === 'function' && toast.matches(':hover, :focus-within');
|
|
|
|
/** Clear the toast when its time is up, unless the pointer or focus is on it: then wait until they leave. */
|
|
function scheduleClear(toast, delay) {
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => {
|
|
if (held(toast)) {
|
|
const release = () => {
|
|
toast.removeEventListener('pointerleave', leave);
|
|
toast.removeEventListener('focusout', later);
|
|
releaseWait = null;
|
|
};
|
|
const leave = () => {
|
|
if (held(toast)) return;
|
|
release();
|
|
scheduleClear(toast, TOAST_MS);
|
|
};
|
|
// focusout fires before the next element takes focus, so it checks once focus has moved.
|
|
const later = () => setTimeout(leave);
|
|
toast.addEventListener('pointerleave', leave);
|
|
toast.addEventListener('focusout', later);
|
|
releaseWait = release;
|
|
return;
|
|
}
|
|
toast.textContent = '';
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* Show one outcome in the shared workspace toast; it clears itself like the concept's. A message given as parts
|
|
* (`['Another job is running: ', {text, href}, '.']`) renders each `{text, href}` as a link (D57); such a toast stays
|
|
* for LINK_TOAST_MS and while the pointer or focus is on it.
|
|
*/
|
|
export function showWorkspaceToast(message) {
|
|
// Null only when a dialog left the page with the toast still in it.
|
|
const toast = document.getElementById('workspace-toast') ?? toastElement;
|
|
if (!toast) return;
|
|
toastElement = toast;
|
|
placeToast();
|
|
releaseWait?.();
|
|
if (Array.isArray(message)) {
|
|
toast.replaceChildren(...message.map(part => {
|
|
if (typeof part === 'string') return document.createTextNode(part);
|
|
const link = document.createElement('a');
|
|
link.href = part.href;
|
|
link.textContent = part.text;
|
|
return link;
|
|
}));
|
|
} else {
|
|
toast.textContent = message;
|
|
}
|
|
const linked = Array.isArray(message) && message.some(part => typeof part !== 'string');
|
|
scheduleClear(toast, linked ? LINK_TOAST_MS : TOAST_MS);
|
|
}
|