• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

adobe / spectrum-web-components / 18569115951

16 Oct 2025 05:10PM UTC coverage: 97.569% (-0.4%) from 97.957%
18569115951

Pull #5809

github

web-flow
Merge c124b0c02 into 7f9549f8c
Pull Request #5809: 1042-form-field-mixin

5382 of 5690 branches covered (94.59%)

Branch coverage included in aggregate %.

330 of 390 new or added lines in 4 files covered. (84.62%)

91 existing lines in 6 files now uncovered.

34354 of 35036 relevant lines covered (98.05%)

589.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.97
/packages/overlay/src/OverlayStack.ts
1
/**
45✔
2
 * Copyright 2025 Adobe. All rights reserved.
45✔
3
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
45✔
4
 * you may not use this file except in compliance with the License. You may obtain a copy
45✔
5
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
45✔
6
 *
45✔
7
 * Unless required by applicable law or agreed to in writing, software distributed under
45✔
8
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
45✔
9
 * OF ANY KIND, either express or implied. See the License for the specific language
45✔
10
 * governing permissions and limitations under the License.
45✔
11
 */
45✔
12

45✔
13
import { Overlay } from './Overlay.js';
45✔
14

45✔
15
const supportsPopover = 'showPopover' in document.createElement('div');
45✔
16

