Files
NetMesh/components/settings/settingsFocus.ts

167 lines
5.3 KiB
TypeScript
Raw Normal View History

import { settingsAnchorDomId } from "../../domain/settingsSearchCatalog";
const HIGHLIGHT_CLASS = "settings-search-highlight";
const HIGHLIGHT_MS = 1600;
/** Keep focused anchors slightly below the top of the settings content pane. */
const SCROLL_TOP_PADDING_PX = 24;
export type SettingsFocusTarget = {
tab: string;
aiSubTab?: string;
syncSubTab?: string;
anchorId: string;
};
let focusGeneration = 0;
let highlightClearTimer: number | null = null;
/** Cancel any in-flight focusSettingsAnchor retries / scroll. */
export function cancelSettingsFocus(): void {
focusGeneration += 1;
}
function isAnchorVisible(el: HTMLElement): boolean {
if (el.closest("[hidden]")) return false;
if (typeof el.checkVisibility === "function") {
return el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
}
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
return el.getClientRects().length > 0;
}
function focusAnchorElement(el: HTMLElement): void {
const focusable = el.matches("button, a, input, select, textarea, [tabindex]")
? el
: el.querySelector<HTMLElement>(
"button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
);
const target = focusable ?? el;
if (!target.hasAttribute("tabindex") && target === el) {
target.tabIndex = -1;
}
try {
// preventScroll: we own scrolling so the settings titlebar / traffic-light
// chrome never rides along with a viewport-level scrollIntoView.
target.focus({ preventScroll: true });
} catch {
// ignore focus failures in non-interactive hosts
}
}
function isVerticallyScrollable(el: HTMLElement): boolean {
const { overflowY } = window.getComputedStyle(el);
if (overflowY !== "auto" && overflowY !== "scroll" && overflowY !== "overlay") {
return false;
}
return el.scrollHeight > el.clientHeight + 1;
}
/**
* Prefer the dedicated settings content scroller; never fall back to
* document/body scrolling (that lifts the whole window under macOS traffic lights).
*/
export function findSettingsScrollContainer(el: HTMLElement): HTMLElement | null {
// Trust the marked pane even when content is shorter than the viewport —
// search jumps must still scroll this host, not the window.
const marked = el.closest<HTMLElement>("[data-settings-scroll-pane]");
if (marked) return marked;
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.documentElement && node !== document.body) {
if (isVerticallyScrollable(node)) return node;
node = node.parentElement;
}
return null;
}
/**
* Scroll only within the settings content pane so the window chrome stays put.
*/
export function scrollSettingsAnchorIntoView(
el: HTMLElement,
behavior: ScrollBehavior = "smooth",
): void {
const scroller = findSettingsScrollContainer(el);
if (!scroller) {
// Nearest-only, never "center" — avoids yanking the whole settings window.
el.scrollIntoView({ behavior, block: "nearest", inline: "nearest" });
return;
}
const scrollerRect = scroller.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const elTopInScroller = elRect.top - scrollerRect.top + scroller.scrollTop;
const targetTop = Math.max(0, elTopInScroller - SCROLL_TOP_PADDING_PX);
const maxScroll = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
scroller.scrollTo({
top: Math.min(targetTop, maxScroll),
behavior,
});
}
/**
* Scroll the settings content pane to a catalog anchor and briefly highlight it.
* Retries to cover lazy tab mounts / nested sub-tab switches.
*/
export function focusSettingsAnchor(
anchorId: string,
options?: { attempts?: number; delayMs?: number },
): Promise<boolean> {
const attempts = options?.attempts ?? 40;
const delayMs = options?.delayMs ?? 50;
const generation = ++focusGeneration;
let remaining = attempts;
return new Promise((resolve) => {
const finish = (ok: boolean) => {
if (generation !== focusGeneration) return;
resolve(ok);
};
const tryFocus = () => {
if (generation !== focusGeneration) {
resolve(false);
return;
}
const domId = settingsAnchorDomId(anchorId);
const candidates = [
document.getElementById(domId),
...Array.from(
document.querySelectorAll<HTMLElement>(
`[data-settings-anchor="${CSS.escape(anchorId)}"]`,
),
),
].filter((node): node is HTMLElement => Boolean(node));
const el = candidates.find((node) => isAnchorVisible(node));
if (!el) {
remaining -= 1;
if (remaining > 0) {
window.setTimeout(tryFocus, delayMs);
return;
}
finish(false);
return;
}
scrollSettingsAnchorIntoView(el, "smooth");
el.classList.remove(HIGHLIGHT_CLASS);
void el.offsetWidth;
el.classList.add(HIGHLIGHT_CLASS);
if (highlightClearTimer !== null) {
window.clearTimeout(highlightClearTimer);
}
highlightClearTimer = window.setTimeout(() => {
el.classList.remove(HIGHLIGHT_CLASS);
highlightClearTimer = null;
}, HIGHLIGHT_MS);
focusAnchorElement(el);
finish(true);
};
requestAnimationFrame(tryFocus);
});
}