Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusTrapOptions>;

Expand Down
1 change: 1 addition & 0 deletions packages/calcite-components/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusTrapOptions>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusTrapOptions>;

Expand Down
1 change: 1 addition & 0 deletions packages/calcite-components/src/components/sheet/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusTrapOptions>;

Expand Down
15 changes: 15 additions & 0 deletions packages/calcite-components/src/controllers/useFocusTrap.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<FocusTrap["updateContainerElements"]>[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<Options["setReturnFocus"], Function>,
undefined
>;
};

function getEffectiveContainerElements(
Expand Down
87 changes: 86 additions & 1 deletion packages/calcite-components/src/utils/focusTrapComponent.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -112,6 +114,7 @@ describe("focusTrapComponent", () => {
expect(customFocusTrapStack).toHaveLength(1);
});
});

describe("focusTrapDisabledOverride", () => {
const fakeComponent = {} as FocusTrapComponent;
let activateSpy: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -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<typeof connectFocusTrap>[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);
});
});
});
});
37 changes: 31 additions & 6 deletions packages/calcite-components/src/utils/focusTrapComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusTrapOptions>;

/**
* Method to update the element(s) that are used within the FocusTrap component.
*
Expand Down Expand Up @@ -53,6 +64,20 @@ export function connectFocusTrap(component: FocusTrapComponent, options?: Connec

const outsideClickDeactivated = new WeakSet<HTMLElement | SVGElement>();

/**
* 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.
*
Expand All @@ -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
Expand All @@ -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;
},
};
}

Expand Down
Loading