Skip to content

Commit ed24934

Browse files
Merge branch 'main' into ruben/picker-value-items-lazy-loaded
2 parents e6935a7 + 8fd197c commit ed24934

File tree

13 files changed

+391
-74
lines changed

13 files changed

+391
-74
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@spectrum-web-components/picker': patch
3+
'@spectrum-web-components/action-menu': patch
4+
---
5+
6+
**Fixed**: Safari + VoiceOver crash when opening Picker and ActionMenu. The issue was caused by an imperative `render()` call that mutated the DOM during the render cycle, causing Safari to crash while VoiceOver scanned the accessibility tree.

.changeset/good-humans-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@spectrum-web-components/shared': patch
3+
---
4+
5+
Improve reliability when composing components and mixins that observe slot presence.

1st-gen/packages/action-button/src/ActionButton.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ export class ActionButton extends SizedMixin(ButtonBase, {
179179
if (code === 'Space' || (altKey && code === 'ArrowDown')) {
180180
event.preventDefault();
181181
if (code === 'ArrowDown') {
182-
event.stopPropagation();
183182
event.stopImmediatePropagation();
184183
}
185184
this.addEventListener('keyup', this.handleKeyup);

1st-gen/packages/button/src/ButtonBase.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [
8989
if (this.disabled) {
9090
event.preventDefault();
9191
event.stopImmediatePropagation();
92-
event.stopPropagation();
9392
return false;
9493
}
9594

1st-gen/packages/menu/src/MenuItem.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,6 @@ export class MenuItem extends LikeAnchor(
331331
if (this.disabled) {
332332
event.preventDefault();
333333
event.stopImmediatePropagation();
334-
event.stopPropagation();
335334
return false;
336335
}
337336

1st-gen/packages/overlay/src/LongpressController.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ export class LongpressController extends InteractionController {
9595
private handleKeydown(event: KeyboardEvent): void {
9696
const { code, altKey } = event;
9797
if (altKey && code === 'ArrowDown') {
98-
event.stopPropagation();
9998
event.stopImmediatePropagation();
10099
}
101100
}

1st-gen/packages/overlay/src/OverlayStack.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -163,23 +163,22 @@ class OverlayStack {
163163
* @param event {PointerEvent}
164164
*/
165165
handlePointerdown = (event: Event): void => {
166+
if (!this.stack.length) return;
167+
166168
const pointerPath = event.composedPath();
167169

168170
// For page overlays only: block clicks outside (no light dismiss)
169171
// Modal overlays have light dismiss handled by handlePointerup
170-
if (this.stack.length) {
171-
const pageOverlays = this.stack.filter(
172-
(o) => o.open && o.type === 'page'
173-
);
174-
if (
175-
pageOverlays.length > 0 &&
176-
!this.isEventInsideModal(pointerPath, pageOverlays)
177-
) {
178-
event.preventDefault();
179-
event.stopPropagation();
180-
event.stopImmediatePropagation();
181-
return;
182-
}
172+
const pageOverlays = this.stack.filter(
173+
(o) => o.open && o.type === 'page'
174+
);
175+
if (
176+
pageOverlays.length > 0 &&
177+
!this.isEventInsideModal(pointerPath, pageOverlays)
178+
) {
179+
event.preventDefault();
180+
event.stopImmediatePropagation();
181+
return;
183182
}
184183

185184
this.pointerdownPath = pointerPath;
@@ -210,7 +209,6 @@ class OverlayStack {
210209
// If click is outside all page overlays, prevent it from reaching the target
211210
// This replicates the behavior that showModal() provided automatically
212211
event.stopImmediatePropagation();
213-
event.stopPropagation();
214212
event.preventDefault();
215213
}
216214
};
@@ -232,19 +230,30 @@ class OverlayStack {
232230
this.lastOverlay = undefined;
233231

234232
const lastIndex = this.stack.length - 1;
235-
const clickedBackdrop = composedPath.some(
236-
(el) =>
237-
el instanceof HTMLElement &&
238-
el.classList.contains('modal-backdrop')
233+
234+
// Avoid the cost of event.composedPath() unless we actually have a modal/page
235+
// overlay open. composedPath() walks and allocates the full event path and can
236+
// be surprisingly expensive in hot paths.
237+
const hasModalOrPageOverlay = this.stack.some(
238+
(overlay) =>
239+
overlay.open &&
240+
(overlay.type === 'modal' || overlay.type === 'page')
239241
);
240-
if (clickedBackdrop) {
241-
const topOverlay = this.stack[this.stack.length - 1];
242-
// Only modal overlays close on backdrop click.
243-
// Page overlays are blocking and should not be light-dismissable.
244-
if (topOverlay?.type === 'modal') {
245-
this.closeOverlay(topOverlay);
242+
if (hasModalOrPageOverlay) {
243+
const clickedBackdrop = composedPath.some(
244+
(el) =>
245+
el instanceof HTMLDivElement &&
246+
el.classList.contains('modal-backdrop')
247+
);
248+
if (clickedBackdrop) {
249+
const topOverlay = this.stack[this.stack.length - 1];
250+
// Only modal overlays close on backdrop click.
251+
// Page overlays are blocking and should not be light-dismissable.
252+
if (topOverlay?.type === 'modal') {
253+
this.closeOverlay(topOverlay);
254+
}
255+
return;
246256
}
247-
return;
248257
}
249258
const nonAncestorOverlays = this.stack.filter((overlay, i) => {
250259
const inStack = composedPath.find(

1st-gen/packages/picker/src/InteractionController.ts

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,11 @@ export class InteractionController implements ReactiveController {
5050
if (this._open === open) return;
5151
this._open = open;
5252

53-
if (this.overlay) {
54-
this.host.open = open;
55-
return;
56-
}
53+
this.host.open = open;
5754

58-
// When there is no Overlay and `open` is moving to `true`, lazily import/create
59-
// an Overlay and apply that state to it.
60-
customElements
61-
.whenDefined('sp-overlay')
62-
.then(async (): Promise<void> => {
63-
const { Overlay } = await import(
64-
'@spectrum-web-components/overlay/src/Overlay.js'
65-
);
66-
this.overlay = new Overlay();
67-
this.host.open = true;
68-
this.host.requestUpdate();
69-
});
70-
import('@spectrum-web-components/overlay/sp-overlay.js');
55+
if (!this.overlay && this.host.overlayElement) {
56+
this.overlay = this.host.overlayElement;
57+
}
7158
}
7259

7360
private _overlay!: AbstractOverlay;
@@ -110,8 +97,6 @@ export class InteractionController implements ReactiveController {
11097
if (this.preventNextToggle === 'no') {
11198
this.open = false;
11299
} else if (!this.pointerdownState) {
113-
// Prevent browser driven closure while opening the Picker
114-
// and the expected event series has not completed.
115100
this.overlay?.manuallyKeepOpen();
116101
}
117102
}
@@ -195,13 +180,12 @@ export class InteractionController implements ReactiveController {
195180
}
196181

197182
public hostUpdated(): void {
198-
if (
199-
this.overlay &&
200-
this.host.dependencyManager.loaded &&
201-
this.host.open !== this.overlay.open
202-
) {
183+
if (!this.overlay && this.host.overlayElement) {
184+
this.overlay = this.host.overlayElement;
185+
}
186+
187+
if (this.overlay && this.host.dependencyManager.loaded) {
203188
this.overlay.willPreventClose = this.preventNextToggle !== 'no';
204-
this.overlay.open = this.host.open;
205189
}
206190
}
207191
}

1st-gen/packages/picker/src/Picker.ts

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
html,
1717
nothing,
1818
PropertyValues,
19-
render,
2019
SizedMixin,
2120
SpectrumElement,
2221
TemplateResult,
@@ -438,6 +437,30 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
438437

439438
public handleSlottableRequest = (_event: SlottableRequestEvent): void => {};
440439

440+
protected handleBeforetoggle = (
441+
event: Event & {
442+
target: Overlay;
443+
newState: 'open' | 'closed';
444+
}
445+
): void => {
446+
if (event.composedPath()[0] !== event.target) {
447+
return;
448+
}
449+
if (event.newState === 'closed') {
450+
if (this.strategy?.preventNextToggle === 'no') {
451+
this.open = false;
452+
} else if (!this.strategy?.pointerdownState) {
453+
// Prevent browser driven closure while opening the Picker
454+
// and the expected event series has not completed.
455+
this.overlayElement?.manuallyKeepOpen();
456+
}
457+
}
458+
if (!this.open) {
459+
this.optionsMenu.updateSelectedItemIndex();
460+
this.optionsMenu.closeDescendentOverlays();
461+
}
462+
};
463+
441464
protected renderLabelContent(content: Node[]): TemplateResult | Node[] {
442465
if (this.value && this.selectedItem) {
443466
return content;
@@ -577,14 +600,30 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
577600
}
578601

579602
protected renderOverlay(menu: TemplateResult): TemplateResult {
580-
if (this.strategy?.overlay === undefined) {
581-
return menu;
582-
}
583603
const container = this.renderContainer(menu);
584-
render(container, this.strategy?.overlay as unknown as HTMLElement, {
585-
host: this,
586-
});
587-
return this.strategy?.overlay as unknown as TemplateResult;
604+
this.dependencyManager.add('sp-overlay');
605+
import('@spectrum-web-components/overlay/sp-overlay.js');
606+
return html`
607+
<sp-overlay
608+
@slottable-request=${this.handleSlottableRequest}
609+
@beforetoggle=${this.handleBeforetoggle}
610+
.triggerElement=${this as HTMLElement}
611+
.offset=${0}
612+
?open=${this.open && this.dependencyManager.loaded}
613+
.placement=${this.isMobile.matches && !this.forcePopover
614+
? undefined
615+
: this.placement}
616+
.type=${this.isMobile.matches && !this.forcePopover
617+
? 'modal'
618+
: 'auto'}
619+
.receivesFocus=${'false'}
620+
.willPreventClose=${this.strategy?.preventNextToggle !== 'no' &&
621+
this.open &&
622+
this.dependencyManager.loaded}
623+
>
624+
${container}
625+
</sp-overlay>
626+
`;
588627
}
589628

590629
protected get renderDescriptionSlot(): TemplateResult {
@@ -662,7 +701,7 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
662701
// await the same here.
663702
this.shouldScheduleManageSelection();
664703
}
665-
// Maybe it's finally time to remove this support?s
704+
// Maybe it's finally time to remove this support?
666705
if (!this.hasUpdated) {
667706
this.deprecatedMenu = this.querySelector(':scope > sp-menu');
668707
this.deprecatedMenu?.toggleAttribute('ignore', true);
@@ -697,15 +736,26 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
697736

698737
protected override updated(changes: PropertyValues<this>): void {
699738
super.updated(changes);
700-
if (changes.has('open')) {
701-
this.strategy.open = this.open;
739+
if (
740+
changes.has('open') &&
741+
this.overlayElement &&
742+
!this.strategy.overlay
743+
) {
744+
this.strategy.overlay = this.overlayElement;
702745
}
703746
}
704747

705-
protected override firstUpdated(changes: PropertyValues<this>): void {
748+
protected override async firstUpdated(
749+
changes: PropertyValues<this>
750+
): Promise<void> {
706751
super.firstUpdated(changes);
707752
this.bindButtonKeydownListener();
708753
this.bindEvents();
754+
755+
await this.updateComplete;
756+
if (this.overlayElement && !this.strategy.overlay) {
757+
this.strategy.overlay = this.overlayElement;
758+
}
709759
}
710760

711761
protected get dismissHelper(): TemplateResult {
@@ -815,7 +865,6 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
815865
((event.target as HTMLElement).getRootNode() as ShadowRoot)
816866
.host === this)
817867
) {
818-
//s set a flag to manage selection on the next frame
819868
this.willManageSelection = true;
820869
requestAnimationFrame(() => {
821870
requestAnimationFrame(() => {
@@ -889,9 +938,6 @@ export class PickerBase extends SizedMixin(SpectrumElement, {
889938
protected override async getUpdateComplete(): Promise<boolean> {
890939
const complete = (await super.getUpdateComplete()) as boolean;
891940
await this.selectionPromise;
892-
// if (this.overlayElement) {
893-
// await this.overlayElement.updateComplete;
894-
// }
895941
return complete;
896942
}
897943

0 commit comments

Comments
 (0)