// ============================================================================ // Stylescape | FAB Manager // ============================================================================ // Opens and closes a `.ss-c-fab` speed dial: toggles `aria-expanded` on the // main button and `.is-open` on the root, closes on Escape (returning focus // to the toggle), on a click outside, and after an action is chosen. // ============================================================================ /** * Configuration options for FabManager */ export interface FabOptions { /** Close the dial after one of its actions is clicked (default true). */ closeOnAction?: boolean; /** Callback when the dial opens or closes. */ onToggle?: (open: boolean, root: HTMLElement) => void; } /** * Speed-dial behaviour for the `fab` module. * * @example HTML with data-ss * ```html *
* *
* *
*
* ``` */ export class FabManager { private root: HTMLElement | null; private toggleButton: HTMLButtonElement | null = null; private options: Required; constructor( selectorOrElement: string | HTMLElement, options: FabOptions = {}, ) { this.root = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = { closeOnAction: options.closeOnAction ?? true, onToggle: options.onToggle ?? (() => {}), }; if (!this.root) { console.warn("[Stylescape] FabManager element not found"); return; } this.toggleButton = this.root.querySelector(".ss-c-fab__toggle"); if (!this.toggleButton) { console.warn("[Stylescape] FabManager: no .ss-c-fab__toggle"); return; } this.init(); } // ======================================================================== // Public API // ======================================================================== get isOpen(): boolean { return this.toggleButton?.getAttribute("aria-expanded") === "true"; } public open(): void { this.setOpen(true); } public close(): void { this.setOpen(false); } public toggle(): void { this.setOpen(!this.isOpen); } public destroy(): void { this.toggleButton?.removeEventListener("click", this.handleToggle); this.root?.removeEventListener("keydown", this.handleKeydown); this.root?.removeEventListener("click", this.handleRootClick); document.removeEventListener("click", this.handleDocumentClick); this.root = null; this.toggleButton = null; } // ======================================================================== // Private // ======================================================================== private init(): void { if (!this.toggleButton) return; if (!this.toggleButton.hasAttribute("aria-expanded")) { this.toggleButton.setAttribute("aria-expanded", "false"); } this.root?.classList.toggle("is-open", this.isOpen); this.toggleButton.addEventListener("click", this.handleToggle); this.root?.addEventListener("keydown", this.handleKeydown); this.root?.addEventListener("click", this.handleRootClick); document.addEventListener("click", this.handleDocumentClick); } private setOpen(open: boolean): void { if (!this.root || !this.toggleButton) return; if (open === this.isOpen) return; this.toggleButton.setAttribute("aria-expanded", String(open)); this.root.classList.toggle("is-open", open); this.options.onToggle(open, this.root); } private handleToggle = (): void => { this.toggle(); }; private handleKeydown = (event: KeyboardEvent): void => { if (event.key !== "Escape" || !this.isOpen) return; event.stopPropagation(); this.close(); this.toggleButton?.focus(); }; private handleRootClick = (event: MouseEvent): void => { if (!this.options.closeOnAction) return; const target = event.target as Element | null; if (target?.closest(".ss-c-fab__action")) { this.close(); this.toggleButton?.focus(); } }; private handleDocumentClick = (event: MouseEvent): void => { if (!this.root || !this.isOpen) return; if (!this.root.contains(event.target as Node)) this.close(); }; } export default FabManager;