components/site-header/site-header.ts
215 lines and 36 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| badgeText | 18 to 18 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| stepIndex | 29 to 36 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| panelItems | 39 to 41 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:40> | 40 to 40 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| isOpen | 43 to 49 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| closePanels | 54 to 56 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| placePanel | 60 to 65 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onEsc | 68 to 77 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| wirePanel | 80 to 177 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| on | 88 to 91 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:90> | 90 to 90 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| follow | 92 to 94 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| close | 95 to 98 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| open | 99 to 106 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:113> | 113 to 123 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:127> | 127 to 133 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| hovered | 134 to 134 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| leave | 135 to 141 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:138> | 138 to 140 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:142> | 142 to 146 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:148> | 148 to 148 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:150> | 150 to 156 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:158> | 158 to 161 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:163> | 163 to 166 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:167> | 167 to 169 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:170> | 170 to 176 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:174> | 174 to 174 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| openSheet | 180 to 186 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| closeSheet | 188 to 191 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| enhance | 195 to 230 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onClose | 214 to 214 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| onClick | 216 to 220 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:222> | 222 to 227 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:225> | 225 to 225 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:229> | 229 to 229 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:229> | 229 to 229 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
Imports
It imports components/dialog/dialog.ts, components/popover/popover.ts, components/tooltip/tooltip.ts. Imported by components/admin-shell/admin-shell.ts, components/site-header/site-header-parts.react.tsx, components/site-header/site-header.react.tsx.
Source
// The site header's behaviour: the panels (the switcher, a section with children, the account),
// the phone's sheet, and each icon control's name tip. No framework; the React wrapper uses the
// same functions. Every link, the logo, Sign in and the controls are in the HTML without any of
// this; a panel's contents are too, so a page with no script still reads and reaches them.
//
// A panel is a list of links under a disclosure button (aria-expanded, aria-controls), not an
// ARIA menu: every item goes somewhere. It is a manual popover in the top layer, so it is above
// the page and every rail, and this module gives it light dismiss and Esc. On a mouse it opens at
// once on hover, with no delay, and on click; on touch, on tap. Opened from the keyboard, focus
// moves to its first item; Up and Down move between items, Home and End go to the ends, Esc closes
// it and focus goes back to its button.
import { closeDialog, openDialog, wireDialog } from "../dialog/dialog.ts";
import { place } from "../popover/popover.ts";
import { attach as attachTip, hideTip } from "../tooltip/tooltip.ts";
// A count is a glance, not a figure: past 99 it says 99+, in the header, the sheet, the bottom bar
// and the admin shell alike; the accessible name keeps the real count.
export const badgeText = (count: number): string => (count > 99 ? "99+" : String(count));
// How long a panel opened by hover stays after the pointer leaves both it and its button, so the
// pointer can cross the gap between them.
export const HOVER_GRACE_MS = 150;
// The phone layout's width (docs/design/design-site-header.md, section 5): under 768px.
export const PHONE_QUERY = "(max-width: 767.98px)";
// The next item for a key in a panel of `count` items with `at` focused (-1: none yet). Returns
// null for a key that does not move. Up and Down wrap; Home and End go to the ends.
export function stepIndex(count: number, at: number, key: string): number | null {
if (count === 0) return null;
if (key === "Home") return 0;
if (key === "End") return count - 1;
if (key === "ArrowDown") return at < 0 ? 0 : (at + 1) % count;
if (key === "ArrowUp") return at < 0 ? count - 1 : (at - 1 + count) % count;
return null;
}
// The items a panel holds that a person can reach: links and enabled buttons that are drawn.
export function panelItems(panel: HTMLElement): HTMLElement[] {
return [...panel.querySelectorAll<HTMLElement>("a[href], button:not(:disabled)")].filter((el) => el.getClientRects().length > 0);
}
const isOpen = (panel: HTMLElement): boolean => {
try {
return panel.matches(":popover-open");
} catch {
return false;
}
};
// Every panel this module has wired, so opening one closes the others in the same header.
const panels = new Map<HTMLElement, { trigger: HTMLElement; close: () => void }>();
export function closePanels(except?: HTMLElement): void {
for (const [panel, p] of panels) if (panel !== except && isOpen(panel)) p.close();
}
// Puts a panel under its button, lined up with the button's edge (start, or end with
// data-align="end"), its top just under the header's bottom edge so it never covers the header.
export function placePanel(trigger: HTMLElement, panel: HTMLElement): void {
const bar = trigger.closest(".cap-site-header")?.getBoundingClientRect();
const t = trigger.getBoundingClientRect();
const offset = bar ? Math.max(4, Math.round(bar.bottom - t.bottom) + 4) : 4;
place(trigger, panel, { side: "bottom", align: panel.dataset.align === "end" ? "end" : "start", offset });
}
let escBound = false;
function onEsc(e: KeyboardEvent): void {
if (e.key !== "Escape") return;
for (const [panel, p] of panels) {
if (!isOpen(panel)) continue;
e.preventDefault();
const inside = panel.contains(document.activeElement) || p.trigger === document.activeElement;
p.close();
if (inside) p.trigger.focus({ preventScroll: true });
}
}
// Wires one disclosure button to its panel. Returns a function that unwires it.
export function wirePanel(trigger: HTMLElement, panel: HTMLElement): () => void {
if (!panel.hasAttribute("popover")) panel.setAttribute("popover", "manual");
trigger.setAttribute("aria-expanded", String(isOpen(panel)));
if (panel.id) trigger.setAttribute("aria-controls", panel.id);
// How it was opened: by hover it closes when the pointer leaves; by a click or a key it stays.
let mode: "hover" | "held" = "held";
let timer: number | undefined;
const undo: Array<() => void> = [];
const on = <E extends Event>(target: EventTarget, type: string, fn: (e: E) => void, opts?: boolean | AddEventListenerOptions) => {
target.addEventListener(type, fn as EventListener, opts);
undo.push(() => target.removeEventListener(type, fn as EventListener, opts));
};
const follow = () => {
if (isOpen(panel)) placePanel(trigger, panel);
};
const close = () => {
window.clearTimeout(timer);
if (isOpen(panel)) panel.hidePopover();
};
const open = (how: "hover" | "held", focusFirst = false) => {
window.clearTimeout(timer);
closePanels(panel);
mode = how;
if (!isOpen(panel)) panel.showPopover();
placePanel(trigger, panel);
if (focusFirst) panelItems(panel)[0]?.focus({ preventScroll: true });
};
panels.set(panel, { trigger, close });
if (!escBound) {
escBound = true;
document.addEventListener("keydown", onEsc);
}
on(panel, "toggle", (e: ToggleEvent) => {
const opened = e.newState === "open";
trigger.setAttribute("aria-expanded", String(opened));
window[opened ? "addEventListener" : "removeEventListener"]("scroll", follow, true);
window[opened ? "addEventListener" : "removeEventListener"]("resize", follow);
if (!opened) {
// Focus left inside a closed panel (or lost to the page) goes back to its button.
const active = document.activeElement;
if (!active || active === document.body || panel.contains(active)) trigger.focus({ preventScroll: true });
}
});
// A click, Enter or Space toggles. A panel opened by hover is held open by a click rather than
// closed by it, since the click is what the person meant. From the keyboard (no pointer, so
// detail 0) focus moves to the first item.
on(trigger, "click", (e: MouseEvent) => {
e.preventDefault();
hideTip(trigger);
const byKey = e.detail === 0;
if (isOpen(panel) && (mode === "held" || byKey)) close();
else open("held", byKey);
});
const hovered = () => trigger.matches(":hover") || panel.matches(":hover");
const leave = () => {
if (mode !== "hover") return;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
if (!hovered()) close();
}, HOVER_GRACE_MS);
};
on(trigger, "pointerenter", (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
window.clearTimeout(timer);
if (!isOpen(panel)) open("hover");
});
on(trigger, "pointerleave", leave);
on(panel, "pointerenter", () => window.clearTimeout(timer));
on(panel, "pointerleave", leave);
on(panel, "keydown", (e: KeyboardEvent) => {
const items = panelItems(panel);
const next = stepIndex(items.length, items.indexOf(document.activeElement as HTMLElement), e.key);
if (next === null) return;
e.preventDefault();
items[next]?.focus();
});
// Tab past the panel's last item closes it without taking focus back.
on(panel, "focusout", (e: FocusEvent) => {
const to = e.relatedTarget as Node | null;
if (to && !panel.contains(to) && to !== trigger) close();
});
// A press outside the panel and its button closes it (light dismiss), as does following a link.
on(document, "pointerdown", (e: PointerEvent) => {
const t = e.target as Node;
if (isOpen(panel) && !panel.contains(t) && !trigger.contains(t)) close();
});
on(panel, "click", (e: MouseEvent) => {
if ((e.target as Element).closest("a[href], [data-panel-close]")) close();
});
return () => {
close();
window.removeEventListener("scroll", follow, true);
window.removeEventListener("resize", follow);
undo.forEach((f) => f());
panels.delete(panel);
};
}
// Opens the phone's sheet (the shared dialog, from the bottom) from the menu button.
export function openSheet(header: HTMLElement): void {
const button = header.querySelector<HTMLButtonElement>(".cap-site-header-menu");
const sheet = header.querySelector<HTMLDialogElement>("dialog.cap-site-header-sheet");
if (!sheet || sheet.open) return;
openDialog(sheet, button, { focus: sheet.querySelector<HTMLElement>(".cap-site-header-sheet-title") });
button?.setAttribute("aria-expanded", "true");
}
export function closeSheet(header: HTMLElement): void {
const sheet = header.querySelector<HTMLDialogElement>("dialog.cap-site-header-sheet");
if (sheet?.open) closeDialog(sheet);
}
// Attaches to every [data-cap="site-header"] under root not yet attached: each panel, each
// icon control's tip, the menu button and its sheet. Returns a function that detaches them all.
export function enhance(root: ParentNode = document): () => void {
const undo: Array<() => void> = [];
for (const header of root.querySelectorAll<HTMLElement>("[data-cap='site-header']:not([data-cap-ready])")) {
header.dataset.capReady = "";
const parts: Array<() => void> = [];
for (const trigger of header.querySelectorAll<HTMLElement>("[data-cap-part='panel-trigger']")) {
const panel = document.getElementById(trigger.getAttribute("aria-controls") ?? "");
if (panel) parts.push(wirePanel(trigger, panel));
}
// An icon control's tip names it, so it adds no description; a press hides it.
for (const tip of header.querySelectorAll<HTMLElement>(".cap-site-header-tip")) {
const trigger = tip.parentElement;
if (!trigger) continue;
parts.push(attachTip(trigger, tip));
trigger.removeAttribute("aria-describedby");
}
const sheet = header.querySelector<HTMLDialogElement>("dialog.cap-site-header-sheet");
const menu = header.querySelector<HTMLButtonElement>(".cap-site-header-menu");
if (sheet) parts.push(wireDialog(sheet));
const onClose = () => menu?.setAttribute("aria-expanded", "false");
sheet?.addEventListener("close", onClose);
const onClick = (e: MouseEvent) => {
const t = e.target as Element;
if (menu && t.closest(".cap-site-header-menu") === menu) openSheet(header);
if (sheet?.open && sheet.contains(t) && t.closest("a[href]")) closeDialog(sheet);
};
header.addEventListener("click", onClick);
undo.push(() => {
header.removeEventListener("click", onClick);
sheet?.removeEventListener("close", onClose);
parts.forEach((f) => f());
delete header.dataset.capReady;
});
}
return () => undo.forEach((f) => f());
}