diff --git a/packages/calcite-components/src/components/dialog/dialog.e2e.ts b/packages/calcite-components/src/components/dialog/dialog.e2e.ts index d9acdb326c8..c96ed09dfbc 100644 --- a/packages/calcite-components/src/components/dialog/dialog.e2e.ts +++ b/packages/calcite-components/src/components/dialog/dialog.e2e.ts @@ -1,6 +1,6 @@ // @ts-strict-ignore import { newE2EPage, E2EPage } from "@arcgis/lumina-compiler/puppeteerTesting"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { accessible, defaults, @@ -1160,4 +1160,59 @@ describe("calcite-dialog", () => { expect(await dialog.getProperty("open")).toBe(true); }); }); + + describe("focusTrap", () => { + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent(html` + + + `); + + await skipAnimations(page); + await page.waitForChanges(); + }); + + it("can tab out of non-modal dialog when focusTrapDisabled=true", async () => { + const dialog = await page.find("calcite-dialog >>> .container"); + const action = await page.find("calcite-dialog >>> calcite-action"); + const outsideEl = await page.find("#outsideEl"); + + expect(await dialog.isVisible()).toBe(true); + + await action.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + const activeElementId = await page.evaluate(() => document.activeElement.id); + expect(activeElementId).toBe(await outsideEl.getProperty("id")); + }); + + it("cannot tab out of dialog when modal=true and focusTrapDisabled=true", async () => { + const dialog = await page.find("calcite-dialog >>> .container"); + const insideEl = await page.find("#insideEl"); + + expect(await dialog.isVisible()).toBe(true); + + await page.keyboard.press("Tab"); + await page.waitForChanges(); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + const activeElementId = await page.evaluate(() => document.activeElement.id); + expect(activeElementId).toBe(await insideEl.getProperty("id")); + }); + }); }); diff --git a/packages/calcite-components/src/components/dialog/dialog.tsx b/packages/calcite-components/src/components/dialog/dialog.tsx index 1b1f65df7b2..40466cd0856 100644 --- a/packages/calcite-components/src/components/dialog/dialog.tsx +++ b/packages/calcite-components/src/components/dialog/dialog.tsx @@ -187,6 +187,9 @@ export class Dialog /** When `true`, displays a scrim blocking interaction underneath the component. */ @property({ reflect: true }) modal = false; + /** When `true` and `modal` is `false`, prevents focus trapping. */ + @property({ reflect: true }) focusTrapDisabled = false; + /** When `true`, displays and positions the component. */ @property({ reflect: true }) get open(): boolean { @@ -270,6 +273,11 @@ export class Dialog updateFocusTrapElements(this); } + /** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */ + focusTrapDisabledOverride(): boolean { + return !this.modal && this.focusTrapDisabled; + } + // #endregion // #region Events @@ -328,6 +336,9 @@ export class Dialog if (changes.has("modal") && (this.hasUpdated || this.modal !== false)) { this.updateOverflowHiddenClass(); } + if ((changes.has("modal") || changes.has("focusTrapDisabled")) && this.hasUpdated) { + this.handleFocusTrapDisabled(); + } if ( (changes.has("open") && (this.hasUpdated || this.open !== false)) || @@ -366,6 +377,15 @@ export class Dialog // #endregion // #region Private Methods + + private handleFocusTrapDisabled(): void { + if (!this.open) { + return; + } + + activateFocusTrap(this); + } + private updateAssistiveText(): void { const { messages } = this; this.assistiveText = @@ -380,7 +400,7 @@ export class Dialog onOpen(): void { this.calciteDialogOpen.emit(); - activateFocusTrap(this); + this.handleFocusTrapDisabled(); } onBeforeClose(): void { diff --git a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts index 8fd9f02b5b1..993f81c215d 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts @@ -112,4 +112,37 @@ describe("focusTrapComponent", () => { expect(customFocusTrapStack).toHaveLength(1); }); }); + describe("focusTrapDisabledOverride", () => { + it("should activate focus trap when focusTrapDisabledOverride returns false", () => { + const fakeComponent = {} as FocusTrapComponent; + fakeComponent.el = document.createElement("div"); + + connectFocusTrap(fakeComponent); + + const activateSpy = vi.fn(); + fakeComponent.focusTrap.activate = activateSpy; + + fakeComponent.focusTrapDisabledOverride = () => false; + + activateFocusTrap(fakeComponent); + + expect(activateSpy).toHaveBeenCalledTimes(1); + }); + + it("should not activate focus trap when focusTrapDisabledOverride returns true", () => { + const fakeComponent = {} as FocusTrapComponent; + fakeComponent.el = document.createElement("div"); + + connectFocusTrap(fakeComponent); + + const activateSpy = vi.fn(); + fakeComponent.focusTrap.activate = activateSpy; + + fakeComponent.focusTrapDisabledOverride = () => true; + + activateFocusTrap(fakeComponent); + + expect(activateSpy).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/packages/calcite-components/src/utils/focusTrapComponent.ts b/packages/calcite-components/src/utils/focusTrapComponent.ts index 8c5c560526e..d41fffadb3c 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.ts @@ -10,6 +10,9 @@ export interface FocusTrapComponent { /** When `true`, prevents focus trapping. */ focusTrapDisabled?: boolean; + /** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */ + focusTrapDisabledOverride?: () => boolean; + /** The focus trap instance. */ focusTrap: FocusTrap; @@ -73,7 +76,7 @@ export function activateFocusTrap( component: FocusTrapComponent, options?: Parameters<_FocusTrap["activate"]>[0], ): void { - if (!component.focusTrapDisabled) { + if (component.focusTrapDisabledOverride ? !component.focusTrapDisabledOverride() : !component.focusTrapDisabled) { component.focusTrap?.activate(options); } }