UNPKG

carbon-components-svelte

Version:
182 lines (168 loc) 5.69 kB
// @ts-check /** * One `window` listener per `(type, options)`. Handlers register as consumers; * the first consumer calls `addEventListener`, the last calls `removeEventListener`. * * @typedef {{ handler: (event: Event) => void }} Consumer * @typedef {{ * type: string, * options: boolean | AddEventListenerOptions | undefined, * listener: (event: Event) => void, * consumers: Set<Consumer>, * }} Pool * * @type {Map<string, Pool>} */ const pools = new Map(); /** * Pool key from type plus `capture`, `passive`, and `once`. Those are the only * options that change how the listener runs. * * @param {string} type * @param {boolean | AddEventListenerOptions | undefined} options * @returns {string} */ function poolKey(type, options) { let capture = false; let passive = false; let once = false; if (typeof options === "boolean") { capture = options; } else if (options) { capture = !!options.capture; passive = !!options.passive; once = !!options.once; } return `${type} ${capture ? 1 : 0}${passive ? 1 : 0}${once ? 1 : 0}`; } /** * Add a consumer to the pool for `(type, options)`. Creates the pool on first use. * * @param {{ type: string, handler: (event: Event) => void, options: boolean | AddEventListenerOptions | undefined }} spec * @returns {{ key: string, pool: Pool, consumer: Consumer }} */ function registerConsumer(spec) { const key = poolKey(spec.type, spec.options); let pool = pools.get(key); if (!pool) { const consumers = /** @type {Set<Consumer>} */ (new Set()); const listener = (/** @type {Event} */ event) => { // Copy before iterating. Handlers may add or remove consumers mid-dispatch; // removed consumers should not run, same as native listeners. for (const consumer of [...consumers]) { if (consumers.has(consumer)) consumer.handler(event); } }; pool = { type: spec.type, options: spec.options, listener, consumers }; pools.set(key, pool); window.addEventListener(spec.type, listener, spec.options); } const consumer = { handler: spec.handler }; pool.consumers.add(consumer); return { key, pool, consumer }; } /** * Remove a consumer. Drops the window listener when the pool is empty. * * @param {{ key: string, pool: Pool, consumer: Consumer }} entry */ function unregisterConsumer({ key, pool, consumer }) { pool.consumers.delete(consumer); if (pool.consumers.size === 0) { window.removeEventListener(pool.type, pool.listener, pool.options); pools.delete(key); } } /** * Svelte action: `window` listeners while `enabled` is true. * Controls with the same `(type, options)` share one listener. * Handler updates in place without re-registering. SSR-safe. * * @param {unknown} _node Host element (unused; listeners attach to `window`). * @param {import("./dismiss.js").DismissParams} params * @returns {{ update: (params: import("./dismiss.js").DismissParams) => void, destroy: () => void }} */ export function dismiss(_node, params) { let specs = normalize(params); let enabled = !!params.enabled; let destroyed = false; let addScheduled = false; /** * @type {Array<{ key: string, pool: Pool, consumer: Consumer }>} */ let registered = []; function add() { if (typeof window === "undefined" || addScheduled) return; addScheduled = true; // Defer registration to the next task (not just a microtask). Enabling // synchronously within a click (e.g. a button outside the anchor setting // `open` to true) would otherwise register the window listener while // that SAME click is still bubbling — heavy synchronous DOM work in the // handler (mounting a portal, positioning it) can delay the event's // arrival at `window` past any microtask flush, so only a macrotask // defer reliably waits for the current dispatch to finish. Re-checks // `enabled`/`destroyed` since either may have changed by the time this // runs. setTimeout(() => { addScheduled = false; if (destroyed || !enabled) return; registered = specs.map((spec) => registerConsumer(spec)); }); } function remove() { for (const entry of registered) unregisterConsumer(entry); registered = []; } if (enabled) add(); return { /** @param {import("./dismiss.js").DismissParams} next */ update(next) { const nextSpecs = normalize(next); const nextEnabled = !!next.enabled; const sameShape = registered.length === nextSpecs.length && nextSpecs.every( (s, i) => registered[i] && registered[i].key === poolKey(s.type, s.options), ); if (enabled && nextEnabled && sameShape) { nextSpecs.forEach((s, i) => { registered[i].consumer.handler = s.handler; }); specs = nextSpecs; return; } remove(); specs = nextSpecs; enabled = nextEnabled; if (enabled) add(); }, destroy() { destroyed = true; remove(); }, }; } /** * @param {import("./dismiss.js").DismissParams} params * @returns {Array<{ type: string, handler: (event: Event) => void, options: boolean | AddEventListenerOptions | undefined }>} */ function normalize(params) { if (params.listeners) { return params.listeners.map((l) => ({ type: l.type, handler: l.handler, options: l.options, })); } const types = Array.isArray(params.type) ? params.type : params.type == null ? [] : [params.type]; return types.map((type) => ({ type, handler: /** @type {(event: Event) => void} */ (params.handler), options: params.options, })); }