Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sheet, modal, popover): support stacked component sequential closing with escape #9231

Draft
wants to merge 11 commits into
base: dev
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,10 @@ export class InputDatePicker
this.datePickerEl.reset();
}

onFocusTrapDeactivate(): void {
this.open = false;
}

setStartInput = (el: HTMLCalciteInputElement): void => {
this.startInput = el;
};
Expand Down Expand Up @@ -932,10 +936,6 @@ export class InputDatePicker
this.open = true;
this.focusOnOpen = true;
event.preventDefault();
} else if (key === "Escape") {
this.open = false;
event.preventDefault();
this.restoreInputFocus();
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,10 @@ export class InputTimePicker
this.calciteInputTimePickerClose.emit();
}

onFocusTrapDeactivate(): void {
this.open = false;
}

private delocalizeTimeString(value: string): string {
// we need to set the corresponding locale before parsing, otherwise it defaults to English (possible dayjs bug)
dayjs.locale(this.effectiveLocale.toLowerCase());
Expand Down Expand Up @@ -716,10 +720,6 @@ export class InputTimePicker
this.open = true;
this.focusOnOpen = true;
event.preventDefault();
} else if (key === "Escape" && this.open) {
this.open = false;
event.preventDefault();
this.calciteInputEl.setFocus();
}
};

Expand Down
19 changes: 4 additions & 15 deletions packages/calcite-components/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
EventEmitter,
h,
Host,
Listen,
Method,
Prop,
State,
Expand Down Expand Up @@ -393,20 +392,6 @@ export class Modal

@State() defaultMessages: ModalMessages;

//--------------------------------------------------------------------------
//
// Event Listeners
//
//--------------------------------------------------------------------------

@Listen("keydown", { target: "window" })
handleEscape(event: KeyboardEvent): void {
if (this.open && !this.escapeDisabled && event.key === "Escape" && !event.defaultPrevented) {
this.open = false;
event.preventDefault();
}
}

//--------------------------------------------------------------------------
//
// Events
Expand Down Expand Up @@ -498,6 +483,10 @@ export class Modal
deactivateFocusTrap(this);
}

onFocusTrapDeactivate(): void {
this.open = false;
}

@Watch("open")
toggleModal(value: boolean): void {
if (this.ignoreOpenChange) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,10 @@ export class Popover
deactivateFocusTrap(this);
}

onFocusTrapDeactivate(): void {
this.open = false;
}

storeArrowEl = (el: SVGElement): void => {
this.arrowEl = el;
this.reposition(true);
Expand Down
19 changes: 4 additions & 15 deletions packages/calcite-components/src/components/sheet/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
EventEmitter,
h,
Host,
Listen,
Method,
Prop,
VNode,
Expand Down Expand Up @@ -218,20 +217,6 @@ export class Sheet implements OpenCloseComponent, FocusTrapComponent, LoadableCo
this.handleMutationObserver(),
);

//--------------------------------------------------------------------------
//
// Event Listeners
//
//--------------------------------------------------------------------------

@Listen("keydown", { target: "window" })
handleEscape(event: KeyboardEvent): void {
if (this.open && !this.escapeDisabled && event.key === "Escape" && !event.defaultPrevented) {
this.open = false;
event.preventDefault();
}
}

//--------------------------------------------------------------------------
//
// Events
Expand Down Expand Up @@ -298,6 +283,10 @@ export class Sheet implements OpenCloseComponent, FocusTrapComponent, LoadableCo
deactivateFocusTrap(this);
}

onFocusTrapDeactivate(): void {
this.open = false;
}

