Skip to content

Commit

Permalink
fix(picker): use "modal" as the menu overlay interaction
Browse files Browse the repository at this point in the history
  • Loading branch information
Westbrook committed Oct 28, 2021
1 parent 5111ddb commit c8fbbe2
Show file tree
Hide file tree
Showing 7 changed files with 1,911 additions and 1,776 deletions.
157 changes: 85 additions & 72 deletions packages/overlay/src/overlay-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,83 +391,98 @@ export class OverlayStack {
}
}

private manageFocusAfterCloseWhenOverlaysRemain(): void {
const topOverlay = this.overlays[this.overlays.length - 1];
topOverlay.feature();
// Push focus in the the next remaining overlay as needed when a `type="modal"` overlay exists.
if (topOverlay.interaction === 'modal' || topOverlay.hasModalRoot) {
topOverlay.focus();
} else {
this.stopTabTrapping();
}
}

private manageFocusAfterCloseWhenLastOverlay(overlay: ActiveOverlay): void {
this.stopTabTrapping();
const isModal = overlay.interaction === 'modal';
const isReplace = overlay.interaction === 'replace';
const isInline = overlay.interaction === 'inline';
const isTabbingAwayFromInlineOrReplace =
(isReplace || isInline) && !overlay.tabbingAway;
overlay.tabbingAway = false;
if (!isModal && !isTabbingAwayFromInlineOrReplace) {
return;
}
// Manage post closure focus when needed.
const overlayRoot = overlay.overlayContent.getRootNode() as ShadowRoot;
const overlayContentActiveElement = overlayRoot.activeElement;
let triggerRoot: ShadowRoot;
let triggerActiveElement: Element | null;
const contentContainsActiveElement = (): boolean =>
overlay.overlayContent.contains(overlayContentActiveElement);
const triggerRootContainsActiveElement = (): boolean => {
triggerRoot = overlay.trigger.getRootNode() as ShadowRoot;
triggerActiveElement = triggerRoot.activeElement;
return triggerRoot.contains(triggerActiveElement);
};
const triggerHostIsActiveElement = (): boolean =>
triggerRoot.host && triggerRoot.host === triggerActiveElement;
// Return focus to the trigger as long as the user hasn't actively focused
// something outside of the current overlay interface; trigger, root, host.
if (
isModal ||
contentContainsActiveElement() ||
triggerRootContainsActiveElement() ||
triggerHostIsActiveElement()
) {
overlay.trigger.focus();
}
}

