@vaadin/popover 25.2.0-alpha8 → 25.2.0-alpha9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +36 -0
- package/package.json +11 -11
- package/src/vaadin-popover-focus-controller.d.ts +35 -0
- package/src/vaadin-popover-focus-controller.js +226 -0
- package/src/vaadin-popover.js +5 -234
- package/web-types.json +1 -1
- package/web-types.lit.json +1 -1
package/custom-elements.json
CHANGED
|
@@ -17,6 +17,42 @@
|
|
|
17
17
|
}
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"kind": "javascript-module",
|
|
22
|
+
"path": "src/vaadin-popover-focus-controller.js",
|
|
23
|
+
"declarations": [
|
|
24
|
+
{
|
|
25
|
+
"kind": "class",
|
|
26
|
+
"description": "Controller that routes Tab and Shift+Tab when a non-modal popover is opened.\nThe controller's host element is the popover itself.\n\nThe popover is reachable via Tab only from its target, and its content comes\nlogically right after the target — regardless of the popover's DOM position.\nWhen the popover lives inside a focus trap (e.g. a dialog), the controller\ncooperates with the active `FocusTrapController` so the trap never lands\nfocus on the popover itself.\n\nModal popovers rely on the overlay's own focus trap; this controller bails\nout early in that case.",
|
|
27
|
+
"name": "PopoverFocusController",
|
|
28
|
+
"members": [
|
|
29
|
+
{
|
|
30
|
+
"kind": "method",
|
|
31
|
+
"name": "activate"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"kind": "method",
|
|
35
|
+
"name": "deactivate"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"kind": "field",
|
|
39
|
+
"name": "host",
|
|
40
|
+
"default": "host"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"exports": [
|
|
46
|
+
{
|
|
47
|
+
"kind": "js",
|
|
48
|
+
"name": "PopoverFocusController",
|
|
49
|
+
"declaration": {
|
|
50
|
+
"name": "PopoverFocusController",
|
|
51
|
+
"module": "src/vaadin-popover-focus-controller.js"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
},
|
|
20
56
|
{
|
|
21
57
|
"kind": "javascript-module",
|
|
22
58
|
"path": "src/vaadin-popover-overlay-mixin.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaadin/popover",
|
|
3
|
-
"version": "25.2.0-
|
|
3
|
+
"version": "25.2.0-alpha9",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -37,19 +37,19 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@open-wc/dedupe-mixin": "^1.3.0",
|
|
40
|
-
"@vaadin/a11y-base": "25.2.0-
|
|
41
|
-
"@vaadin/component-base": "25.2.0-
|
|
42
|
-
"@vaadin/lit-renderer": "25.2.0-
|
|
43
|
-
"@vaadin/overlay": "25.2.0-
|
|
44
|
-
"@vaadin/vaadin-themable-mixin": "25.2.0-
|
|
40
|
+
"@vaadin/a11y-base": "25.2.0-alpha9",
|
|
41
|
+
"@vaadin/component-base": "25.2.0-alpha9",
|
|
42
|
+
"@vaadin/lit-renderer": "25.2.0-alpha9",
|
|
43
|
+
"@vaadin/overlay": "25.2.0-alpha9",
|
|
44
|
+
"@vaadin/vaadin-themable-mixin": "25.2.0-alpha9",
|
|
45
45
|
"lit": "^3.0.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@vaadin/aura": "25.2.0-
|
|
49
|
-
"@vaadin/chai-plugins": "25.2.0-
|
|
50
|
-
"@vaadin/test-runner-commands": "25.2.0-
|
|
48
|
+
"@vaadin/aura": "25.2.0-alpha9",
|
|
49
|
+
"@vaadin/chai-plugins": "25.2.0-alpha9",
|
|
50
|
+
"@vaadin/test-runner-commands": "25.2.0-alpha9",
|
|
51
51
|
"@vaadin/testing-helpers": "^2.0.0",
|
|
52
|
-
"@vaadin/vaadin-lumo-styles": "25.2.0-
|
|
52
|
+
"@vaadin/vaadin-lumo-styles": "25.2.0-alpha9",
|
|
53
53
|
"sinon": "^21.0.2"
|
|
54
54
|
},
|
|
55
55
|
"customElements": "custom-elements.json",
|
|
@@ -57,5 +57,5 @@
|
|
|
57
57
|
"web-types.json",
|
|
58
58
|
"web-types.lit.json"
|
|
59
59
|
],
|
|
60
|
-
"gitHead": "
|
|
60
|
+
"gitHead": "a38a03e8a8be45821f39c14054c63634dafe08d0"
|
|
61
61
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/vaadin-popover.js
CHANGED
|
@@ -6,13 +6,7 @@
|
|
|
6
6
|
import './vaadin-popover-overlay.js';
|
|
7
7
|
import { css, html, LitElement } from 'lit';
|
|
8
8
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
getDeepActiveElement,
|
|
12
|
-
getFocusableElements,
|
|
13
|
-
isElementFocused,
|
|
14
|
-
isKeyboardActive,
|
|
15
|
-
} from '@vaadin/a11y-base/src/focus-utils.js';
|
|
9
|
+
import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
|
|
16
10
|
import { defineCustomElement } from '@vaadin/component-base/src/define.js';
|
|
17
11
|
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
|
|
18
12
|
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
|
|
@@ -22,6 +16,7 @@ import {
|
|
|
22
16
|
isLastOverlay as isLastOverlayBase,
|
|
23
17
|
} from '@vaadin/overlay/src/vaadin-overlay-stack-mixin.js';
|
|
24
18
|
import { ThemePropertyMixin } from '@vaadin/vaadin-themable-mixin/vaadin-theme-property-mixin.js';
|
|
19
|
+
import { PopoverFocusController } from './vaadin-popover-focus-controller.js';
|
|
25
20
|
import { PopoverPositionMixin } from './vaadin-popover-position-mixin.js';
|
|
26
21
|
import { PopoverTargetMixin } from './vaadin-popover-target-mixin.js';
|
|
27
22
|
|
|
@@ -486,7 +481,6 @@ class Popover extends PopoverPositionMixin(
|
|
|
486
481
|
|
|
487
482
|
this.__generatedId = `vaadin-popover-${generateUniqueId()}`;
|
|
488
483
|
|
|
489
|
-
this.__onGlobalKeyDown = this.__onGlobalKeyDown.bind(this);
|
|
490
484
|
this.__onTargetClick = this.__onTargetClick.bind(this);
|
|
491
485
|
this.__onTargetFocusIn = this.__onTargetFocusIn.bind(this);
|
|
492
486
|
this.__onTargetFocusOut = this.__onTargetFocusOut.bind(this);
|
|
@@ -494,6 +488,7 @@ class Popover extends PopoverPositionMixin(
|
|
|
494
488
|
this.__onTargetMouseLeave = this.__onTargetMouseLeave.bind(this);
|
|
495
489
|
|
|
496
490
|
this._openedStateController = new PopoverOpenedStateController(this);
|
|
491
|
+
this._focusController = new PopoverFocusController(this);
|
|
497
492
|
}
|
|
498
493
|
|
|
499
494
|
/** @protected */
|
|
@@ -667,9 +662,9 @@ class Popover extends PopoverPositionMixin(
|
|
|
667
662
|
/** @private */
|
|
668
663
|
__openedChanged(opened, oldOpened) {
|
|
669
664
|
if (opened) {
|
|
670
|
-
|
|
665
|
+
this._focusController.activate();
|
|
671
666
|
} else if (oldOpened) {
|
|
672
|
-
|
|
667
|
+
this._focusController.deactivate();
|
|
673
668
|
}
|
|
674
669
|
}
|
|
675
670
|
|
|
@@ -714,230 +709,6 @@ class Popover extends PopoverPositionMixin(
|
|
|
714
709
|
}
|
|
715
710
|
}
|
|
716
711
|
|
|
717
|
-
/**
|
|
718
|
-
* Overlay's global Escape press listener doesn't work when
|
|
719
|
-
* the overlay is modeless, so we use a separate listener.
|
|
720
|
-
* @private
|
|
721
|
-
*/
|
|
722
|
-
__onGlobalKeyDown(event) {
|
|
723
|
-
// Modal popover uses overlay logic focus trap.
|
|
724
|
-
if (this.modal) {
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// Include popover content in the Tab order after the target.
|
|
729
|
-
if (event.key === 'Tab') {
|
|
730
|
-
if (event.shiftKey) {
|
|
731
|
-
this.__onGlobalShiftTab(event);
|
|
732
|
-
} else {
|
|
733
|
-
this.__onGlobalTab(event);
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/** @private */
|
|
739
|
-
__onGlobalTab(event) {
|
|
740
|
-
// Move focus to the popover on target element Tab
|
|
741
|
-
if (this.target && isElementFocused(this.__getTargetFocusable())) {
|
|
742
|
-
event.preventDefault();
|
|
743
|
-
this.focus();
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
|
|
748
|
-
const focusables = this.__getScopeFocusables();
|
|
749
|
-
|
|
750
|
-
// Move focus to the next element after target on last content Tab,
|
|
751
|
-
// or when popover itself is focused and has no focusable content
|
|
752
|
-
const lastFocusable = this.__getLastFocusable();
|
|
753
|
-
const isFocusOut = lastFocusable ? isElementFocused(lastFocusable) : isElementFocused(this);
|
|
754
|
-
if (isFocusOut) {
|
|
755
|
-
let focusable = this.__getNextScopeFocusable(this.__getTargetFocusable(), focusables);
|
|
756
|
-
// If the next element after the target is the popover itself (DOM position
|
|
757
|
-
// differs from logical position), skip past it to the actual next element.
|
|
758
|
-
if (focusable === this) {
|
|
759
|
-
focusable = this.__getNextScopeFocusable(this, focusables);
|
|
760
|
-
}
|
|
761
|
-
if (focusable) {
|
|
762
|
-
event.preventDefault();
|
|
763
|
-
focusable.focus();
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
// No next element after the target in the scope. When inside a focus trap,
|
|
767
|
-
// wrap explicitly to the first focusable. Don't fall through - the
|
|
768
|
-
// FocusTrapController uses DOM order which may differ from the popover's
|
|
769
|
-
// logical tab position.
|
|
770
|
-
if (getActiveTrappingNode(this) && focusables[0]) {
|
|
771
|
-
event.preventDefault();
|
|
772
|
-
focusables[0].focus();
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Handle cases where Tab from the current element would land on the popover
|
|
778
|
-
const activeElement = getDeepActiveElement();
|
|
779
|
-
const nextFocusable = this.__getNextScopeFocusable(activeElement, focusables);
|
|
780
|
-
if (nextFocusable === this) {
|
|
781
|
-
// The popover should only be Tab-reachable from its target (handled above).
|
|
782
|
-
// Skip the popover when Tab from any other element would land on it
|
|
783
|
-
// due to its DOM position.
|
|
784
|
-
const focusableAfterPopover = this.__getNextScopeFocusable(this, focusables);
|
|
785
|
-
if (focusableAfterPopover) {
|
|
786
|
-
event.preventDefault();
|
|
787
|
-
focusableAfterPopover.focus();
|
|
788
|
-
} else if (getActiveTrappingNode(this) && focusables[0]) {
|
|
789
|
-
// Popover is last in DOM scope but shouldn't be Tab-reachable from
|
|
790
|
-
// non-target elements. Wrap to first focusable in focus trap.
|
|
791
|
-
event.preventDefault();
|
|
792
|
-
focusables[0].focus();
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/** @private */
|
|
798
|
-
__onGlobalShiftTab(event) {
|
|
799
|
-
// Prevent restoring focus after target blur on Shift + Tab
|
|
800
|
-
if (this.target && isElementFocused(this.__getTargetFocusable()) && this.__shouldRestoreFocus) {
|
|
801
|
-
this.__shouldRestoreFocus = false;
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Move focus back to the target on popover Shift + Tab
|
|
806
|
-
if (this.target && isElementFocused(this)) {
|
|
807
|
-
event.preventDefault();
|
|
808
|
-
this.__getTargetFocusable().focus();
|
|
809
|
-
return;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
// Don't intercept if focus is inside the popover content.
|
|
813
|
-
// The browser's native Shift+Tab handles navigation within
|
|
814
|
-
// the overlay (e.g. between focusable content and the popover element itself).
|
|
815
|
-
const activeElement = getDeepActiveElement();
|
|
816
|
-
if (this.contains(activeElement)) {
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
|
|
821
|
-
const focusables = this.__getScopeFocusables();
|
|
822
|
-
|
|
823
|
-
// Get previous focusable element excluding the popover
|
|
824
|
-
const prevFocusable = this.__getPrevScopeFocusable(activeElement, focusables);
|
|
825
|
-
const targetFocusable = this.__getTargetFocusable();
|
|
826
|
-
|
|
827
|
-
// Intercept Shift+Tab when the previous focusable (excluding the popover)
|
|
828
|
-
// is the target. Instead of moving to the target, redirect focus into
|
|
829
|
-
// the popover's last focusable content (or the popover itself).
|
|
830
|
-
if (prevFocusable === targetFocusable) {
|
|
831
|
-
event.preventDefault();
|
|
832
|
-
this.__focusLastOrSelf();
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// Move focus into the popover when:
|
|
837
|
-
// 1. There is no previous focusable element in the focus trap (at the
|
|
838
|
-
// beginning, would wrap around), and
|
|
839
|
-
// 2. The target is the last focusable in the focus trap (making the
|
|
840
|
-
// popover logically last).
|
|
841
|
-
// Don't fall through - the FocusTrapController uses DOM order which
|
|
842
|
-
// may differ from the popover's logical tab position.
|
|
843
|
-
if (!prevFocusable && getActiveTrappingNode(this)) {
|
|
844
|
-
const list = focusables.filter((el) => el !== this);
|
|
845
|
-
if (list.at(-1) === targetFocusable) {
|
|
846
|
-
event.preventDefault();
|
|
847
|
-
this.__focusLastOrSelf();
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
// Popover is last in DOM but target is not the last focusable.
|
|
851
|
-
// Wrap to last non-popover focusable to prevent FocusTrapController
|
|
852
|
-
// from landing on the popover.
|
|
853
|
-
const last = list.at(-1);
|
|
854
|
-
if (last) {
|
|
855
|
-
event.preventDefault();
|
|
856
|
-
last.focus();
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Get previous focusable element including the popover (simulates native Tab order)
|
|
862
|
-
const prevFocusableNative = this.__getPrevScopeFocusable(activeElement, focusables, true);
|
|
863
|
-
// Skip the popover when native Shift+Tab would land on it
|
|
864
|
-
// and redirect to the actual previous element
|
|
865
|
-
if (prevFocusableNative === this) {
|
|
866
|
-
if (prevFocusable) {
|
|
867
|
-
event.preventDefault();
|
|
868
|
-
prevFocusable.focus();
|
|
869
|
-
} else if (getActiveTrappingNode(this)) {
|
|
870
|
-
// Popover is first in DOM scope but shouldn't be Shift+Tab-reachable
|
|
871
|
-
// from non-target elements. Wrap to last non-popover focusable.
|
|
872
|
-
const list = focusables.filter((el) => el !== this);
|
|
873
|
-
const last = list.at(-1);
|
|
874
|
-
if (last) {
|
|
875
|
-
event.preventDefault();
|
|
876
|
-
last.focus();
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
* Returns whether the element is a light DOM child of this popover
|
|
884
|
-
* (i.e. slotted popover content, excluding the popover element itself).
|
|
885
|
-
* @param {Element} el
|
|
886
|
-
* @return {boolean}
|
|
887
|
-
* @private
|
|
888
|
-
*/
|
|
889
|
-
__isPopoverContent(el) {
|
|
890
|
-
return el !== this && this.contains(el);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
/**
|
|
894
|
-
* Returns focusable elements within the current scope (active focus trap or
|
|
895
|
-
* document body) with popover light DOM children filtered out.
|
|
896
|
-
* @return {Element[]}
|
|
897
|
-
* @private
|
|
898
|
-
*/
|
|
899
|
-
__getScopeFocusables() {
|
|
900
|
-
const scope = getActiveTrappingNode(this) || document.body;
|
|
901
|
-
return getFocusableElements(scope).filter((el) => !this.__isPopoverContent(el));
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/** @private */
|
|
905
|
-
__getNextScopeFocusable(target, focusables = this.__getScopeFocusables()) {
|
|
906
|
-
const idx = focusables.findIndex((el) => el === target);
|
|
907
|
-
return idx >= 0 ? focusables[idx + 1] : undefined;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** @private */
|
|
911
|
-
__getPrevScopeFocusable(target, focusables = this.__getScopeFocusables(), includePopover = false) {
|
|
912
|
-
const list = includePopover ? focusables : focusables.filter((el) => el !== this);
|
|
913
|
-
const idx = list.findIndex((el) => el === target);
|
|
914
|
-
// Returns null both when target is the first element (idx === 0)
|
|
915
|
-
// and when target is not found in the list (idx === -1)
|
|
916
|
-
return idx > 0 ? list[idx - 1] : null;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
/** @private */
|
|
920
|
-
__getLastFocusable() {
|
|
921
|
-
// Search within the overlay's content area to avoid returning the popover element itself
|
|
922
|
-
const focusables = getFocusableElements(this._overlayElement.$.content);
|
|
923
|
-
return focusables.pop();
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/** @private */
|
|
927
|
-
__focusLastOrSelf() {
|
|
928
|
-
(this.__getLastFocusable() || this).focus();
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
/** @private */
|
|
932
|
-
__getTargetFocusable() {
|
|
933
|
-
if (!this.target) {
|
|
934
|
-
return null;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
// If target has `focusElement`, check if that one is focused.
|
|
938
|
-
return this.target.focusElement || this.target;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
712
|
/** @private */
|
|
942
713
|
__onTargetFocusIn() {
|
|
943
714
|
this.__focusInside = true;
|
package/web-types.json
CHANGED