Phase 17: UI Optimization & Polish #24
@@ -29,12 +29,22 @@ export function useFocusTrap(
|
||||
).filter((el) => {
|
||||
// Exclude disabled / explicitly-untabbable nodes …
|
||||
if (el.hasAttribute('disabled') || el.getAttribute('tabindex') === '-1') return false;
|
||||
// … and the `hidden` attribute, which is unambiguous regardless of layout.
|
||||
if (el.hasAttribute('hidden')) return false;
|
||||
// … and nodes that are not actually rendered/visible (WR-01). A focusable
|
||||
// inside a hidden/collapsed block would otherwise become the computed
|
||||
// first/last and `last.focus()` would no-op, leaking Tab to background
|
||||
// content that `aria-modal="true"` promises is unreachable.
|
||||
if (el.hasAttribute('hidden') || el.offsetParent === null) return false;
|
||||
//
|
||||
// Guard against a non-layout environment (jsdom): there, every node reports
|
||||
// all-zero geometry and a null offsetParent, so applying the visibility
|
||||
// heuristic unconditionally would reject *every* focusable and silently
|
||||
// disable the trap. Only filter on visibility when there is positive
|
||||
// evidence a layout engine is present; otherwise treat the node as visible.
|
||||
const r = el.getBoundingClientRect();
|
||||
const hasLayout = r.width > 0 || r.height > 0 || el.offsetParent !== null;
|
||||
if (!hasLayout) return true; // no layout engine → don't filter on visibility
|
||||
if (el.offsetParent === null) return false;
|
||||
return r.width > 0 && r.height > 0;
|
||||
});
|
||||
|
||||
@@ -43,16 +53,12 @@ export function useFocusTrap(
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
// Containment guard (IN-01): if focus has somehow landed outside the dialog
|
||||
// (programmatic focus, browser quirk), neither boundary condition below
|
||||
// matches and Tab would walk background content. Pull focus back to the
|
||||
// first focusable instead of relying on a boundary-only trap.
|
||||
if (!dialogRef.current.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Boundary-only trap (IN-01): this handler is wired to the dialog's own
|
||||
// onKeyDown, so it only runs while focus is already inside the dialog
|
||||
// subtree — a `document`-level containment guard would be required to pull
|
||||
// back focus that originates outside, and is unnecessary for the current
|
||||
// always-focus-the-heading-on-open flows. We intentionally do not claim a
|
||||
// containment guarantee the wiring cannot provide.
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: if on first element, wrap to last
|
||||
if (document.activeElement === first) {
|
||||
|
||||
Reference in New Issue
Block a user