private async hideAndCloseOverlay(
overlay?: ActiveOverlay,
animated?: boolean
): Promise<void> {
if (overlay) {
await overlay.hide(animated);
const contentWithLifecycle =
overlay.overlayContent as unknown as ManagedOverlayContent;
if (typeof contentWithLifecycle.open !== 'undefined') {
contentWithLifecycle.open = false;
}
if (contentWithLifecycle.overlayCloseCallback) {
const { trigger } = overlay;
contentWithLifecycle.overlayCloseCallback({ trigger });
}
if (overlay.state != 'dispose') return;
if (!overlay) {
return;
}
await overlay.hide(animated);
const contentWithLifecycle =
overlay.overlayContent as unknown as ManagedOverlayContent;
if (typeof contentWithLifecycle.open !== 'undefined') {
contentWithLifecycle.open = false;
}
if (contentWithLifecycle.overlayCloseCallback) {
const { trigger } = overlay;
contentWithLifecycle.overlayCloseCallback({ trigger });
}

const index = this.overlays.indexOf(overlay);
if (index >= 0) {
this.overlays.splice(index, 1);
}
if (this.overlays.length) {
const topOverlay = this.overlays[this.overlays.length - 1];
topOverlay.feature();
if (
topOverlay.interaction === 'modal' ||
topOverlay.hasModalRoot
) {
topOverlay.focus();
} else {
this.stopTabTrapping();
}
} else {
this.stopTabTrapping();
if (
overlay.interaction === 'modal' ||
((overlay.interaction === 'replace' ||
overlay.interaction === 'inline') &&
!overlay.tabbingAway)
) {
const overlayRoot =
overlay.overlayContent.getRootNode() as ShadowRoot;
const overlayContentActiveElement =
overlayRoot.activeElement;
const triggerRoot =
overlay.trigger.getRootNode() as ShadowRoot;
const triggerActiveElement = triggerRoot.activeElement;
if (
overlay.overlayContent.contains(
overlayContentActiveElement
) ||
overlay.trigger
.getRootNode()
.contains(triggerActiveElement) ||
(triggerRoot.host &&
triggerRoot.host === triggerActiveElement)
) {
overlay.trigger.focus();
}
}
overlay.tabbingAway = false;
}
if (overlay.state != 'dispose') return;

overlay.remove();
overlay.dispose();

overlay.trigger.dispatchEvent(
new CustomEvent<OverlayOpenCloseDetail>('sp-closed', {
bubbles: true,
composed: true,
cancelable: true,
detail: {
interaction: overlay.interaction,
},
})
);
const index = this.overlays.indexOf(overlay);
if (index >= 0) {
this.overlays.splice(index, 1);
}

if (this.overlays.length) {
this.manageFocusAfterCloseWhenOverlaysRemain();
} else {
this.manageFocusAfterCloseWhenLastOverlay(overlay);
}

overlay.remove();
overlay.dispose();

overlay.trigger.dispatchEvent(
new CustomEvent<OverlayOpenCloseDetail>('sp-closed', {
bubbles: true,
composed: true,
cancelable: true,
detail: {
interaction: overlay.interaction,
},
})
);
}

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

private handleKeyUp = (event: KeyboardEvent): void => {
if (event.code === 'Escape') {
const overlay = this.topOverlay as ActiveOverlay;
this.closeTopOverlay();
overlay && overlay.trigger.focus();
}
};

Expand Down
36 changes: 34 additions & 2 deletions packages/picker/src/Picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,28 @@ export class PickerBase extends SizedMixin(Focusable) {
this.toggle();
}

public focus(options?: FocusOptions): void {
super.focus(options);

if (!this.disabled && this.focusElement) {
const activeElement = (this.getRootNode() as Document)
.activeElement as HTMLElement;
let shouldFocus = false;
try {
// Browsers without support for the `:focus-visible`
// selector will throw on the following test (Safari, older things).
// Some won't throw, but will be focusing item rather than the menu and
// will rely on the polyfill to know whether focus is "visible" or not.
shouldFocus =
activeElement.matches(':focus-visible') ||
activeElement.matches('.focus-visible');
} catch (error) {
shouldFocus = activeElement.matches('.focus-visible');
}
this.focused = shouldFocus;
}
}

public onHelperFocus(): void {
// set focused to true here instead of onButtonFocus so clicks don't flash a focus outline
this.focused = true;
Expand All @@ -181,6 +203,7 @@ export class PickerBase extends SizedMixin(Focusable) {
}

protected onKeydown = (event: KeyboardEvent): void => {
this.focused = true;
if (event.code !== 'ArrowDown' && event.code !== 'ArrowUp') {
return;
}
Expand Down Expand Up @@ -236,6 +259,10 @@ export class PickerBase extends SizedMixin(Focusable) {
this.open = false;
}

public overlayCloseCallback = (): void => {
this.open = false;
};

protected onOverlayClosed(): void {
this.close();
if (this.restoreChildren) {
Expand Down Expand Up @@ -290,7 +317,7 @@ export class PickerBase extends SizedMixin(Focusable) {
},
{ once: true }
);
this.closeOverlay = await Picker.openOverlay(this, 'inline', popover, {
this.closeOverlay = await Picker.openOverlay(this, 'modal', popover, {
placement: this.placement,
receivesFocus: 'auto',
});
Expand Down Expand Up @@ -406,7 +433,11 @@ export class PickerBase extends SizedMixin(Focusable) {

protected get renderPopover(): TemplateResult {
return html`
<sp-popover id="popover" @sp-overlay-closed=${this.onOverlayClosed}>
<sp-popover
id="popover"
@sp-overlay-closed=${this.onOverlayClosed}
.overlayCloseCallback=${this.overlayCloseCallback}
>
<sp-menu
id="menu"
role="${this.listRole}"
Expand Down Expand Up @@ -546,6 +577,7 @@ export class Picker extends PickerBase {

protected onKeydown = (event: KeyboardEvent): void => {
const { code } = event;
this.focused = true;
if (!code.startsWith('Arrow') || this.readonly) {
return;
}
Expand Down
Loading

0 comments on commit c8fbbe2

Please # to comment.