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

adobe / spectrum-web-components / 10807694883

11 Sep 2024 08:13AM UTC coverage: 97.438% (-0.8%) from 98.203%
10807694883

Pull #4690

github

web-flow
Merge 2f0425abd into 4c6a0dcf7
Pull Request #4690: fix(OverlayTrigger): conditionally attach slotchange listener

5041 of 5323 branches covered (94.7%)

Branch coverage included in aggregate %.

20 of 20 new or added lines in 1 file covered. (100.0%)

268 existing lines in 8 files now uncovered.

32262 of 32961 relevant lines covered (97.88%)

374.59 hits per line

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

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

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

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

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

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

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

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

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

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

42✔
33
    bindEvents(): void {
42✔
34
        this.document.addEventListener('pointerdown', this.handlePointerdown);
42✔
35
        this.document.addEventListener('pointerup', this.handlePointerup);
42✔
36
        this.document.addEventListener('keydown', this.handleKeydown);
42✔
37
    }
42✔
38

42✔
39
    private closeOverlay(overlay: Overlay): void {
42✔
40
        const overlayIndex = this.stack.indexOf(overlay);
399✔
41
        if (overlayIndex > -1) {
399✔
42
            this.stack.splice(overlayIndex, 1);
336✔
43
        }
336✔
44
        overlay.open = false;
399✔
45
    }
399✔
46

42✔
47
    /**
42✔
48
     * Cach the `pointerdownTarget` for later testing
42✔
49
     *
42✔
50
     * @param event {ClickEvent}
42✔
51
     */
42✔
52
    handlePointerdown = (event: Event): void => {
42✔
53
        this.pointerdownPath = event.composedPath();
164✔
54
        this.lastOverlay = this.stack[this.stack.length - 1];
164✔
55
    };
164✔
56

42✔
57
    /**
42✔
58
     * Close all overlays that are not ancestors of this click event
42✔
59
     *
42✔
60
     * @param event {ClickEvent}
42✔
61
     */
42✔
62
    handlePointerup = (): void => {
42✔
63
        // Test against the composed path in `pointerdown` in case the visitor moved their
497✔
64
        // pointer during the course of the interaction.
497✔
65
        // Ensure that this value is cleared even if the work in this method goes undone.
497✔
66
        const composedPath = this.pointerdownPath;
497✔
67
        this.pointerdownPath = undefined;
497✔
68
        if (!this.stack.length) return;
497✔
69
        if (!composedPath?.length) return;
497!
70

47✔
71
        const lastIndex = this.stack.length - 1;
47✔
72
        const nonAncestorOverlays = this.stack.filter((overlay, i) => {
47✔
73
            const inStack = composedPath.find(
47✔
74
                (el) =>
47✔
75
                    // The Overlay is in the stack
648✔
76
                    el === overlay ||
648✔
77
                    // The Overlay trigger is in the stack and the Overlay is a "hint"
605✔
78
                    (el === overlay?.triggerElement &&
605!
79
                        'hint' === overlay?.type) ||
3!
80
                    // The last Overlay in the stack is not the last Overlay at `pointerdown` time and has a
602✔
81
                    // `triggerInteraction` of "longpress", meaning it was opened by this poitner interaction
602✔
82
                    (i === lastIndex &&
602✔
83
                        overlay !== this.lastOverlay &&
602!
UNCOV
84
                        overlay.triggerInteraction === 'longpress')
×
85
            );
47✔
86
            return (
47✔
87
                !inStack &&
47✔
88
                !overlay.shouldPreventClose() &&
1✔
89
                overlay.type !== 'manual'
1✔
90
            );
47✔
91
        }) as Overlay[];
47✔
92
        nonAncestorOverlays.reverse();
47✔
93
        nonAncestorOverlays.forEach((overlay) => {
47✔
UNCOV
94
            this.closeOverlay(overlay);
×
UNCOV
95
            let parentToClose = overlay.parentOverlayToForceClose;
×
UNCOV
96
            while (parentToClose) {
×
97
                this.closeOverlay(parentToClose);
×
98
                parentToClose = parentToClose.parentOverlayToForceClose;
×
99
            }
×
100
        });
47✔
101
    };
497✔
102

42✔
103
    handleBeforetoggle = (event: Event): void => {
42✔
104
        const { target, newState: open } = event as Event & {
335✔
105
            newState: string;
335✔
106
        };
335✔
107
        if (open === 'open') return;
335✔
108
        this.closeOverlay(target as Overlay);
38✔
109
    };
335✔
110

