/** * Structural representation of a lock layer. */ export type Lock = { /** * Friendly id for the lock. Must be unique at least across all active locks. */ uid: string; /** * Representation of the current state of the lock. Only gets updated _after_ * enable callbacks have been run. */ enabled: boolean; /** * Callback for actually enabling/disabling the locking logic in the consumer. * This function _must_ immediately cause the logic to be enabled/disabled. * Using asynchronous logic can cause issues where two mutually-exclusive locks * may be active at the same time, causing an infinite loop of focus movement. */ setEnabled: (enabled: boolean) => unknown; }; /** * A callback meant to be invoked whenever the state of a lock stack changes. * The first argument, `enabled`, indicates the enabled state of the top lock * in the stack, and the second argument, `stack`, is a reference to that * stack itself. */ export type LockListener = (enabled: boolean, stack: Lock[]) => unknown; /** * Simple stack-like structure representing a layer system for focus locks. * * The stack takes care of enabling and disabling layers as necessary when locks * are added or removed from the stack. In this way, it is guaranteed that only * the top lock in the stack will be enabled at any given time. * * The stack also provides a subscription interface for consumers to be notified * whenever the state of the stack changes. */ export default class LockStack { /** * The stack of currently-active locks. At any given time, only the lock at * the top of the stack should be enabled. Locks in the rest of the stack will * be re-enabled when all locks above it are removed. */ private locks: Lock[] = []; /** * List of listeners that will be notified whenever the state of the lock tree * is updated. */ private listeners: LockListener[] = []; /** * Push a new lock layer onto the top of the stack. This will cause the layer * below to be disabled before this lock's callback is enabled. * Locks should not enable themselves until the callback has been invoked to * ensure that other locks are not also concurrently active. */ add(uid: Lock["uid"], setEnabled: Lock["setEnabled"]) { const newLock: Lock = { uid, setEnabled, enabled: false }; this.toggleLayer(this.current(), false); this.locks.push(newLock); this.toggleLayer(newLock, true); this.emit(); } /** * Immediately remove the lock with the given id from the stack. This will * call the lock's callback to disable it before removing. Then if it was the * currently-active lock, the new current lock will be re-enabled. */ remove(uid: string) { const lock = this.locks.find((lock) => lock.uid === uid); this.toggleLayer(lock, false); const current = this.current(); const wasCurrent = current != null && current.uid === uid; this.locks = this.locks.filter((lock) => lock.uid !== uid); if (wasCurrent) this.toggleLayer(this.current(), true); this.emit(); } /** * Returns the lock that currently lives at the top of the stack. */ current() { return this.locks[this.locks.length - 1]; } /** * Returns the enabled state of the currently-active lock. */ isActive() { const current = this.current(); return current != null ? current.enabled : false; } /** * Utility for enabling and disabling layers atomically. */ toggleLayer(lock: Lock | undefined, enabled: boolean) { if (lock == null) return; lock.setEnabled(enabled); lock.enabled = enabled; } /** * Adds a listener that will be invoked whenever the state of the lock stack * changes. Callbacks are called with the "enabled" state of the current lock, * and the stack itself as a reference. * * Returns a function to unsubscribe the listener from the stack. */ subscribe(callback: LockListener): () => void { this.listeners.push(callback); callback(this.isActive(), this.locks); return () => this.listeners = this.listeners.filter((listener) => listener !== callback); } /** * Updates all currently-subscribed listeners with the current state of the * lock stack. */ emit() { const active = this.isActive(); this.listeners.forEach((listener) => listener(active, this.locks)); } }