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

adobe / spectrum-web-components / 13555636806

26 Feb 2025 11:47PM UTC coverage: 97.854% (-0.1%) from 97.966%
13555636806

Pull #5012

github

web-flow
Merge 19da91686 into ea38ef0db
Pull Request #5012: fix(OverlayTrigger): delay update for overlay trigger to avoid infinite loop

5274 of 5585 branches covered (94.43%)

Branch coverage included in aggregate %.

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

40 existing lines in 6 files now uncovered.

33628 of 34170 relevant lines covered (98.41%)

643.43 hits per line

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

86.94
/packages/overlay/src/LongpressController.ts
1
/*
44✔
2
Copyright 2024 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

44✔
7
Unless required by applicable law or agreed to in writing, software distributed under
44✔
8
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
44✔
9
OF ANY KIND, either express or implied. See the License for the specific language
44✔
10
governing permissions and limitations under the License.
44✔
11
*/
44✔
12

44✔
13
import {
44✔
14
    isAndroid,
44✔
15
    isIOS,
44✔
16
} from '@spectrum-web-components/shared/src/platform.js';
44✔
17
import { conditionAttributeWithId } from '@spectrum-web-components/base/src/condition-attribute-with-id.js';
44✔
18
import { randomID } from '@spectrum-web-components/shared/src/random-id.js';
44✔
19

44✔
20
import { noop } from './AbstractOverlay.js';
44✔
21
import {
44✔
22
    InteractionController,
44✔
23
    InteractionTypes,
44✔
24
} from './InteractionController.js';
44✔
25

44✔
26
const LONGPRESS_DURATION = 300;
44✔
27
export const LONGPRESS_INSTRUCTIONS = {
44✔
28
    touch: 'Double tap and long press for additional options',
44✔
29
    keyboard: 'Press Space or Alt+Down Arrow for additional options',
44✔
30
    mouse: 'Click and hold for additional options',
44✔
31
};
44✔
32

44✔
33
type LongpressEvent = {
44✔
34
    source: 'pointer' | 'keyboard';
44✔
35
};
44✔
36

