diff --git a/packages/calcite-components/src/components/dialog/dialog.tsx b/packages/calcite-components/src/components/dialog/dialog.tsx index c7432adf329..4819ec7c7c7 100644 --- a/packages/calcite-components/src/components/dialog/dialog.tsx +++ b/packages/calcite-components/src/components/dialog/dialog.tsx @@ -161,6 +161,7 @@ export class Dialog extends LitElement implements OpenCloseComponent { * `"initialFocus"` enables initial focus, * `"returnFocusOnDeactivate"` returns focus when not active, and * `"extraContainers"` specifies additional focusable elements external to the trap (e.g., 3rd-party components appending elements to the document body). + * `"setReturnFocus"` customizes the element to which focus is returned when the trap is deactivated. Return `false` to prevent focus return, or `undefined` to use the default behavior (returning focus to the element focused before activation). */ @property() focusTrapOptions: Partial; diff --git a/packages/calcite-components/src/components/modal/modal.tsx b/packages/calcite-components/src/components/modal/modal.tsx index 735cb7f9810..78ae7dd93fe 100644 --- a/packages/calcite-components/src/components/modal/modal.tsx +++ b/packages/calcite-components/src/components/modal/modal.tsx @@ -183,6 +183,7 @@ export class Modal extends LitElement implements OpenCloseComponent { * `"initialFocus"` enables initial focus, * `"returnFocusOnDeactivate"` returns focus when not active, and * `"extraContainers"` specifies additional focusable elements external to the trap (e.g., 3rd-party components appending elements to the document body). + * `"setReturnFocus"` customizes the element to which focus is returned when the trap is deactivated. Return `false` to prevent focus return, or `undefined` to use the default behavior (returning focus to the element focused before activation). */ @property() focusTrapOptions: Partial; diff --git a/packages/calcite-components/src/components/popover/popover.tsx b/packages/calcite-components/src/components/popover/popover.tsx index be6c8940710..4502449aa4a 100644 --- a/packages/calcite-components/src/components/popover/popover.tsx +++ b/packages/calcite-components/src/components/popover/popover.tsx @@ -137,6 +137,7 @@ export class Popover extends LitElement implements FloatingUIComponent, OpenClos * `"initialFocus"` enables initial focus, * `"returnFocusOnDeactivate"` returns focus when not active, and * `"extraContainers"` specifies additional focusable elements external to the trap (e.g., 3rd-party components appending elements to the document body). + * `"setReturnFocus"` customizes the element to which focus is returned when the trap is deactivated. Return `false` to prevent focus return, or `undefined` to use the default behavior (returning focus to the element focused before activation). */ @property() focusTrapOptions: Partial; diff --git a/packages/calcite-components/src/components/sheet/sheet.tsx b/packages/calcite-components/src/components/sheet/sheet.tsx index 38a0de365cb..29ef90b538e 100644 --- a/packages/calcite-components/src/components/sheet/sheet.tsx +++ b/packages/calcite-components/src/components/sheet/sheet.tsx @@ -157,6 +157,7 @@ export class Sheet extends LitElement implements OpenCloseComponent { * `"initialFocus"` enables initial focus, * `"returnFocusOnDeactivate"` returns focus when not active, and * `"extraContainers"` specifies additional focusable elements external to the trap (e.g., 3rd-party components appending elements to the document body). + * `"setReturnFocus"` customizes the element to which focus is returned when the trap is deactivated. Return `false` to prevent focus return, or `undefined` to use the default behavior (returning focus to the element focused before activation). */ @property() focusTrapOptions: Partial; diff --git a/packages/calcite-components/src/controllers/useFocusTrap.ts b/packages/calcite-components/src/controllers/useFocusTrap.ts index be8615bb550..16be1887a76 100644 --- a/packages/calcite-components/src/controllers/useFocusTrap.ts +++ b/packages/calcite-components/src/controllers/useFocusTrap.ts @@ -1,6 +1,7 @@ import { makeGenericController } from "@arcgis/lumina/controllers"; import { createFocusTrap, FocusTrap, Options as Options } from "focus-trap"; import { LitElement } from "@arcgis/lumina"; +import { SetReturnType } from "type-fest"; import { createFocusTrapOptions } from "../utils/focusTrapComponent"; export interface UseFocusTrap { @@ -70,6 +71,20 @@ export type FocusTrapOptions = * Additional elements to include in the focus trap. This is useful for including elements that may have related parts rendered outside the main focus-trap element. */ extraContainers: Parameters[0]; + + /** + * By default, when the focus trap is deactivated, focus will return to the element that was focused before the trap was activated. This option allows customizing that behavior. + * + * Returning undefined will use the default behavior of returning focus to the element focused before activation. + */ + setReturnFocus?: + | Options["setReturnFocus"] + | undefined + | SetReturnType< + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- we only want this for function types regardless of signature + Extract, + undefined + >; }; function getEffectiveContainerElements( diff --git a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts index a413ad33e31..4187341f7c3 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts @@ -1,5 +1,7 @@ // @ts-strict-ignore -import { describe, expect, it, afterEach, beforeEach, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DetachedWindowAPI, Window as HappyDOMWindow } from "happy-dom"; +import { GlobalThis } from "type-fest"; import { GlobalTestProps } from "../tests/utils/puppeteer"; import { activateFocusTrap, @@ -112,6 +114,7 @@ describe("focusTrapComponent", () => { expect(customFocusTrapStack).toHaveLength(1); }); }); + describe("focusTrapDisabledOverride", () => { const fakeComponent = {} as FocusTrapComponent; let activateSpy: ReturnType; @@ -141,4 +144,86 @@ describe("focusTrapComponent", () => { expect(activateSpy).toHaveBeenCalledTimes(0); }); }); + + describe("focusTrapOptions", () => { + let happyDOM: DetachedWindowAPI; + let fakeComponent: FocusTrapComponent; + let insideButton: HTMLButtonElement; + let previousFocusedEl: HTMLInputElement; + let nextFocusedEl: HTMLInputElement; + + function setUpTest(options: Parameters[1]): void { + fakeComponent = {} as FocusTrapComponent; + fakeComponent.el = document.createElement("div"); + insideButton = document.createElement("button"); + insideButton.id = "inside-button"; + fakeComponent.el.append(insideButton); + previousFocusedEl = document.createElement("input"); + nextFocusedEl = document.createElement("input"); + document.body.append(nextFocusedEl, previousFocusedEl, fakeComponent.el); + previousFocusedEl.focus(); + + connectFocusTrap(fakeComponent, options); + } + + beforeEach(() => { + happyDOM = (globalThis as GlobalThis & HappyDOMWindow).happyDOM; + }); + + describe("setReturnFocus option", () => { + it("should use custom setReturnFocus function if provided", async () => { + setUpTest({ + focusTrapOptions: { + setReturnFocus: () => nextFocusedEl, + }, + }); + + activateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(insideButton); + + deactivateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(nextFocusedEl); + }); + + it("allows disabling return focus behavior", async () => { + setUpTest({ + focusTrapOptions: { + setReturnFocus: false, + }, + }); + + activateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(insideButton); + + deactivateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(insideButton); + }); + + it("should use default setReturnFocus if custom function is not provided", async () => { + setUpTest({ + focusTrapOptions: { + setReturnFocus: undefined, + }, + }); + + activateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(insideButton); + + deactivateFocusTrap(fakeComponent); + await happyDOM.waitUntilComplete(); + + expect(document.activeElement).toBe(previousFocusedEl); + }); + }); + }); }); diff --git a/packages/calcite-components/src/utils/focusTrapComponent.ts b/packages/calcite-components/src/utils/focusTrapComponent.ts index 22e1bcf433f..81745d463de 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.ts @@ -16,6 +16,17 @@ export interface FocusTrapComponent { /** The focus trap instance. */ focusTrap: FocusTrap; + /** + * Specifies custom focus trap configuration on the component, where + * + * `"allowOutsideClick`" allows outside clicks, + * `"initialFocus"` enables initial focus, + * `"returnFocusOnDeactivate"` returns focus when not active, and + * `"extraContainers"` specifies additional focusable elements external to the trap (e.g., 3rd-party components appending elements to the document body). + * `"setReturnFocus"` customizes the element to which focus is returned when the trap is deactivated. Return `false` to prevent focus return, or `undefined` to use the default behavior (returning focus to the element focused before activation). + */ + focusTrapOptions?: Partial; + /** * Method to update the element(s) that are used within the FocusTrap component. * @@ -53,6 +64,20 @@ export function connectFocusTrap(component: FocusTrapComponent, options?: Connec const outsideClickDeactivated = new WeakSet(); +/** + * Default behavior for returning focus when the FocusTrap is deactivated. + * + * @param hostEl + * @param el + */ +function defaultSetReturnFocus(hostEl: HTMLElement, el: HTMLElement | SVGElement): false { + if (!outsideClickDeactivated.has(hostEl)) { + focusElement(el as FocusableElement); + } + + return false; +} + /** * Helper to create the FocusTrap options. * @@ -65,12 +90,6 @@ export function createFocusTrapOptions(hostEl: HTMLElement, options?: FocusTrapO return { fallbackFocus, - setReturnFocus: (el) => { - if (!outsideClickDeactivated.has(hostEl)) { - focusElement(el as FocusableElement); - } - return false; - }, ...options, // the following options are not overridable @@ -86,6 +105,12 @@ export function createFocusTrapOptions(hostEl: HTMLElement, options?: FocusTrapO onPostDeactivate: () => { outsideClickDeactivated.delete(hostEl); }, + setReturnFocus: (el) => { + const returnFocusTarget = + typeof options?.setReturnFocus === "function" ? options.setReturnFocus(el) : options?.setReturnFocus; + + return returnFocusTarget === undefined ? defaultSetReturnFocus(hostEl, el) : returnFocusTarget; + }, }; }