mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
refactor(ui): share surface keyboard lifecycle
This commit is contained in:
1 parent
d5b09efa74
commit
e1ffb9c572
11 files changed
+367
-128
No files matched your search
+14
-62
@@ -1,3 +1,5 @@
|
||||
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||
|
||||
// First-run mission launcher.
|
||||
//
|
||||
// The map deliberately does not auto-enable live feeds on every visit: doing so
|
||||
@@ -342,14 +344,9 @@ export function initFirstRunExperience({
|
||||
const suppressBox = root.querySelector('[data-first-run-suppress]');
|
||||
const buttons = [...root.querySelectorAll('[data-first-run-choice]')];
|
||||
const defaultStatus = status?.textContent || '';
|
||||
const previouslyFocused = documentRef.activeElement;
|
||||
let busy = false;
|
||||
let closing = false;
|
||||
|
||||
const focusables = () => [
|
||||
...root.querySelectorAll('button, input, [href], [tabindex]:not([tabindex="-1"])'),
|
||||
].filter((node) => !node.hasAttribute('disabled') && node.getClientRects().length > 0);
|
||||
|
||||
/**
|
||||
* Is something painted OVER the card? A measurable box is not a visible card.
|
||||
* The attribution lightbox is a full-screen overlay at `z-index: 200` against
|
||||
@@ -401,7 +398,6 @@ export function initFirstRunExperience({
|
||||
rememberFirstRunSessionDismissed(sessionStorageRef);
|
||||
root.classList.remove('visible');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||
surfaceObserver?.disconnect();
|
||||
const remove = () => root.remove();
|
||||
@@ -411,12 +407,7 @@ export function initFirstRunExperience({
|
||||
globalThis.setTimeout?.(remove, 400);
|
||||
// Return the keyboard where it was, not to a node that is being removed —
|
||||
// but never when yielding, because the surface taking over owns focus now.
|
||||
if (!restoreFocus) return;
|
||||
if (typeof previouslyFocused?.focus === 'function' && previouslyFocused.isConnected) {
|
||||
previouslyFocused.focus({ preventScroll: true });
|
||||
} else {
|
||||
documentRef.body?.focus?.({ preventScroll: true });
|
||||
}
|
||||
keyboard.deactivate({ restoreFocus });
|
||||
};
|
||||
|
||||
const setBusy = (next, choice = '') => {
|
||||
@@ -491,60 +482,21 @@ export function initFirstRunExperience({
|
||||
status.textContent = 'This browser is blocking storage, so that could not be saved.';
|
||||
};
|
||||
|
||||
function onKeyDown(event) {
|
||||
// THE ARBITRATION RULE: never consume input for a card nobody can see.
|
||||
// The observer below normally removes the launcher before another surface
|
||||
// finishes engaging, but MutationObserver callbacks are microtasks, so a
|
||||
// keydown can still arrive in the window between the class landing and the
|
||||
// yield running. This check closes that window deterministically.
|
||||
if (closing || !isTopmost()) return;
|
||||
// AND THE BELT FOR THE COOPERATIVE HALF. A surface that owns this key marks
|
||||
// it handled (preventDefault) and silences the rest of us on the way past
|
||||
// (stopImmediatePropagation). If one of them ever ships only the first half,
|
||||
// the mark alone still keeps ONE key to ONE action — which is precisely what
|
||||
// the compact Radio disclosure did with a plain stopPropagation(), a call
|
||||
// that never blocks later listeners on the same document.
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key === 'Escape') {
|
||||
// ESC is an exit, not a mission: it must work even mid-flight. The
|
||||
// launcher is the topmost surface while it is up, so it consumes the key
|
||||
// rather than also closing a panel the visitor cannot see behind it.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
// Keep Tab inside the launcher while it is up. The rest of the page is
|
||||
// deliberately still live to the mouse, so this stops short of claiming
|
||||
// `aria-modal` — it confines the keyboard without asserting the map is inert.
|
||||
const order = focusables();
|
||||
if (!order.length) return;
|
||||
const first = order[0];
|
||||
const last = order[order.length - 1];
|
||||
const active = documentRef.activeElement;
|
||||
// Plain focus(), NOT preventScroll: on a short viewport the mission list
|
||||
// scrolls inside the card, and a tile the keyboard just reached has to be
|
||||
// brought into view rather than focused somewhere off-screen.
|
||||
if (!root.contains(active)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
const keyboard = createSurfaceKeyboard({
|
||||
root,
|
||||
documentRef,
|
||||
// A measurable card may still be hidden by another surface. Check on the
|
||||
// key itself, before the observer has had a chance to process that change.
|
||||
isActive: () => !closing && isTopmost(),
|
||||
onEscape: () => dismiss(),
|
||||
fallbackFocus: () => documentRef.body,
|
||||
});
|
||||
|
||||
for (const button of buttons) button.addEventListener('click', onChoice);
|
||||
suppressBox?.addEventListener('change', onSuppressChange);
|
||||
// Capture phase: the app binds its own global hotkeys (including bare letters
|
||||
// that cycle detection and styles), and the launcher owns the keyboard first.
|
||||
documentRef.addEventListener('keydown', onKeyDown, true);
|
||||
keyboard.activate();
|
||||
|
||||
// The scroll fade is an affordance, so it may only appear when the list really
|
||||
// overflows. On a viewport where all five tiles fit, a faded bottom edge would
|
||||
@@ -646,7 +598,7 @@ export function initFirstRunExperience({
|
||||
// Teardown is not a user dismissal and must not change the show preference.
|
||||
const destroy = () => {
|
||||
closing = true;
|
||||
documentRef.removeEventListener('keydown', onKeyDown, true);
|
||||
keyboard.destroy();
|
||||
globalThis.removeEventListener?.('resize', onViewportResize);
|
||||
surfaceObserver?.disconnect();
|
||||
root.remove();
|
||||
|
||||
Reference in new issue
Block a user