42✔
111
    private handleKeydown = (event: KeyboardEvent): void => {
42✔
112
        if (event.code !== 'Escape') return;
275✔
113
        if (!this.stack.length) return;
6✔
114
        const last = this.stack[this.stack.length - 1];
5✔
115
        if (last?.type === 'page') {
275!
116
            event.preventDefault();
1✔
117
            return;
1✔
118
        }
1✔
119
        if (supportsPopover) return;
4!
120
        if (last?.type === 'manual') {
275!
121
            // Manual Overlays should not close on "light dismiss".
×
122
            return;
×
123
        }
×
124

×
125
        if (!last) return;
×
126
        this.closeOverlay(last);
×
127
    };
275✔
128

42✔
129
    /**
42✔
130
     * Get an array of Overlays that all share the same trigger element.
42✔
131
     *
42✔
132
     * @param triggerElement {HTMLELement}
42✔
133
     * @returns {Overlay[]}
42✔
134
     */
42✔
135
    overlaysByTriggerElement(triggerElement: HTMLElement): Overlay[] {
42✔
136
        return this.stack.filter(
×
137
            (overlay) => overlay.triggerElement === triggerElement
×
138
        );
×
139
    }
×
140

42✔
141
    /**
42✔
142
     * When overlays are added manage the open state of exisiting overlays appropriately:
42✔
143
     * - 'modal': should close other overlays
42✔
144
     * - 'page': should close other overlays
42✔
145
     * - 'auto': should close other 'auto' overlays and other 'hint' overlays, but not 'manual' overlays
42✔
146
     * - 'manual': shouldn't close other overlays
42✔
147
     * - 'hint': shouldn't close other overlays and give way to all other overlays on a trigger
42✔
148
     */
42✔
149
    add(overlay: Overlay): void {
42✔
150
        if (this.stack.includes(overlay)) {
336!
UNCOV
151
            const overlayIndex = this.stack.indexOf(overlay);
×
UNCOV
152
            if (overlayIndex > -1) {
×
UNCOV
153
                this.stack.splice(overlayIndex, 1);
×
UNCOV
154
                this.stack.push(overlay);
×
UNCOV
155
            }
×
UNCOV
156
            return;
×
UNCOV
157
        }
×
158
        if (
336✔
159
            overlay.type === 'auto' ||
336✔
160
            overlay.type === 'modal' ||
61✔
161
            overlay.type === 'page'
50✔
162
        ) {
336✔
163
            // manage closing open overlays
295✔
164
            const queryPathEventName = 'sp-overlay-query-path';
295✔
165
            const queryPathEvent = new Event(queryPathEventName, {
295✔
166
                composed: true,
295✔
167
                bubbles: true,
295✔
168
            });
295✔
169
            overlay.addEventListener(
295✔
170
                queryPathEventName,
295✔
171
                (event: Event) => {
295✔
172
                    const path = event.composedPath();
295✔
173
                    this.stack.forEach((overlayEl) => {
295✔
174
                        const inPath = path.find((el) => el === overlayEl);
40✔
175
                        if (!inPath && overlayEl.type !== 'manual') {
40✔
176
                            this.closeOverlay(overlayEl);
23✔
177
                        }
23✔
178
                    });
295✔
179
                },
295✔
180
                { once: true }
295✔
181
            );
295✔
182
            overlay.dispatchEvent(queryPathEvent);
295✔
183
        } else if (overlay.type === 'hint') {
336✔
184
            const hasPrevious = this.stack.some((overlayEl) => {
31✔
185
                return (
2✔
186
                    overlayEl.type !== 'manual' &&
2✔
187
                    overlayEl.triggerElement &&
2✔
UNCOV
188
                    overlayEl.triggerElement === overlay.triggerElement
×
189
                );
2✔
190
            });
31✔
191
            if (hasPrevious) {
31!
192
                overlay.open = false;
×
193
                return;
×
194
            }
×
195
            this.stack.forEach((overlayEl) => {
31✔
196
                if (overlayEl.type === 'hint') {
2✔
197
                    this.closeOverlay(overlayEl);
2✔
198
                }
2✔
199
            });
31✔
200
        }
31✔
201
        requestAnimationFrame(() => {
336✔
202
            this.stack.push(overlay);
336✔
203
            overlay.addEventListener('beforetoggle', this.handleBeforetoggle, {
336✔
204
                once: true,
336✔
205
            });
336✔
206
        });
336✔
207
    }
336✔
208

42✔
209
    remove(overlay: Overlay): void {
42✔
210
        this.closeOverlay(overlay);
336✔
211
    }
336✔
212
}
42✔
213

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