private setTransitionEl = (el: HTMLDivElement): void => {
this.transitionEl = el;
this.contentId = ensureId(el);
Expand Down
130 changes: 129 additions & 1 deletion packages/calcite-components/src/tests/globalStyles.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { newE2EPage } from "@stencil/core/testing";
import { E2EElement, newE2EPage, E2EPage } from "@stencil/core/testing";
import { html } from "../../support/formatting";
import { skipAnimations } from "../tests/utils";

describe("global styles", () => {
describe("animation", () => {
const snippet = `<calcite-notice width="half" id="in" class="calcite-animate ">
Expand Down Expand Up @@ -92,4 +94,130 @@ describe("global styles", () => {
});
expect(eleTransitionDuration).toEqual("0.15s");
});

describe("stacked focus-trap components", () => {
const componentStack = html`
<calcite-sheet id="example-sheet" label="libero nunc" position="inline-start" display-mode="overlay">
<calcite-panel closable>
<calcite-block open heading="Preview Sheet options"> </calcite-block>
<calcite-button onClick="openComponent('example-modal')"> Open Modal from Sheet</calcite-button>
</calcite-panel>
</calcite-sheet>

<calcite-modal id="example-modal">
<div slot="content">
<p>This is an example modal that opens from a Sheet.</p>
</div>
<calcite-button slot="back" width="full" onClick="openComponent('another-modal')"
>Open Another Modal</calcite-button
>
</calcite-modal>

<calcite-modal id="another-modal">
<div slot="content" style="display: flex; flex-direction: column; gap: 12px; margin: 100px"">
<p>
This is an example of a another modal that opens from a modal. This modal an input date picker, a combobox, a
dropdown, a popover and a tooltip.
</p>
<calcite-combobox
label="test"
placeholder="placeholder"
max-items="8"
selection-mode="ancestors"
style="width: 200px"
id="combobox"
>
<calcite-combobox-item value="Grand 1" text-label="Grand 1">
<calcite-combobox-item value="Parent 1" text-label="Parent 1">
<calcite-combobox-item value="Child 1" text-label="Child 1"></calcite-combobox-item>
<calcite-combobox-item value="Child 2" text-label="Child 2"></calcite-combobox-item>
</calcite-combobox-item>
</calcite-combobox-item>
</calcite-combobox>
<calcite-dropdown scale="s" width-scale="s" id="dropdown">
<calcite-button icon-end="hamburger" appearance="outline" slot="trigger">Scale S</calcite-button>
<calcite-dropdown-group group-title="View">
<calcite-dropdown-item icon-end="list-bullet" selected>List</calcite-dropdown-item>
<calcite-dropdown-item icon-end="grid">Grid</calcite-dropdown-item>
</calcite-dropdown-group>
</calcite-dropdown>
<calcite-popover
heading="Heading"
label="right end popover"
reference-element="popover-button"
placement="right-end"
id="popover-heading"
closable
style="width: 25vw"
id="popover"
>
<div style="padding: 0.5rem 1rem 0.75rem">
<p style="margin-top: 0">Example Popover.</p>
<calcite-label>
Input Date Picker
<calcite-input-date-picker value="2023-03-07" id="input-date-picker"></calcite-input-date-picker>
</calcite-label>
<calcite-label>
Input Time Picker
<calcite-input-time-picker name="calcite-input-time-picker"></calcite-input-time-picker>
</calcite-label>
</div>
</calcite-popover>
<calcite-button appearance="outline" id="popover-button" icon-start="popup">Example Popover</calcite-button>
<calcite-tooltip placement="auto" reference-element="tooltip-auto-ref"> Example Tooltip </calcite-tooltip>
<calcite-button appearance="outline" id="tooltip-auto-ref">auto</calcite-button>
</div>
</calcite-modal>
<calcite-button onClick="openComponent('example-sheet')"> Open Sheet </calcite-button>
`;

it("closes a stack of open components sequentially in visual order", async () => {
const page = await newE2EPage();
await page.setContent(componentStack);
await skipAnimations(page);

async function testStackEscapeSequence(page: E2EPage, pickerType: string) {
async function openAndCheckVisibility(element: E2EElement) {
element.setProperty("open", true);
await page.waitForChanges();
expect(await element.isVisible()).toBe(true);
}

const sheet = await page.find("calcite-sheet");
await openAndCheckVisibility(sheet);

const firstModal = await page.find("#example-modal");
await openAndCheckVisibility(firstModal);

const secondModal = await page.find("#another-modal");
await openAndCheckVisibility(secondModal);

const popover = await page.find("calcite-popover");
await openAndCheckVisibility(popover);

const inputPicker = await page.find(pickerType);
inputPicker.click();
await page.waitForChanges();
expect(await inputPicker.getProperty("open")).toBe(true);

async function testEscapeAndCheckOpenState(elements: E2EElement[]) {
for (let i = 0; i < elements.length; i++) {
await page.keyboard.press("Escape");
await page.waitForChanges();
expect(await elements[i].getProperty("open")).toBe(false);

for (let j = 0; j < elements.length; j++) {
const expectedOpenState = j > i;
expect(await elements[j].getProperty("open")).toBe(expectedOpenState);
}
}
}

await testEscapeAndCheckOpenState([inputPicker, popover, secondModal, firstModal, sheet]);
}

await testStackEscapeSequence(page, "calcite-input-date-picker");
await testStackEscapeSequence(page, "calcite-input-time-picker");
});
});
});
16 changes: 14 additions & 2 deletions packages/calcite-components/src/utils/focusTrapComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface FocusTrapComponent {
*/
el: HTMLElement;

/** When `true`, disables the default close on escape behavior. */
driskull marked this conversation as resolved.
Show resolved Hide resolved
escapeDisabled?: boolean;

/** When `true`, disables the closing of the component when clicked outside. */
outsideCloseDisabled?: boolean;

/**
* When `true`, prevents focus trapping.
*/
Expand All @@ -27,6 +33,11 @@ export interface FocusTrapComponent {
* This should be implemented for components that allow user content and/or have conditionally-rendered focusable elements within the trap.
*/
updateFocusTrapElements?: () => Promise<void>;

/**
* Method that will be called before returning focus to the node that had focus prior to activation upon deactivation.
*/
onFocusTrapDeactivate?(): void;
driskull marked this conversation as resolved.
Show resolved Hide resolved
}

export type FocusTrap = _FocusTrap;
Expand Down Expand Up @@ -58,9 +69,10 @@ export function connectFocusTrap(component: FocusTrapComponent, options?: Connec
}

const focusTrapOptions: FocusTrapOptions = {
clickOutsideDeactivates: true,
escapeDeactivates: false,
clickOutsideDeactivates: !component.outsideCloseDisabled ?? true,
escapeDeactivates: !component.escapeDisabled ?? true,
fallbackFocus: focusTrapNode,
onDeactivate: () => component.onFocusTrapDeactivate?.(),
setReturnFocus: (el) => {
focusElement(el as FocusableElement);
return false;
Expand Down