Skip to content

Commit f1a82cb

Browse files
committed
fix(picker): use "modal" as the menu overlay interaction
1 parent 9232eb1 commit f1a82cb

File tree

7 files changed

+1911
-1776
lines changed

7 files changed

+1911
-1776
lines changed

packages/overlay/src/overlay-stack.ts

+85-72
Original file line numberDiff line numberDiff line change
@@ -391,83 +391,98 @@ export class OverlayStack {
391391
}
392392
}
393393

394+
private manageFocusAfterCloseWhenOverlaysRemain(): void {
395+
const topOverlay = this.overlays[this.overlays.length - 1];
396+
topOverlay.feature();
397+
// Push focus in the the next remaining overlay as needed when a `type="modal"` overlay exists.
398+
if (topOverlay.interaction === 'modal' || topOverlay.hasModalRoot) {
399+
topOverlay.focus();
400+
} else {
401+
this.stopTabTrapping();
402+
}
403+
}
404+
405+
private manageFocusAfterCloseWhenLastOverlay(overlay: ActiveOverlay): void {
406+
this.stopTabTrapping();
407+
const isModal = overlay.interaction === 'modal';
408+
const isReplace = overlay.interaction === 'replace';
409+
const isInline = overlay.interaction === 'inline';
410+
const isTabbingAwayFromInlineOrReplace =
411+
(isReplace || isInline) && !overlay.tabbingAway;
412+
overlay.tabbingAway = false;
413+
if (!isModal && !isTabbingAwayFromInlineOrReplace) {
414+
return;
415+
}
416+
// Manage post closure focus when needed.
417+
const overlayRoot = overlay.overlayContent.getRootNode() as ShadowRoot;
418+
const overlayContentActiveElement = overlayRoot.activeElement;
419+
let triggerRoot: ShadowRoot;
420+
let triggerActiveElement: Element | null;
421+
const contentContainsActiveElement = (): boolean =>
422+
overlay.overlayContent.contains(overlayContentActiveElement);
423+
const triggerRootContainsActiveElement = (): boolean => {
424+
triggerRoot = overlay.trigger.getRootNode() as ShadowRoot;
425+
triggerActiveElement = triggerRoot.activeElement;
426+
return triggerRoot.contains(triggerActiveElement);
427+
};
428+
const triggerHostIsActiveElement = (): boolean =>
429+
triggerRoot.host && triggerRoot.host === triggerActiveElement;
430+
// Return focus to the trigger as long as the user hasn't actively focused
431+
// something outside of the current overlay interface; trigger, root, host.
432+
if (
433+
isModal ||
434+
contentContainsActiveElement() ||
435+
triggerRootContainsActiveElement() ||
436+
triggerHostIsActiveElement()
437+
) {
438+
overlay.trigger.focus();
439+
}
440+
}
441+
394442
private async hideAndCloseOverlay(
395443
overlay?: ActiveOverlay,
396444
animated?: boolean
397445
): Promise<void> {
398-
if (overlay) {
399-
await overlay.hide(animated);
400-
const contentWithLifecycle =
401-
overlay.overlayContent as unknown as ManagedOverlayContent;
402-
if (typeof contentWithLifecycle.open !== 'undefined') {
403-
contentWithLifecycle.open = false;
404-
}
405-
if (contentWithLifecycle.overlayCloseCallback) {
406-
const { trigger } = overlay;
407-
contentWithLifecycle.overlayCloseCallback({ trigger });
408-
}
409-
if (overlay.state != 'dispose') return;
446+
if (!overlay) {
447+
return;
448+
}
449+
await overlay.hide(animated);
450+
const contentWithLifecycle =
451+
overlay.overlayContent as unknown as ManagedOverlayContent;
452+
if (typeof contentWithLifecycle.open !== 'undefined') {
453+
contentWithLifecycle.open = false;
454+
}
455+
if (contentWithLifecycle.overlayCloseCallback) {
456+
const { trigger } = overlay;
457+
contentWithLifecycle.overlayCloseCallback({ trigger });
458+
}
410459

411-
const index = this.overlays.indexOf(overlay);
412-
if (index >= 0) {
413-
this.overlays.splice(index, 1);
414-
}
415-
if (this.overlays.length) {
416-
const topOverlay = this.overlays[this.overlays.length - 1];
417-
topOverlay.feature();
418-
if (
419-
topOverlay.interaction === 'modal' ||
420-
topOverlay.hasModalRoot
421-
) {
422-
topOverlay.focus();
423-
} else {
424-
this.stopTabTrapping();
425-
}
426-
} else {
427-
this.stopTabTrapping();
428-
if (
429-
overlay.interaction === 'modal' ||
430-
((overlay.interaction === 'replace' ||
431-
overlay.interaction === 'inline') &&
432-
!overlay.tabbingAway)
433-
) {
434-
const overlayRoot =
435-
overlay.overlayContent.getRootNode() as ShadowRoot;
436-
const overlayContentActiveElement =
437-
overlayRoot.activeElement;
438-
const triggerRoot =
439-
overlay.trigger.getRootNode() as ShadowRoot;
440-
const triggerActiveElement = triggerRoot.activeElement;
441-
if (
442-
overlay.overlayContent.contains(
443-
overlayContentActiveElement
444-
) ||
445-
overlay.trigger
446-
.getRootNode()
447-
.contains(triggerActiveElement) ||
448-
(triggerRoot.host &&
449-
triggerRoot.host === triggerActiveElement)
450-
) {
451-
overlay.trigger.focus();
452-
}
453-
}
454-
overlay.tabbingAway = false;
455-
}
460+
if (overlay.state != 'dispose') return;
456461

457-
overlay.remove();
458-
overlay.dispose();
459-
460-
overlay.trigger.dispatchEvent(
461-
new CustomEvent<OverlayOpenCloseDetail>('sp-closed', {
462-
bubbles: true,
463-
composed: true,
464-
cancelable: true,
465-
detail: {
466-
interaction: overlay.interaction,
467-
},
468-
})
469-
);
462+
const index = this.overlays.indexOf(overlay);
463+
if (index >= 0) {
464+
this.overlays.splice(index, 1);
470465
}
466+
467+
if (this.overlays.length) {
468+
this.manageFocusAfterCloseWhenOverlaysRemain();
469+
} else {
470+
this.manageFocusAfterCloseWhenLastOverlay(overlay);
471+
}
472+
473+
overlay.remove();
474+
overlay.dispose();
475+
476+
overlay.trigger.dispatchEvent(
477+
new CustomEvent<OverlayOpenCloseDetail>('sp-closed', {
478+
bubbles: true,
479+
composed: true,
480+
cancelable: true,
481+
detail: {
482+
interaction: overlay.interaction,
483+
},
484+
})
485+
);
471486
}
472487

473488
private closeTopOverlay(): Promise<void> {
@@ -494,9 +509,7 @@ export class OverlayStack {
494509

495510
private handleKeyUp = (event: KeyboardEvent): void => {
496511
if (event.code === 'Escape') {
497-
const overlay = this.topOverlay as ActiveOverlay;
498512
this.closeTopOverlay();
499-
overlay && overlay.trigger.focus();
500513
}
501514
};
502515

packages/picker/src/Picker.ts

+34-2
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,28 @@ export class PickerBase extends SizedMixin(Focusable) {
160160
this.toggle();
161161
}
162162

163+
public focus(options?: FocusOptions): void {
164+
super.focus(options);
165+
166+
if (!this.disabled && this.focusElement) {
167+
const activeElement = (this.getRootNode() as Document)
168+
.activeElement as HTMLElement;
169+
let shouldFocus = false;
170+
try {
171+
// Browsers without support for the `:focus-visible`
172+
// selector will throw on the following test (Safari, older things).
173+
// Some won't throw, but will be focusing item rather than the menu and
174+
// will rely on the polyfill to know whether focus is "visible" or not.
175+
shouldFocus =
176+
activeElement.matches(':focus-visible') ||
177+
activeElement.matches('.focus-visible');
178+
} catch (error) {
179+
shouldFocus = activeElement.matches('.focus-visible');
180+
}
181+
this.focused = shouldFocus;
182+
}
183+
}
184+
163185
public onHelperFocus(): void {
164186
// set focused to true here instead of onButtonFocus so clicks don't flash a focus outline
165187
this.focused = true;
@@ -181,6 +203,7 @@ export class PickerBase extends SizedMixin(Focusable) {
181203
}
182204

183205
protected onKeydown = (event: KeyboardEvent): void => {
206+
this.focused = true;
184207
if (event.code !== 'ArrowDown' && event.code !== 'ArrowUp') {
185208
return;
186209
}
@@ -236,6 +259,10 @@ export class PickerBase extends SizedMixin(Focusable) {
236259
this.open = false;
237260
}
238261

262+
public overlayCloseCallback = (): void => {
263+
this.open = false;
264+
};
265+
239266
protected onOverlayClosed(): void {
240267
this.close();
241268
if (this.restoreChildren) {
@@ -290,7 +317,7 @@ export class PickerBase extends SizedMixin(Focusable) {
290317
},
291318
{ once: true }
292319
);
293-
this.closeOverlay = await Picker.openOverlay(this, 'inline', popover, {
320+
this.closeOverlay = await Picker.openOverlay(this, 'modal', popover, {
294321
placement: this.placement,
295322
receivesFocus: 'auto',
296323
});
@@ -406,7 +433,11 @@ export class PickerBase extends SizedMixin(Focusable) {
406433

407434
protected get renderPopover(): TemplateResult {
408435
return html`
409-
<sp-popover id="popover" @sp-overlay-closed=${this.onOverlayClosed}>
436+
<sp-popover
437+
id="popover"
438+
@sp-overlay-closed=${this.onOverlayClosed}
439+
.overlayCloseCallback=${this.overlayCloseCallback}
440+
>
410441
<sp-menu
411442
id="menu"
412443
role="${this.listRole}"
@@ -546,6 +577,7 @@ export class Picker extends PickerBase {
546577

547578
protected onKeydown = (event: KeyboardEvent): void => {
548579
const { code } = event;
580+
this.focused = true;
549581
if (!code.startsWith('Arrow') || this.readonly) {
550582
return;
551583
}

0 commit comments

Comments
 (0)