@@ -41,7 +41,8 @@ import {
4141 getContainerNode ,
4242 getContainersForNode ,
4343 getLastContainer ,
44- getOwnedNodes ,
44+ getOwnedSelfOnlyContainerIds ,
45+ getPopupOwnerElement ,
4546 getSpottableDescendants ,
4647 isContainer ,
4748 isContainer5WayHoldable ,
@@ -98,19 +99,12 @@ const isRight = is('right');
9899const isTab = is ( 'tab' ) ;
99100const isUp = is ( 'up' ) ;
100101
101- // Two elements whose vertical centers are within this many pixels are treated as being on the same
102- // visual row, so horizontal position decides Tab order between them. 24 px covers typical
103- // line-height variance (e.g. 20 px text + 4 px leading) while still separating distinct rows.
102+ // Vertical centers within this distance share a Tab row; horizontal position breaks ties.
104103const TAB_ROW_THRESHOLD = 24 ;
105104
106- // Per-keypress cache for getLinearTargetsInContainer. Keyed by containerId; populated lazily
107- // during one synchronous Tab onKeyDown call and cleared before the next. The DOM cannot change
108- // between two synchronous calls within the same event handler, so no further invalidation logic
109- // is needed.
105+ // Per-keypress cache for getLinearTargetsInContainer, scoped to one handleTab call.
110106let _linearTargetsCache = null ;
111107
112- // Populated by the Spotlight IIFE; exported for unit tests only.
113- // Not part of the public Spotlight API — import from this module path directly in tests.
114108let _tabNavTestHooks = { } ;
115109
116110/**
@@ -537,65 +531,13 @@ const Spotlight = (function () {
537531 return false ;
538532 }
539533
540- function getPopupOwnerElement ( containerNode ) {
541- if ( ! containerNode ) {
542- return null ;
543- }
544-
545- const owners = document . querySelectorAll ( '[aria-owns]' ) ;
546- for ( const owner of owners ) {
547- if ( getOwnedNodes ( owner , '*' ) . some ( n => n . contains ( containerNode ) ) ) {
548- return owner ;
549- }
550- }
551-
552- return null ;
553- }
554-
555- /*
556- * Returns the IDs of all `self-only` spotlight containers reachable through the `aria-owns`
557- * of `ownerNode`, excluding `excludeContainerId`. Handles both the common case where
558- * `aria-owns` points to a float-layer wrapper div (so containers are found via querySelector)
559- * and the direct case where `aria-owns` points to the container node itself.
560- */
561- function getOwnedSelfOnlyContainerIds ( ownerNode , excludeContainerId ) {
562- const containerSelector = '[data-spotlight-container][data-spotlight-id]' ;
563- const ids = [ ] ;
564-
565- for ( const ownedNode of getOwnedNodes ( ownerNode , '*' ) ) {
566- const containerNodes = [
567- ...( ownedNode . matches ?. ( containerSelector ) ? [ ownedNode ] : [ ] ) ,
568- ...ownedNode . querySelectorAll ( containerSelector )
569- ] ;
570- for ( const containerNode of containerNodes ) {
571- const id = getContainerId ( containerNode ) ;
572- if ( ! id || id === excludeContainerId ) continue ;
573- const cfg = getContainerConfig ( id ) ;
574- if ( cfg ?. restrict === 'self-only' ) {
575- ids . push ( id ) ;
576- }
577- }
578- }
579-
580- return ids ;
581- }
582-
583- // Returns the nearest ancestor (including self) that carries `aria-owns`, or null when the
584- // node is missing, reaches the body, or has no such ancestor. Used to identify popup owners.
585- function getAriaOwnerNode ( target ) {
586- const ownerNode = target ?. closest ( '[aria-owns]' ) ;
587- return ownerNode && ownerNode !== document . body ? ownerNode : null ;
588- }
589-
590534 function resolveTargetToOpenPopupItem ( target , currentPopupContainerId , isForward ) {
591- const ownerNode = getAriaOwnerNode ( target ) ;
592- if ( ! ownerNode ) {
535+ const ownerNode = target ?. closest ?. ( '[aria-owns]' ) ;
536+ if ( ! ownerNode || ownerNode === document . body ) {
593537 return target ;
594538 }
595539
596540 for ( const popupContainerId of getOwnedSelfOnlyContainerIds ( ownerNode , currentPopupContainerId ) ) {
597- // Preserve linear order when entering another open dropdown: Tab forward starts at
598- // first option, Shift+Tab starts at last option.
599541 const popupTargets = getLinearTargetsInContainer ( popupContainerId ) ;
600542 const popupTargetEntry = isForward ? popupTargets [ 0 ] : popupTargets [ popupTargets . length - 1 ] ;
601543 const popupTarget = popupTargetEntry ?. target || getTargetByContainer ( popupContainerId ) ;
@@ -639,10 +581,7 @@ const Spotlight = (function () {
639581 comesBeforeInTabOrder ( ax , ay , cx , cy , isRtl ) :
640582 comesBeforeInTabOrder ( cx , cy , ax , ay , isRtl ) ;
641583 if ( inOrder ) {
642- // Return the first in-order target regardless of whether it is a popup owner.
643- // resolveTargetToOpenPopupItem will redirect into an open popup when appropriate.
644- // Deferring popup owners as a fallback causes plain controls later in visual
645- // order to win instead, which breaks the expected a→b→c traversal chain.
584+ // Do not defer popup owners; resolveTargetToOpenPopupItem redirects into open popups.
646585 return target ;
647586 }
648587 }
@@ -666,8 +605,6 @@ const Spotlight = (function () {
666605 const isRtl = isRtlDocument ( ) ;
667606 const rootTargets = getLinearTargetsInContainer ( rootContainerId ) ;
668607
669- // For forward Tab, prefer continuity from the actual focused option so the next nearby
670- // control in visual order wins after the popup boundary.
671608 if ( isForward ) {
672609 const fallbackTarget = findLinearTabExitTargetInTargets (
673610 rootTargets ,
@@ -681,14 +618,9 @@ const Spotlight = (function () {
681618 }
682619 }
683620
684- // Anchor exit traversal to the popup owner first so Tab completes the opened dropdown and
685- // then continues through surrounding nearby controls in the base layout order.
686621 if ( popupOwner ) {
687622 const ownerRect = getRect ( popupOwner ) ;
688- // Use top+1 rather than the vertical center so the anchor sits at the leading edge of
689- // the owner's bounding box. This keeps controls that are visually level with the top
690- // of the trigger button in the same TAB_ROW_THRESHOLD band, preventing them from
691- // being incorrectly classified as a separate row when the owner button is tall.
623+ // top+1 keeps row-level neighbors in the same TAB_ROW_THRESHOLD band as a tall owner button.
692624 const ownerTarget = findLinearTabExitTargetInTargets (
693625 rootTargets ,
694626 ownerRect . center . x ,
@@ -701,7 +633,6 @@ const Spotlight = (function () {
701633 }
702634 }
703635
704- // Reverse Shift+Tab fallback keeps previous behavior when owner-based handoff has no match.
705636 if ( ! isForward ) {
706637 const fallbackTarget = findLinearTabExitTargetInTargets (
707638 rootTargets ,
@@ -717,6 +648,20 @@ const Spotlight = (function () {
717648 return null ;
718649 }
719650
651+ function getLinearTargetContainerId ( target ) {
652+ // Prefer DOM ancestry over getContainersForNode so aria-owns owners stay in root Tab order.
653+ const containerNode = target ?. closest ?. ( '[data-spotlight-container][data-spotlight-id]' ) ;
654+ if ( containerNode ) {
655+ const domContainerId = getContainerId ( containerNode ) ;
656+ if ( domContainerId ) {
657+ return domContainerId ;
658+ }
659+ }
660+
661+ const containerIds = getContainersForNode ( target ) ;
662+ return last ( containerIds ) ;
663+ }
664+
720665 function getLinearTargetsInContainer ( containerId ) {
721666 if ( _linearTargetsCache ?. has ( containerId ) ) {
722667 return _linearTargetsCache . get ( containerId ) ;
@@ -725,20 +670,6 @@ const Spotlight = (function () {
725670 const isRtl = isRtlDocument ( ) ;
726671 const visitedContainers = new Set ( ) ;
727672 const visitedTargets = new Set ( ) ;
728- const getLinearTargetContainerId = ( target ) => {
729- // Resolve by DOM ancestry first so popup owners linked through aria-owns are treated as
730- // part of their visual/root context rather than being pulled into an owned self-only popup.
731- const containerNode = target ?. closest ?. ( '[data-spotlight-container][data-spotlight-id]' ) ;
732- if ( containerNode ) {
733- const domContainerId = getContainerId ( containerNode ) ;
734- if ( domContainerId ) {
735- return domContainerId ;
736- }
737- }
738-
739- const containerIds = getContainersForNode ( target ) ;
740- return last ( containerIds ) ;
741- } ;
742673
743674 const gatherSpottableLeaves = ( id ) => {
744675 if ( ! id || visitedContainers . has ( id ) ) {
@@ -785,7 +716,6 @@ const Spotlight = (function () {
785716 function spotLinear ( isForward ) {
786717 let currentFocusedElement = getCurrent ( ) ;
787718
788- // If there is no currently focused spottable target, bootstrap focus first.
789719 if ( ! currentFocusedElement ) {
790720 if ( ! restoreFocus ( ) ) {
791721 return false ;
@@ -796,17 +726,6 @@ const Spotlight = (function () {
796726 }
797727 }
798728
799- const currentContainerIds = getContainersForNode ( currentFocusedElement ) ;
800- if ( ! currentContainerIds . length ) {
801- if ( ! restoreFocus ( ) ) {
802- return false ;
803- }
804- currentFocusedElement = getCurrent ( ) ;
805- if ( ! currentFocusedElement ) {
806- return false ;
807- }
808- }
809-
810729 const searchContainerId = getLinearTabSearchContainerId ( currentFocusedElement ) ;
811730 const linearTargets = getLinearTargetsInContainer ( searchContainerId ) ;
812731 if ( ! linearTargets . length ) {
@@ -859,21 +778,8 @@ const Spotlight = (function () {
859778 }
860779
861780 /*
862- * Shared Tab/Shift+Tab handler: resets pointer mode, runs the linear traversal with a
863- * fresh per-keypress cache, and calls preventDefault when focus actually moved.
864- * Returns true when spotLinear successfully moved focus.
865- */
866- /*
867- * Unified Tab/Shift+Tab handler. All Tab-specific dispatch that previously lived inline in
868- * onKeyDown is consolidated here so the 5-way path stays uncluttered.
869- *
870- * When shouldPreventNavigation() is true (paused or no containers), notifyKeyDown is called
871- * without the pointer callback and navigation still runs. preventDefault is called when focus
872- * moved OR when Spotlight is actively paused (modal open) to prevent the browser from
873- * escaping the overlay even when no Spotlight target was found.
874- *
875- * When the pointer moved during this key press, notifyKeyDown already resets pointer mode so
876- * navigation is skipped cleanly without an extra setPointerMode call.
781+ * Tab/Shift+Tab handler. Runs spotLinear with a per-keypress target cache and calls
782+ * preventDefault when focus moved or when paused (to keep Tab inside a modal overlay).
877783 */
878784 function handleTab ( evt ) {
879785 const keyCode = evt . keyCode ;
@@ -1429,23 +1335,16 @@ const Spotlight = (function () {
14291335
14301336 } ;
14311337
1432- // Expose internals for unit tests without polluting the public Spotlight API.
14331338 _tabNavTestHooks = {
14341339 findLinearTabExitTarget,
14351340 getLinearTabSearchContainerId,
14361341 comesBeforeInTabOrder,
1437- getPopupOwnerElement,
14381342 resolveTargetToOpenPopupItem,
14391343 isTargetInSelfOnlyContainer,
14401344 findLinearTabExitTargetInTargets,
1345+ getLinearTargetContainerId,
14411346 getLinearTargetsInContainer,
1442- spotLinear,
1443- onAcceleratedKeyDown,
1444- onBlur,
1445- onFocus,
1446- handleWebOSMouseEvent,
1447- handleKeyboardStateChangeEvent,
1448- onKeyUp
1347+ spotLinear
14491348 } ;
14501349
14511350 return exports ;
0 commit comments