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( "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("[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 { 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( `[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); }); }