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

adobe / spectrum-web-components / 15073691922

16 May 2025 05:07PM UTC coverage: 97.992%. First build
15073691922

Pull #5222

github

web-flow
Merge a1497f9a8 into 737ce2c7b
Pull Request #5222: chore: migrate workflows from circleci to github actions [SWC-767]

5264 of 5570 branches covered (94.51%)

Branch coverage included in aggregate %.

33627 of 34118 relevant lines covered (98.56%)

650.91 hits per line

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

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

44✔
12
import { Overlay } from './Overlay.js';
44✔
13

44✔
14
const supportsPopover = 'showPopover' in document.createElement('div');
44✔
15

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

44✔
21
    private pointerdownPath?: EventTarget[];
44✔
22

44✔
23
    private lastOverlay?: Overlay;
44✔
24

44✔
25
    private root: HTMLElement = document.body;
44✔
26

44✔
27
    stack: Overlay[] = [];
44✔
28

44✔
29
    constructor() {
44✔
30
        this.bindEvents();
44✔
31
    }
44✔
32

44✔
33
    bindEvents(): void {
44✔
34
        this.document.addEventListener('pointerdown', this.handlePointerdown);
44✔
35
        this.document.addEventListener('pointerup', this.handlePointerup);
44✔
36
        this.document.addEventListener('keydown', this.handleKeydown);
44✔
37
        this.document.addEventListener('scroll', this.handleScroll, {
44✔
38
            capture: true,
44✔
39
        });
44✔
40
    }
44✔
41

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

44✔
75
    private closeOverlay(overlay: Overlay): void {
44✔
76
        const overlayIndex = this.stack.indexOf(overlay);
535✔
77
        if (overlayIndex > -1) {
535✔
78
            this.stack.splice(overlayIndex, 1);
471✔
79
        }
471✔
80
        overlay.open = false;
535✔
81
    }
535✔
82

44✔
83
    /**
44✔
84
     * Cach the `pointerdownTarget` for later testing
44✔
85
     *
44✔
86
     * @param event {ClickEvent}
44✔
87
     */
44✔
88
    handlePointerdown = (event: Event): void => {
44✔
89
        this.pointerdownPath = event.composedPath();
179✔
90
        this.lastOverlay = this.stack[this.stack.length - 1];
179✔
91
    };
179✔
92

44✔
93
    /**
44✔
94
     * Close all overlays that are not ancestors of this click event
44✔
95
     *
44✔
96
     * @param event {ClickEvent}
44✔
97
     */
44✔
98
    handlePointerup = (): void => {
44✔
99
        // Test against the composed path in `pointerdown` in case the visitor moved their
521✔
100
        // pointer during the course of the interaction.
521✔
101
        // Ensure that this value is cleared even if the work in this method goes undone.
521✔
102
        const composedPath = this.pointerdownPath;
521✔
103
        this.pointerdownPath = undefined;
521✔
104
        if (!this.stack.length) return;
521✔
105
        if (!composedPath?.length) return;
521!
106
        const lastOverlay = this.lastOverlay;
63✔
107
        this.lastOverlay = undefined;
63✔
108

63✔
109
        const lastIndex = this.stack.length - 1;
63✔
110
        const nonAncestorOverlays = this.stack.filter((overlay, i) => {
63✔
111
            const inStack = composedPath.find(
65✔
112
                (el) =>
65✔
113
                    // The Overlay is in the stack
900✔
114
                    el === overlay ||
900✔
115
                    // The Overlay trigger is in the stack and the Overlay is a "hint"
842✔
116
                    (el === overlay?.triggerElement &&
842!
117
                        'hint' === overlay?.type) ||
5!
118
                    // The last Overlay in the stack is not the last Overlay at `pointerdown` time and has a
838✔
119
                    // `triggerInteraction` of "longpress", meaning it was opened by this poitner interaction
838✔
120
                    (i === lastIndex &&
838✔
121
                        overlay !== lastOverlay &&
781✔
122
                        overlay.triggerInteraction === 'longpress')
2✔
123
            );
65✔
124
            return (
65✔
125
                !inStack &&
65✔
126
                !overlay.shouldPreventClose() &&
1✔
127
                overlay.type !== 'manual' &&
1!
128
                // Don't close if this overlay is modal and not on top of the overlay stack.
×
129
                !(overlay.type === 'modal' && lastOverlay !== overlay)
×
130
            );
65✔
131
        }) as Overlay[];
63✔
132
        nonAncestorOverlays.reverse();
63✔
133
        nonAncestorOverlays.forEach((overlay) => {
63✔
134
            this.closeOverlay(overlay);
×
135
            let parentToClose = overlay.parentOverlayToForceClose;
×
136
            while (parentToClose) {
×
137
                this.closeOverlay(parentToClose);
×
138
                parentToClose = parentToClose.parentOverlayToForceClose;
×
139
            }
×
140
        });
63✔
141
    };
521✔
142

44✔
143
    handleBeforetoggle = (event: Event): void => {
44✔
144
        const { target, newState: open } = event as Event & {
471✔
145
            newState: string;
471✔
146
        };
471✔
147
        if (open === 'open') return;
471✔
148
        this.closeOverlay(target as Overlay);
34✔
149
    };