45✔
17
class OverlayStack {
45✔
18
    private get document(): Document {
45✔
19
        return this.root.ownerDocument /* c8 ignore next */ || document;
45✔
20
    }
45✔
21

45✔
22
    private pointerdownPath?: EventTarget[];
45✔
23

45✔
24
    private lastOverlay?: Overlay;
45✔
25

45✔
26
    private root: HTMLElement = document.body;
45✔
27

45✔
28
    stack: Overlay[] = [];
45✔
29

45✔
30
    private originalBodyOverflow = '';
45✔
31

45✔
32
    private bodyScrollBlocked = false;
45✔
33

45✔
34
    constructor() {
45✔
35
        this.bindEvents();
45✔
36
    }
45✔
37

45✔
38
    bindEvents(): void {
45✔
39
        this.document.addEventListener('pointerdown', this.handlePointerdown);
45✔
40
        this.document.addEventListener('pointerup', this.handlePointerup);
45✔
41
        this.document.addEventListener('keydown', this.handleKeydown);
45✔
42
        this.document.addEventListener('scroll', this.handleScroll, {
45✔
43
            capture: true,
45✔
44
        });
45✔
45
    }
45✔
46

45✔
47
    private handleScroll = (event: Event): void => {
45✔
48
        // Only handle document/body level scrolls
19✔
49
        // Skip any component scrolls
19✔
50
        if (
19✔
51
            event.target !== document &&
19✔
52
            event.target !== document.documentElement &&
3✔
53
            event.target !== document.body
3✔
54
        ) {
19✔
55
            return;
3✔
56
        }
3✔
57
        // Update positions of all open overlays
16✔
58
        this.stack.forEach((overlay) => {
16✔
59
            if (overlay.open) {
6✔
60
                // Don't close pickers on document scroll
6✔
61
                if (
6✔
62
                    overlay.type === 'auto' &&
6✔
63
                    overlay.triggerElement instanceof HTMLElement &&
3✔
64
                    overlay.triggerElement.closest('sp-picker, sp-action-menu')
3✔
65
                ) {
6✔
UNCOV
66
                    event.stopPropagation();
×
UNCOV
67
                }
×
68
                // Update the overlay's position by dispatching the update event
6✔
69
                document.dispatchEvent(
6✔
70
                    new CustomEvent('sp-update-overlays', {
6✔
71
                        bubbles: true,
6✔
72
                        composed: true,
6✔
73
                        cancelable: true,
6✔
74
                    })
6✔
75
                );
6✔
76
            }
6✔
77
        });
16✔
78
    };
19✔
79

45✔
80
    private closeOverlay(overlay: Overlay): void {
45✔
81
        const overlayIndex = this.stack.indexOf(overlay);
477✔
82
        if (overlayIndex > -1) {
477✔
83
            this.stack.splice(overlayIndex, 1);
415✔
84
        }
415✔
85
        overlay.open = false;
477✔
86

477✔
87
        this.manageBodyScroll();
477✔
88
    }
477✔
89

45✔
90
    /**
45✔
91
     * Manage body scroll blocking based on modal/page overlays
45✔
92
     */
45✔
93
    private manageBodyScroll(): void {
45✔
94
        const shouldBlock = this.stack.some(
892✔
95
            (overlay) => overlay.type === 'modal' || overlay.type === 'page'
892✔
96
        );
892✔
97
        if (shouldBlock && !this.bodyScrollBlocked) {
892✔
98
            this.originalBodyOverflow = document.body.style.overflow || '';
51✔
99
            document.body.style.overflow = 'hidden';
51✔
100
            this.bodyScrollBlocked = true;
51✔
101
        } else if (!shouldBlock && this.bodyScrollBlocked) {
887✔
102
            document.body.style.overflow = this.originalBodyOverflow;
51✔
103
            this.bodyScrollBlocked = false;
51✔
104
        }
51✔
105
    }
892✔
106

45✔
107
    /**
45✔
108
     * Cach the `pointerdownTarget` for later testing
45✔
109
     *
45✔
110
     * @param event {ClickEvent}
45✔
111
     */
45✔
112
    handlePointerdown = (event: Event): void => {
45✔
113
        this.pointerdownPath = event.composedPath();
167✔
114
        this.lastOverlay = this.stack[this.stack.length - 1];
167✔
115
    };
167✔
116

45✔
117
    /**
45✔
118
     * Close all overlays that are not ancestors of this click event
45✔
119
     *
45✔
120
     * @param event {ClickEvent}
45✔
121
     */
45✔
122
    handlePointerup = (): void => {
45✔
123
        // Test against the composed path in `pointerdown` in case the visitor moved their
1,055✔
124
        // pointer during the course of the interaction.
1,055✔
125
        // Ensure that this value is cleared even if the work in this method goes undone.
1,055✔
126
        const composedPath = this.pointerdownPath;
1,055✔
127
        this.pointerdownPath = undefined;
1,055✔
128
        if (!this.stack.length) return;
1,055✔
129
        if (!composedPath?.length) return;
1,055✔
130
        const lastOverlay = this.lastOverlay;
64✔
131
        this.lastOverlay = undefined;
64✔
132

64✔
133
        const lastIndex = this.stack.length - 1;
64✔
134
        const nonAncestorOverlays = this.stack.filter((overlay, i) => {
64✔
135
            const inStack = composedPath.find(
66✔
136
                (el) =>
66✔
137
                    // The Overlay is in the stack
904✔
138
                    el === overlay ||
904✔
139
                    // The Overlay trigger is in the stack and the Overlay is a "hint"
845✔
140
                    (el === overlay?.triggerElement &&
845!
141
                        'hint' === overlay?.type) ||
5!
142
                    // The last Overlay in the stack is not the last Overlay at `pointerdown` time and has a
841✔
143
                    // `triggerInteraction` of "longpress", meaning it was opened by this poitner interaction
841✔
144
                    (i === lastIndex &&
841✔
145
                        overlay !== lastOverlay &&
784✔
146
                        overlay.triggerInteraction === 'longpress')
2✔
147
            );
66✔
148
            return (
66✔
149
                !inStack &&
66✔
150
                !overlay.shouldPreventClose() &&
1✔
151
                overlay.type !== 'manual' &&
1!
152
                // Don't close if this overlay is modal and not on top of the overlay stack.
×
153
                !(overlay.type === 'modal' && lastOverlay !== overlay)
×
154
            );
66✔
155
        }) as Overlay[];
64✔
156
        nonAncestorOverlays.reverse();
64✔
157
        nonAncestorOverlays.forEach((overlay) => {
64✔
158
            this.closeOverlay(overlay);
×
159
            let parentToClose = overlay.parentOverlayToForceClose;
×
160
            while (parentToClose) {
×
161
                this.closeOverlay(parentToClose);
×
162
                parentToClose = parentToClose.parentOverlayToForceClose;
×
163
            }
×
164
        });
64✔
165
    };
1,055✔
166

45✔
167
    handleBeforetoggle = (event: Event): void => {
45✔
168
        const { target, newState: open } = event as Event & {
415✔
169
            newState: string;
415✔
170
        };
415✔
171
        if (open === 'open') return;
415✔
172
        this.closeOverlay(target as Overlay);
32✔
173
    };
415✔
174

45✔
175
    private handleKeydown = (event: KeyboardEvent): void => {
45✔
176
        if (event.code !== 'Escape') return;
290✔
177
        if (!this.stack.length) return;
27✔
178
        const last = this.stack[this.stack.length - 1];
21✔
179
        if (last?.type === 'page') {
290!
180
            event.preventDefault();
1✔
181
            return;
1✔
182
        }
1✔
183
        if (last?.type === 'manual') {
290!
184
            // Manual overlays should close on "Escape" key, but not when losing focus or interacting with other parts of the page.
1✔
185
            this.closeOverlay(last);
1✔
186
            return;
1✔
187
        }
1✔
188
        if (supportsPopover) return;
19!
189
        if (!last) return;
×
190
        this.closeOverlay(last);
×
191
    };
290✔
192

45✔
193
    /**
45✔
194
     * Get an array of Overlays that all share the same trigger element.
45✔
195
     *
45✔
196
     * @param triggerElement {HTMLELement}
45✔
197
     * @returns {Overlay[]}
45✔
198
     */
45✔
199
    overlaysByTriggerElement(triggerElement: HTMLElement): Overlay[] {
45✔
200
        return this.stack.filter(
×
201
            (overlay) => overlay.triggerElement === triggerElement
×
202
        );
×
203
    }
×
204

45✔
205
    /**
45✔
206
     * When overlays are added manage the open state of exisiting overlays appropriately:
45✔
207
     * - 'modal': should close other non-'modal' and non-'manual' overlays
45✔
208
     * - 'page': should close other non-'modal' and non-'manual' overlays
45✔
209
     * - 'auto': should close other 'auto' overlays and other 'hint' overlays, but not 'manual' overlays
45✔
210
     * - 'manual': shouldn't close other overlays
45✔
211
     * - 'hint': shouldn't close other overlays and give way to all other overlays on a trigger
45✔
212
     */
45✔
213
    add(overlay: Overlay): void {
45✔
214
        if (this.stack.includes(overlay)) {
417✔
215
            const overlayIndex = this.stack.indexOf(overlay);
2✔
216
            if (overlayIndex > -1) {
2✔
217
                this.stack.splice(overlayIndex, 1);
2✔
218
                this.stack.push(overlay);
2✔
219
            }
2✔
220
            return;
2✔
221
        }
2✔
222
        if (
415✔
223
            overlay.type === 'auto' ||
415✔
224
            overlay.type === 'modal' ||
157✔
225
            overlay.type === 'page'
108✔
226
        ) {
417✔
227
            // manage closing open overlays
316✔
228
            const queryPathEventName = 'sp-overlay-query-path';
316✔
229
            const queryPathEvent = new Event(queryPathEventName, {
316✔
230
                composed: true,
316✔
231
                bubbles: true,
316✔
232
            });
316✔
233
            overlay.addEventListener(
316✔
234
                queryPathEventName,
316✔
235
                (event: Event) => {
316✔
236
                    const path = event.composedPath();
316✔
237
                    this.stack.forEach((overlayEl) => {
316✔
238
                        const inPath = path.find((el) => el === overlayEl);
53✔
239
                        if (
53✔
240
                            !inPath &&
53✔
241
                            overlayEl.type !== 'manual' &&
28✔
242
                            overlayEl.type !== 'modal'
26✔
243
                        ) {
53✔
244
                            this.closeOverlay(overlayEl);
25✔
245
                        }
25✔
246
                    });
316✔
247
                },
316✔
248
                { once: true }
316✔
249
            );
316✔
250
            overlay.dispatchEvent(queryPathEvent);
316✔
251
        } else if (overlay.type === 'hint') {
409✔
252
            const hasPrevious = this.stack.some((overlayEl) => {
89✔
253
                return (
9✔
254
                    overlayEl.type !== 'manual' &&
9✔
255
                    overlayEl.triggerElement &&
9✔
256
                    overlayEl.triggerElement === overlay.triggerElement
7✔
257
                );
9✔
258
            });
89✔
259
            if (hasPrevious) {
89!
260
                overlay.open = false;
×
261
                return;
×
262
            }
×
263
            this.stack.forEach((overlayEl) => {
89✔
264
                if (overlayEl.type === 'hint') {
9✔
265
                    this.closeOverlay(overlayEl);
3✔
266
                }
3✔
267
            });
89✔
268
        }
89✔
269
        requestAnimationFrame(() => {
415✔
270
            this.stack.push(overlay);
415✔
271
            overlay.addEventListener('beforetoggle', this.handleBeforetoggle, {
415✔
272
                once: true,
415✔
273
            });
415✔
274
            this.manageBodyScroll();
415✔
275
        });
415✔
276
    }
417✔
277

45✔
278
    remove(overlay: Overlay): void {
45✔
279
        this.closeOverlay(overlay);
416✔
280
    }
416✔
281
}
45✔
282

45✔
283
export const overlayStack = new OverlayStack();
45✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc