// ============================================================================
// Stylescape | Sheet Manager
// ============================================================================
// Opens and closes a bottom sheet, side dock or app-shell sidebar by
// toggling `data-state="open" | "closed"` on it, keeping `aria-expanded`
// on every button that controls it in sync. Escape closes an overlaying
// panel and returns focus to the button that opened it.
// ============================================================================
export type SheetState = "open" | "closed";
export interface SheetOptions {
/** Callback after the state changes */
onChange?: (open: boolean, panel: HTMLElement) => void;
}
const SHEET_CLASS = "ss-c-sheet";
const HANDLE_SELECTOR = ".ss-c-sheet__handle";
/**
* Toggle behaviour for `ss-c-sheet` panels and `ss-l-app__sidebar`s.
*
* Controls are the sheet's own `.ss-c-sheet__handle` plus any button with
* `data-ss-sheet-toggle` and `aria-controls` naming the panel. Arrow keys
* on a sheet handle open and close it in the direction of its edge.
*
* @example HTML
* ```html
*
*
*
*
…
*
* ```
*
* @example JavaScript
* ```typescript
* SheetManager.initAll()
* ```
*/
export class SheetManager {
readonly panel: HTMLElement;
private readonly toggles: HTMLElement[];
private readonly options: SheetOptions;
private returnFocus: HTMLElement | null = null;
constructor(
panelOrSelector: string | HTMLElement,
options: SheetOptions = {},
) {
const panel =
typeof panelOrSelector === "string"
? document.querySelector(panelOrSelector)
: panelOrSelector;
if (!panel) throw new Error("SheetManager: panel element not found");
this.panel = panel;
this.options = options;
const own = Array.from(
panel.querySelectorAll(`:scope > ${HANDLE_SELECTOR}`),
);
const external = panel.id
? Array.from(
document.querySelectorAll(
"[data-ss-sheet-toggle][aria-controls]",
),
).filter((t) => t.getAttribute("aria-controls") === panel.id)
: [];
this.toggles = [...new Set([...own, ...external])];
if (panel.id) {
this.toggles.forEach((t) => {
if (!t.hasAttribute("aria-controls")) {
t.setAttribute("aria-controls", panel.id);
}
});
}
this.sync();
this.toggles.forEach((t) => {
t.addEventListener("click", this.onToggleClick);
t.addEventListener("keydown", this.onToggleKeyDown);
});
panel.addEventListener("keydown", this.onPanelKeyDown);
}
// ========================================================================
// Public API
// ========================================================================
isOpen(): boolean {
const state = this.panel.dataset.state;
if (state === "open") return true;
if (state === "closed") return false;
// No state yet: a sheet starts closed; any other panel (a sidebar)
// is open when the stylesheet currently shows it.
if (this.panel.classList.contains(SHEET_CLASS)) return false;
return getComputedStyle(this.panel).display !== "none";
}
open(): void {
this.setState("open");
}
close(): void {
this.setState("closed");
}
toggle(): void {
this.setState(this.isOpen() ? "closed" : "open");
}
destroy(): void {
this.toggles.forEach((t) => {
t.removeEventListener("click", this.onToggleClick);
t.removeEventListener("keydown", this.onToggleKeyDown);
});
this.panel.removeEventListener("keydown", this.onPanelKeyDown);
}
/**
* Initialise every `ss-c-sheet` and every panel named by a
* `[data-ss-sheet-toggle]` button under `root`.
*/
static initAll(root: ParentNode = document): SheetManager[] {
const panels = new Set(
Array.from(
root.querySelectorAll(
`.${SHEET_CLASS}, [data-ss="sheet"]`,
),
),
);
root.querySelectorAll(
"[data-ss-sheet-toggle][aria-controls]",
).forEach((t) => {
const target = document.getElementById(
t.getAttribute("aria-controls") as string,
);
if (target) panels.add(target);
});
return Array.from(panels).map((panel) => new SheetManager(panel));
}
// ========================================================================
// Internals
// ========================================================================
private setState(state: SheetState): void {
const wasOpen = this.isOpen();
this.panel.dataset.state = state;
this.sync();
const open = state === "open";
if (open !== wasOpen) {
this.options.onChange?.(open, this.panel);
this.panel.dispatchEvent(
new CustomEvent("ss:sheet-toggle", {
detail: { open },
bubbles: true,
}),
);
}
}
private sync(): void {
const open = this.isOpen();
this.toggles.forEach((t) =>
t.setAttribute("aria-expanded", String(open)),
);
}
/** True when the panel floats over other content (Escape may close it). */
private overlays(): boolean {
if (this.panel.classList.contains(SHEET_CLASS)) return true;
const position = getComputedStyle(this.panel).position;
return position === "absolute" || position === "fixed";
}
private onToggleClick = (event: Event): void => {
event.preventDefault();
if (!this.isOpen())
this.returnFocus = event.currentTarget as HTMLElement;
this.toggle();
};
private onToggleKeyDown = (event: KeyboardEvent): void => {
const handle = event.currentTarget as HTMLElement;
if (!handle.matches(HANDLE_SELECTOR)) return;
const list = this.panel.classList;
const [openKey, closeKey] = list.contains(`${SHEET_CLASS}--left`)
? ["ArrowRight", "ArrowLeft"]
: list.contains(`${SHEET_CLASS}--right`)
? ["ArrowLeft", "ArrowRight"]
: ["ArrowUp", "ArrowDown"];
if (event.key === openKey) this.open();
else if (event.key === closeKey) this.close();
else return;
event.preventDefault();
};
private onPanelKeyDown = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || !this.isOpen() || !this.overlays())
return;
event.preventDefault();
event.stopPropagation();
this.close();
const target = this.returnFocus ?? this.toggles[0] ?? null;
this.returnFocus = null;
target?.focus();
};
}