44✔
37
export class LongpressController extends InteractionController {
44✔
38
    override type = InteractionTypes.longpress;
10✔
39

10✔
40
    override get activelyOpening(): boolean {
10✔
41
        return (
10✔
42
            this.longpressState === 'opening' ||
10✔
43
            this.longpressState === 'pressed'
12✔
44
        );
10✔
45
    }
10✔
46

10✔
47
    protected longpressState: null | 'potential' | 'opening' | 'pressed' = null;
10✔
48

10✔
49
    override releaseDescription = noop;
10✔
50

10✔
51
    private timeout!: ReturnType<typeof setTimeout>;
10✔
52

10✔
53
    handleLongpress(): void {
10✔
54
        this.open = true;
2✔
55
        this.longpressState =
2✔
56
            this.longpressState === 'potential' ? 'opening' : 'pressed';
2✔
57
    }
2✔
58

10✔
59
    handlePointerdown(event: PointerEvent): void {
10✔
60
        if (!this.target) return;
1✔
61
        if (event.button !== 0) return;
1✔
62
        this.longpressState = 'potential';
1✔
63
        document.addEventListener('pointerup', this.handlePointerup);
1✔
64
        document.addEventListener('pointercancel', this.handlePointerup);
1✔
65
        // Only dispatch longpress event if the trigger element isn't doing it for us.
1✔
66
        const triggerHandlesLongpress = 'holdAffordance' in this.target;
1✔
67
        if (triggerHandlesLongpress) return;
1✔
68
        this.timeout = setTimeout(() => {
1✔
69
            if (!this.target) return;
1✔
70
            this.target.dispatchEvent(
1✔
71
                new CustomEvent<LongpressEvent>('longpress', {
1✔
72
                    bubbles: true,
1✔
73
                    composed: true,
1✔
74
                    detail: {
1✔
75
                        source: 'pointer',
1✔
76
                    },
1✔
77
                })
1✔
78
            );
1✔
79
        }, LONGPRESS_DURATION);
1✔
80
    }
1✔
81

10✔
82
    private handlePointerup = (): void => {
10✔
83
        clearTimeout(this.timeout);
1✔
84
        if (!this.target) return;
1✔
85
        // When triggered by the pointer, the last of `opened`
1✔
86
        // or `pointerup` should move the `longpressState` to
1✔
87
        // `null` so that the earlier event can void the "light
1✔
88
        // dismiss" and keep the Overlay open.
1✔
89
        this.longpressState =
1✔
90
            this.overlay?.state === 'opening' ? 'pressed' : null;
1✔
91
        document.removeEventListener('pointerup', this.handlePointerup);
1✔
92
        document.removeEventListener('pointercancel', this.handlePointerup);
1✔
93
    };
1✔
94

44✔
95
    private handleKeydown(event: KeyboardEvent): void {
44✔
UNCOV
96
        const { code, altKey } = event;
×
UNCOV
97
        if (altKey && code === 'ArrowDown') {
×
UNCOV
98
            event.stopPropagation();
×
UNCOV
99
            event.stopImmediatePropagation();
×
UNCOV
100
        }
×
UNCOV
101
    }
×
102

44✔
103
    private handleKeyup(event: KeyboardEvent): void {
44✔
UNCOV
104
        const { code, altKey } = event;
×
UNCOV
105
        if (code === 'Space' || (altKey && code === 'ArrowDown')) {
×
UNCOV
106
            if (!this.target) {
×
107
                return;
×
108
            }
×
UNCOV
109
            event.stopPropagation();
×
UNCOV
110
            this.target.dispatchEvent(
×
UNCOV
111
                new CustomEvent<LongpressEvent>('longpress', {
×
UNCOV
112
                    bubbles: true,
×
UNCOV
113
                    composed: true,
×
UNCOV
114
                    detail: {
×
UNCOV
115
                        source: 'keyboard',
×
UNCOV
116
                    },
×
UNCOV
117
                })
×
UNCOV
118
            );
×
UNCOV
119
            setTimeout(() => {
×
UNCOV
120
                this.longpressState = null;
×
UNCOV
121
            });
×
UNCOV
122
        }
×
UNCOV
123
    }
×
124

44✔
125
    override prepareDescription(trigger: HTMLElement): void {
44✔
126
        if (
28✔
127
            // do not reapply until target is recycled
28✔
128
            this.releaseDescription !== noop ||
28✔
129
            // require "longpress content" to apply relationship
8✔
130
            !this.overlay.elements.length
8✔
131
        ) {
28✔
132
            return;
20✔
133
        }
20✔
134

8✔
135
        const longpressDescription = document.createElement('div');
8✔
136
        longpressDescription.id = `longpress-describedby-descriptor-${randomID()}`;
8✔
137
        const messageType = isIOS() || isAndroid() ? 'touch' : 'keyboard';
28!
138
        longpressDescription.textContent = LONGPRESS_INSTRUCTIONS[messageType];
28✔
139
        longpressDescription.slot = 'longpress-describedby-descriptor';
28✔
140
        const triggerParent = trigger.getRootNode() as HTMLElement;
28✔
141
        const overlayParent = this.overlay.getRootNode() as HTMLElement;
28✔
142
        // Manage the placement of the helper element in an accessible place with
28✔
143
        // the lowest chance of negatively affecting the layout of the page.
28✔
144
        if (triggerParent === overlayParent) {
28!
145
            // Trigger and Overlay in same DOM tree...
×
146
            // Append helper element to Overlay.
×
147
            this.overlay.append(longpressDescription);
×
148
        } else {
28✔
149
            // If Trigger in <body>, hide helper
8✔
150
            longpressDescription.hidden = !('host' in triggerParent);
8✔
151
            // Trigger and Overlay in different DOM tree, Trigger in shadow tree...
8✔
152
            // Insert helper element after Trigger.
8✔
153
            trigger.insertAdjacentElement('afterend', longpressDescription);
8✔
154
        }
8✔
155

8✔
156
        const releaseDescription = conditionAttributeWithId(
8✔
157
            trigger,
8✔
158
            'aria-describedby',
8✔
159
            [longpressDescription.id]
8✔
160
        );
8✔
161
        this.releaseDescription = () => {
8✔
162
            releaseDescription();
8✔
163
            longpressDescription.remove();
8✔
164
            this.releaseDescription = noop;
8✔
165
        };
8✔
166
    }
28✔
167

44✔
168
    override shouldCompleteOpen(): void {
44✔
169
        // When triggered by the pointer, the last of `opened`
6✔
170
        // or `pointerup` should move the `longpressState` to
6✔
171
        // `null` so that the earlier event can void the "light
6✔
172
        // dismiss" and keep the Overlay open.
6✔
173
        this.longpressState =
6✔
174
            this.longpressState === 'pressed' ? null : this.longpressState;
6✔
175
    }
6✔
176

44✔
177
    override init(): void {
44✔
178
        // Clean up listeners if they've already been bound
10✔
179
        this.abortController?.abort();
10!
180
        this.abortController = new AbortController();
10✔
181
        const { signal } = this.abortController;
10✔
182
        this.target.addEventListener(
10✔
183
            'longpress',
10✔
184
            () => this.handleLongpress(),
10✔
185
            { signal }
10✔
186
        );
10✔
187
        this.target.addEventListener(
10✔
188
            'pointerdown',
10✔
189
            (event: PointerEvent) => this.handlePointerdown(event),
10✔
190
            { signal }
10✔
191
        );
10✔
192

10✔
193
        this.prepareDescription(this.target);
10✔
194
        if (
10✔
195
            (this.target as HTMLElement & { holdAffordance: boolean })
10✔
196
                .holdAffordance
10✔
197
        ) {
10✔
198
            // Only bind keyboard events when the trigger element isn't doing it for us.
5✔
199
            return;
5✔
200
        }
5✔
201
        this.target.addEventListener(
5✔
202
            'keydown',
5✔
203
            (event: KeyboardEvent) => this.handleKeydown(event),
5✔
204
            { signal }
5✔
205
        );
5✔
206
        this.target.addEventListener(
5✔
207
            'keyup',
5✔
208
            (event: KeyboardEvent) => this.handleKeyup(event),
5✔
209
            { signal }
5✔
210
        );
5✔
211
    }
10✔
212
}
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