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

adobe / spectrum-web-components / 10610750323

29 Aug 2024 07:36AM UTC coverage: 97.439% (-0.8%) from 98.202%
10610750323

Pull #4690

github

web-flow
Merge 47e5d42e9 into 49892903c
Pull Request #4690: fix(OverlayTrigger): conditionally attach slotchange listener

5034 of 5316 branches covered (94.7%)

Branch coverage included in aggregate %.

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

267 existing lines in 8 files now uncovered.

32248 of 32946 relevant lines covered (97.88%)

372.89 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

41✔
57
    /**
41✔
58
     * Close all overlays that are not ancestors of this click event
41✔
59
     *
41✔
60
     * @param event {ClickEvent}
41✔
61
     */
41✔
62
    handlePointerup = (): void => {
41✔
63
        // Test against the composed path in `pointerdown` in case the visitor moved their
495✔
64
        // pointer during the course of the interaction.
495✔
65
        // Ensure that this value is cleared even if the work in this method goes undone.
495✔
66
        const composedPath = this.pointerdownPath;
495✔
67
        this.pointerdownPath = undefined;
495✔
68
        if (!this.stack.length) return;
495✔
69
        if (!composedPath?.length) return;
495!
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
649✔
76
                    el === overlay ||
649✔
77
                    // The Overlay trigger is in the stack and the Overlay is a "hint"
606✔
78
                    (el === overlay?.triggerElement &&
606!
79
                        'hint' === overlay?.type) ||
3!
80
                    // The last Overlay in the stack is not the last Overlay at `pointerdown` time and has a
604✔
81
                    // `triggerInteraction` of "longpress", meaning it was opened by this poitner interaction
604✔
82
                    (i === lastIndex &&
604✔
83
                        overlay !== this.lastOverlay &&
604✔
84
                        overlay.triggerInteraction === 'longpress')
12✔
85
            );
47✔
86
            return (
47✔
87
                !inStack &&
47✔
88
                !overlay.shouldPreventClose() &&
2✔
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
    };
495✔
102

41✔
103
    handleBeforetoggle = (event: Event): void => {
41✔
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);
36✔
109
    };
335✔
110

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

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

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

41✔
141
    /**
41✔
142
     * When overlays are added manage the open state of exisiting overlays appropriately:
41✔
143
     * - 'modal': should close other overlays
41✔
144
     * - 'page': should close other overlays
41✔
145
     * - 'auto': should close other 'auto' overlays and other 'hint' overlays, but not 'manual' overlays
41✔
146
     * - 'manual': shouldn't close other overlays
41✔
147
     * - 'hint': shouldn't close other overlays and give way to all other overlays on a trigger
41✔
148
     */
41✔
149
    add(overlay: Overlay): void {
41✔
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);
38✔
175
                        if (!inPath && overlayEl.type !== 'manual') {
38✔
176
                            this.closeOverlay(overlayEl);
21✔
177
                        }
21✔
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

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

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