471✔
150

44✔
151
    private handleKeydown = (event: KeyboardEvent): void => {
44✔
152
        if (event.code !== 'Escape') return;
360✔
153
        if (!this.stack.length) return;
31✔
154
        const last = this.stack[this.stack.length - 1];
19✔
155
        if (last?.type === 'page') {
360!
156
            event.preventDefault();
1✔
157
            return;
1✔
158
        }
1✔
159
        if (last?.type === 'manual') {
360!
160
            // Manual overlays should close on "Escape" key, but not when losing focus or interacting with other parts of the page.
1✔
161
            this.closeOverlay(last);
1✔
162
            return;
1✔
163
        }
1✔
164
        if (supportsPopover) return;
17!
165
        if (!last) return;
×
166
        this.closeOverlay(last);
×
167
    };
360✔
168

44✔
169
    /**
44✔
170
     * Get an array of Overlays that all share the same trigger element.
44✔
171
     *
44✔
172
     * @param triggerElement {HTMLELement}
44✔
173
     * @returns {Overlay[]}
44✔
174
     */
44✔
175
    overlaysByTriggerElement(triggerElement: HTMLElement): Overlay[] {
44✔
176
        return this.stack.filter(
×
177
            (overlay) => overlay.triggerElement === triggerElement
×
178
        );
×
179
    }
×
180

44✔
181
    /**
44✔
182
     * When overlays are added manage the open state of exisiting overlays appropriately:
44✔
183
     * - 'modal': should close other non-'modal' and non-'manual' overlays
44✔
184
     * - 'page': should close other non-'modal' and non-'manual' overlays
44✔
185
     * - 'auto': should close other 'auto' overlays and other 'hint' overlays, but not 'manual' overlays
44✔
186
     * - 'manual': shouldn't close other overlays
44✔
187
     * - 'hint': shouldn't close other overlays and give way to all other overlays on a trigger
44✔
188
     */
44✔
189
    add(overlay: Overlay): void {
44✔
190
        if (this.stack.includes(overlay)) {
473✔
191
            const overlayIndex = this.stack.indexOf(overlay);
2✔
192
            if (overlayIndex > -1) {
2✔
193
                this.stack.splice(overlayIndex, 1);
2✔
194
                this.stack.push(overlay);
2✔
195
            }
2✔
196
            return;
2✔
197
        }
2✔
198
        if (
471✔
199
            overlay.type === 'auto' ||
471✔
200
            overlay.type === 'modal' ||
142✔
201
            overlay.type === 'page'
102✔
202
        ) {
473✔
203
            // manage closing open overlays
378✔
204
            const queryPathEventName = 'sp-overlay-query-path';
378✔
205
            const queryPathEvent = new Event(queryPathEventName, {
378✔
206
                composed: true,
378✔
207
                bubbles: true,
378✔
208
            });
378✔
209
            overlay.addEventListener(
378✔
210
                queryPathEventName,
378✔
211
                (event: Event) => {
378✔
212
                    const path = event.composedPath();
378✔
213
                    this.stack.forEach((overlayEl) => {
378✔
214
                        const inPath = path.find((el) => el === overlayEl);
53✔
215
                        if (
53✔
216
                            !inPath &&
53✔
217
                            overlayEl.type !== 'manual' &&
28✔
218
                            overlayEl.type !== 'modal'
26✔
219
                        ) {
53✔
220
                            this.closeOverlay(overlayEl);
25✔
221
                        }
25✔
222
                    });
378✔
223
                },
378✔
224
                { once: true }
378✔
225
            );
378✔
226
            overlay.dispatchEvent(queryPathEvent);
378✔
227
        } else if (overlay.type === 'hint') {
473✔
228
            const hasPrevious = this.stack.some((overlayEl) => {
83✔
229
                return (
9✔
230
                    overlayEl.type !== 'manual' &&
9✔
231
                    overlayEl.triggerElement &&
9✔
232
                    overlayEl.triggerElement === overlay.triggerElement
7✔
233
                );
9✔
234
            });
83✔
235
            if (hasPrevious) {
83!
236
                overlay.open = false;
×
237
                return;
×
238
            }
×
239
            this.stack.forEach((overlayEl) => {
83✔
240
                if (overlayEl.type === 'hint') {
9✔
241
                    this.closeOverlay(overlayEl);
3✔
242
                }
3✔
243
            });
83✔
244
        }
83✔
245
        requestAnimationFrame(() => {
471✔
246
            this.stack.push(overlay);
471✔
247
            overlay.addEventListener('beforetoggle', this.handleBeforetoggle, {
471✔
248
                once: true,
471✔
249
            });
471✔
250
        });
471✔
251
    }
473✔
252

44✔
253
    remove(overlay: Overlay): void {
44✔
254
        this.closeOverlay(overlay);
472✔
255
    }
472✔
256
}
44✔
257

44✔
258
export const overlayStack = new OverlayStack();
44✔
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

© 2025 Coveralls, Inc