[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
166
components/settings/settingsFocus.ts
Normal file
166
components/settings/settingsFocus.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user