Skip to content

Commit 597a99e

Browse files
web-padawanclaude
andauthored
refactor: extract popover focus management into a controller (#11535)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e39488d commit 597a99e

3 files changed

Lines changed: 266 additions & 234 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2024 - 2026 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import type { Popover } from './vaadin-popover.js';
7+
8+
/**
9+
* Controller that routes Tab and Shift+Tab when a non-modal popover is opened.
10+
* The controller's host element is the popover itself.
11+
*
12+
* The popover is reachable via Tab only from its target, and its content comes
13+
* logically right after the target — regardless of the popover's DOM position.
14+
* When the popover lives inside a focus trap (e.g. a dialog), the controller
15+
* cooperates with the active `FocusTrapController` so the trap never lands
16+
* focus on the popover itself.
17+
*
18+
* Modal popovers rely on the overlay's own focus trap; this controller bails
19+
* out early in that case.
20+
*/
21+
export class PopoverFocusController {
22+
host: Popover;
23+
24+
constructor(host: Popover);
25+
26+
/**
27+
* Starts listening for Tab keystrokes. Called when the popover opens.
28+
*/
29+
activate(): void;
30+
31+
/**
32+
* Stops listening for Tab keystrokes. Called when the popover closes.
33+
*/
34+
deactivate(): void;
35+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2024 - 2026 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import { getActiveTrappingNode } from '@vaadin/a11y-base/src/focus-trap-controller.js';
7+
import { getDeepActiveElement, getFocusableElements, isElementFocused } from '@vaadin/a11y-base/src/focus-utils.js';
8+
9+
/**
10+
* Controller that routes Tab and Shift+Tab when a non-modal popover is opened.
11+
* The controller's host element is the popover itself.
12+
*
13+
* The popover is reachable via Tab only from its target, and its content comes
14+
* logically right after the target — regardless of the popover's DOM position.
15+
* When the popover lives inside a focus trap (e.g. a dialog), the controller
16+
* cooperates with the active `FocusTrapController` so the trap never lands
17+
* focus on the popover itself.
18+
*
19+
* Modal popovers rely on the overlay's own focus trap; this controller bails
20+
* out early in that case.
21+
*/
22+
export class PopoverFocusController {
23+
constructor(host) {
24+
this.host = host;
25+
this.__onKeyDown = this.__onKeyDown.bind(this);
26+
}
27+
28+
activate() {
29+
document.addEventListener('keydown', this.__onKeyDown, true);
30+
}
31+
32+
deactivate() {
33+
document.removeEventListener('keydown', this.__onKeyDown, true);
34+
}
35+
36+
/** @private */
37+
__handleTab(event) {
38+
const host = this.host;
39+
const targetFocusable = this.__getTargetFocusable();
40+
41+
if (targetFocusable && isElementFocused(targetFocusable)) {
42+
event.preventDefault();
43+
host.focus();
44+
return;
45+
}
46+
47+
const lastPopoverFocusable = this.__getLastPopoverFocusable();
48+
if (isElementFocused(lastPopoverFocusable)) {
49+
this.__moveLogicalNext(event, host);
50+
return;
51+
}
52+
53+
// Native Tab would land on the popover when DOM order places it right
54+
// after the current element. Skip past it via the logical list.
55+
const activeElement = getDeepActiveElement();
56+
const scopeFocusables = this.__getScopeFocusables();
57+
const activeIdx = scopeFocusables.indexOf(activeElement);
58+
if (activeIdx >= 0 && scopeFocusables[activeIdx + 1] === host) {
59+
this.__moveLogicalNext(event, activeElement, scopeFocusables);
60+
}
61+
}
62+
63+
/** @private */
64+
__handleShiftTab(event) {
65+
const host = this.host;
66+
const targetFocusable = this.__getTargetFocusable();
67+
68+
// Just clear the flag so native Shift+Tab from the target doesn't reopen.
69+
if (targetFocusable && isElementFocused(targetFocusable) && host.__shouldRestoreFocus) {
70+
host.__shouldRestoreFocus = false;
71+
return;
72+
}
73+
74+
if (isElementFocused(host)) {
75+
event.preventDefault();
76+
targetFocusable.focus();
77+
return;
78+
}
79+
80+
// Browser handles Shift+Tab inside popover content.
81+
const activeElement = getDeepActiveElement();
82+
if (host.contains(activeElement)) {
83+
return;
84+
}
85+
86+
const scopeFocusables = this.__getScopeFocusables();
87+
const logicalFocusables = this.__buildLogicalList(scopeFocusables);
88+
const activeLogicalIdx = logicalFocusables.indexOf(activeElement);
89+
const prevFocusable = activeLogicalIdx > 0 ? logicalFocusables[activeLogicalIdx - 1] : null;
90+
91+
// When the logical previous is the popover, move focus into the popover tail.
92+
if (prevFocusable === host) {
93+
event.preventDefault();
94+
this.__getLastPopoverFocusable().focus();
95+
return;
96+
}
97+
98+
// Native Shift+Tab would land on the popover: skip it and redirect to the
99+
// true logical previous, or wrap when at the logical start inside a trap.
100+
const activeScopeIdx = scopeFocusables.indexOf(activeElement);
101+
if (activeScopeIdx > 0 && scopeFocusables[activeScopeIdx - 1] === host) {
102+
if (prevFocusable) {
103+
event.preventDefault();
104+
prevFocusable.focus();
105+
} else if (getActiveTrappingNode(host)) {
106+
this.__wrapToLogicalLast(event, logicalFocusables);
107+
}
108+
return;
109+
}
110+
111+
// At the logical start of a trap: wrap to the logical last. When the
112+
// popover is the logical last (target is last), this lands on the popover tail.
113+
if (!prevFocusable && getActiveTrappingNode(host)) {
114+
this.__wrapToLogicalLast(event, logicalFocusables);
115+
}
116+
}
117+
118+
/** @private */
119+
__onKeyDown(event) {
120+
// Modal popovers rely on the overlay's focus trap.
121+
if (this.host.modal) {
122+
return;
123+
}
124+
if (event.key !== 'Tab') {
125+
return;
126+
}
127+
if (event.shiftKey) {
128+
this.__handleShiftTab(event);
129+
} else {
130+
this.__handleTab(event);
131+
}
132+
}
133+
134+
/** @private */
135+
__getTargetFocusable() {
136+
const target = this.host.target;
137+
if (!target) {
138+
return null;
139+
}
140+
return target.focusElement || target;
141+
}
142+
143+
/**
144+
* The popover's tail element: the last focusable inside the popover's content
145+
* area, or the popover itself when it has no focusable content.
146+
* @private
147+
*/
148+
__getLastPopoverFocusable() {
149+
const lastContent = getFocusableElements(this.host._overlayElement.$.content).pop();
150+
return lastContent || this.host;
151+
}
152+
153+
/**
154+
* DOM-ordered focusables in the current scope (active focus trap, or document
155+
* body), with popover light-DOM descendants excluded but the popover itself
156+
* retained. Used to detect DOM adjacency to the popover.
157+
* @private
158+
*/
159+
__getScopeFocusables() {
160+
const host = this.host;
161+
const scope = getActiveTrappingNode(host) || document.body;
162+
return getFocusableElements(scope).filter((el) => el === host || !host.contains(el));
163+
}
164+
165+
/**
166+
* Scope focusables in *logical* tab order: the popover is moved from its DOM
167+
* position to right after the target focusable. The popover is left out of
168+
* the list entirely when there is no target.
169+
* @private
170+
*/
171+
__buildLogicalList(scopeFocusables = this.__getScopeFocusables()) {
172+
const host = this.host;
173+
const targetFocusable = this.__getTargetFocusable();
174+
const logicalFocusables = scopeFocusables.filter((el) => el !== host);
175+
176+
if (targetFocusable && targetFocusable !== host) {
177+
const targetIdx = logicalFocusables.indexOf(targetFocusable);
178+
if (targetIdx >= 0) {
179+
logicalFocusables.splice(targetIdx + 1, 0, host);
180+
}
181+
}
182+
return logicalFocusables;
183+
}
184+
185+
/** @private */
186+
__moveLogicalNext(event, from, scopeFocusables) {
187+
const host = this.host;
188+
const logicalFocusables = this.__buildLogicalList(scopeFocusables);
189+
const fromIdx = logicalFocusables.indexOf(from);
190+
if (fromIdx < 0) {
191+
return;
192+
}
193+
194+
// The popover sits right after the target in the logical list, so it can
195+
// be the logical next only when `from` is the target. Skip it: the popover
196+
// is Tab-reachable only from its target (handled in __handleTab case 1).
197+
let nextIdx = fromIdx + 1;
198+
if (logicalFocusables[nextIdx] === host) {
199+
nextIdx += 1;
200+
}
201+
202+
// Past the end inside a trap: wrap to the first element. The popover
203+
// never sits at position 0 (it only follows a target), so list[0] is real.
204+
let focusable = logicalFocusables[nextIdx];
205+
if (!focusable && getActiveTrappingNode(host)) {
206+
focusable = logicalFocusables[0];
207+
}
208+
209+
if (focusable) {
210+
event.preventDefault();
211+
focusable.focus();
212+
}
213+
}
214+
215+
/** @private */
216+
__wrapToLogicalLast(event, logicalFocusables) {
217+
const logicalLast = logicalFocusables.at(-1);
218+
if (!logicalLast) {
219+
return;
220+
}
221+
// When the popover is the logical last, land on the popover tail instead.
222+
const focusable = logicalLast === this.host ? this.__getLastPopoverFocusable() : logicalLast;
223+
event.preventDefault();
224+
focusable.focus();
225+
}
226+
}

0 commit comments

Comments
 (0)