diff --git a/packages/calcite-components/src/utils/config.spec.ts b/packages/calcite-components/src/utils/config.spec.ts new file mode 100644 index 00000000000..662dc031954 --- /dev/null +++ b/packages/calcite-components/src/utils/config.spec.ts @@ -0,0 +1,31 @@ +import type { CalciteConfig } from "./config"; + +describe("config", () => { + let config: CalciteConfig; + + /** + * Need to load the config at runtime to allow test to specify custom configuration if needed. + */ + async function loadConfig(): Promise { + return import("./config"); + } + + beforeEach(() => jest.resetModules()); + + it("has defaults", async () => { + config = await loadConfig(); + expect(config.focusTrapStack).toHaveLength(0); + }); + + it("allows custom configuration", async () => { + const customFocusTrapStack = []; + + globalThis.calciteConfig = { + focusTrapStack: customFocusTrapStack, + }; + + config = await loadConfig(); + + expect(config.focusTrapStack).toBe(customFocusTrapStack); + }); +}); diff --git a/packages/calcite-components/src/utils/config.ts b/packages/calcite-components/src/utils/config.ts index 0af0bd0de8c..0948e861e53 100644 --- a/packages/calcite-components/src/utils/config.ts +++ b/packages/calcite-components/src/utils/config.ts @@ -1,13 +1,20 @@ /** - * This module helps users provide custom configuration for component internals. - * - * @internal + * This module allows custom configuration for components. */ -const configOverrides = globalThis["calciteComponentsConfig"]; +import { FocusTrap } from "./focusTrapComponent"; -const config = { - ...configOverrides, -}; +export interface CalciteConfig { + /** + * Defines the global trap stack to use for focus-trapping components. + * + * This is useful if your application uses its own instance of `focus-trap` and both need to be aware of each other. + * + * @see https://github.com/focus-trap/focus-trap#createoptions + */ + focusTrapStack: FocusTrap[]; +} -export { config }; +const customConfig: CalciteConfig = globalThis["calciteConfig"]; + +export const focusTrapStack: FocusTrap[] = customConfig?.focusTrapStack || []; diff --git a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts index ea5bf29bd4a..92ac8af2cd2 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.spec.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.spec.ts @@ -2,12 +2,17 @@ import { activateFocusTrap, connectFocusTrap, deactivateFocusTrap, + FocusTrapComponent, updateFocusTrapElements, } from "./focusTrapComponent"; +import { JSDOM } from "jsdom"; +import { CalciteConfig } from "./config"; +import { GlobalTestProps } from "../tests/utils"; + describe("focusTrapComponent", () => { it("focusTrapComponent lifecycle", () => { - const fakeComponent = {} as any; + const fakeComponent = {} as FocusTrapComponent; fakeComponent.el = document.createElement("div"); connectFocusTrap(fakeComponent); @@ -35,7 +40,7 @@ describe("focusTrapComponent", () => { }); it("supports passing options", () => { - const fakeComponent = {} as any; + const fakeComponent = {} as FocusTrapComponent; fakeComponent.el = document.createElement("div"); connectFocusTrap(fakeComponent); @@ -54,4 +59,48 @@ describe("focusTrapComponent", () => { deactivateFocusTrap(fakeComponent, fakeDeactivateOptions); expect(deactivateSpy).toHaveBeenCalledWith(fakeDeactivateOptions); }); + + describe("configuration", () => { + beforeEach(() => jest.resetModules()); + + it("supports custom global trap stack", async () => { + const customFocusTrapStack = []; + + // we clobber Stencil's custom Mock document implementation + const { window: win } = new JSDOM(); + window = win; // make window references use JSDOM + globalThis.MutationObserver = window.MutationObserver; // needed for focus-trap + + type TestGlobal = GlobalTestProps<{ calciteConfig: CalciteConfig }>; + + (globalThis as TestGlobal).calciteConfig = { + focusTrapStack: customFocusTrapStack, + }; + + const focusTrap = await import("focus-trap"); + const createFocusTrapSpy = jest.spyOn(focusTrap, "createFocusTrap"); + + const focusTrapComponent = await import("./focusTrapComponent"); + const fakeComponent = {} as FocusTrapComponent; + fakeComponent.el = win.document.createElement("div"); + + focusTrapComponent.connectFocusTrap(fakeComponent); + expect(createFocusTrapSpy).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + trapStack: customFocusTrapStack, + }) + ); + expect(customFocusTrapStack).toHaveLength(0); + + focusTrapComponent.activateFocusTrap(fakeComponent); + expect(customFocusTrapStack).toHaveLength(1); + + focusTrapComponent.deactivateFocusTrap(fakeComponent); + expect(customFocusTrapStack).toHaveLength(0); + + focusTrapComponent.activateFocusTrap(fakeComponent); + expect(customFocusTrapStack).toHaveLength(1); + }); + }); }); diff --git a/packages/calcite-components/src/utils/focusTrapComponent.ts b/packages/calcite-components/src/utils/focusTrapComponent.ts index 6378d749f06..6ba1e4b5f74 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.ts @@ -1,7 +1,6 @@ import { createFocusTrap, FocusTrap as _FocusTrap, Options as FocusTrapOptions } from "focus-trap"; import { FocusableElement, focusElement, tabbableOptions } from "./dom"; - -const trapStack: _FocusTrap[] = []; +import { focusTrapStack } from "./config"; /** * Defines interface for components with a focus trap. Focusable content is required for components implementing focus trapping with this interface. @@ -71,7 +70,7 @@ export function connectFocusTrap(component: FocusTrapComponent, options?: Connec // the following options are not overrideable document: el.ownerDocument, tabbableOptions, - trapStack, + trapStack: focusTrapStack, }; component.focusTrap = createFocusTrap(focusTrapNode, focusTrapOptions);