diff --git a/packages/calcite-components/src/components/dialog/dialog.tsx b/packages/calcite-components/src/components/dialog/dialog.tsx index 1aad7525706..1ef1f3fbdde 100644 --- a/packages/calcite-components/src/components/dialog/dialog.tsx +++ b/packages/calcite-components/src/components/dialog/dialog.tsx @@ -5,14 +5,6 @@ import { PropertyValues } from "lit"; import { createRef } from "lit-html/directives/ref.js"; import { createEvent, h, JsxNode, LitElement, method, property, state } from "@arcgis/lumina"; import { focusFirstTabbable, isPixelValue } from "../../utils/dom"; -import { - activateFocusTrap, - connectFocusTrap, - deactivateFocusTrap, - FocusTrap, - FocusTrapComponent, - updateFocusTrapElements, -} from "../../utils/focusTrapComponent"; import { componentFocusable, LoadableComponent, @@ -28,6 +20,7 @@ import { HeadingLevel } from "../functional/Heading"; import type { OverlayPositioning } from "../../utils/floating-ui"; import { useT9n } from "../../controllers/useT9n"; import type { Panel } from "../panel/panel"; +import { useFocusTrap } from "../../controllers/useFocusTrap"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, @@ -66,10 +59,7 @@ let initialDocumentOverflowStyle: string = ""; * @slot footer-end - A slot for adding a trailing footer custom content. Should not be used with the `"footer"` slot. * @slot footer-start - A slot for adding a leading footer custom content. Should not be used with the `"footer"` slot. */ -export class Dialog - extends LitElement - implements OpenCloseComponent, FocusTrapComponent, LoadableComponent -{ +export class Dialog extends LitElement implements OpenCloseComponent, LoadableComponent { // #region Static Members static override styles = styles; @@ -80,7 +70,21 @@ export class Dialog private dragPosition: DialogDragPosition = { ...initialDragPosition }; - focusTrap: FocusTrap; + focusTrap = useFocusTrap({ + triggerProp: "open", + focusTrapOptions: { + // scrim closes on click, so we let it take over + clickOutsideDeactivates: () => !this.modal, + escapeDeactivates: (event) => { + if (!event.defaultPrevented && !this.escapeDisabled) { + this.open = false; + event.preventDefault(); + } + + return false; + }, + }, + })(this); private ignoreOpenChange = false; @@ -268,7 +272,7 @@ export class Dialog /** Updates the element(s) that are used within the focus-trap of the component. */ @method() async updateFocusTrapElements(): Promise { - updateFocusTrapElements(this); + this.focusTrap.updateContainerElements(); } // #endregion @@ -296,29 +300,11 @@ export class Dialog override connectedCallback(): void { this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); - connectFocusTrap(this, { - focusTrapOptions: { - // scrim closes on click, so we let it take over - clickOutsideDeactivates: () => !this.modal, - escapeDeactivates: (event) => { - if (!event.defaultPrevented && !this.escapeDisabled) { - this.open = false; - event.preventDefault(); - } - - return false; - }, - }, - }); this.setupInteractions(); } async load(): Promise { setUpLoadableComponent(this); - // when dialog initially renders, if active was set we need to open as watcher doesn't fire - if (this.open) { - this.openDialog(); - } } override willUpdate(changes: PropertyValues): void { @@ -359,7 +345,6 @@ export class Dialog override disconnectedCallback(): void { this.removeOverflowHiddenClass(); this.mutationObserver?.disconnect(); - deactivateFocusTrap(this); this.embedded = false; this.cleanupInteractions(); } @@ -381,7 +366,7 @@ export class Dialog onOpen(): void { this.calciteDialogOpen.emit(); - activateFocusTrap(this); + this.focusTrap.activate(); } onBeforeClose(): void { @@ -390,7 +375,7 @@ export class Dialog onClose(): void { this.calciteDialogClose.emit(); - deactivateFocusTrap(this); + this.focusTrap.deactivate(); } private toggleDialog(value: boolean): void { diff --git a/packages/calcite-components/src/controllers/useFocusTrap.ts b/packages/calcite-components/src/controllers/useFocusTrap.ts new file mode 100644 index 00000000000..ffcd2b95d37 --- /dev/null +++ b/packages/calcite-components/src/controllers/useFocusTrap.ts @@ -0,0 +1,96 @@ +import { makeGenericController } from "@arcgis/components-controllers"; +import { createFocusTrap, FocusTrap, Options as FocusTrapOptions } from "focus-trap"; +import { LitElement } from "@arcgis/lumina"; +import { createFocusTrapOptions } from "../utils/focusTrapComponent"; + +export interface UseFocusTrap { + /** + * Activates the focus trap. + * + * @see https://github.com/focus-trap/focus-trap#trapactivate + */ + activate: (options?: Parameters[0]) => void; + + /** + * Deactivates the focus trap. + * + * @see https://github.com/focus-trap/focus-trap#trapdeactivate + */ + deactivate: (options?: Parameters[0]) => void; + + /** + * By default, the host element will be used as the focus-trap element, but if the focus-trap element needs to be a different element, use this method prior to activating to set the focus-trap element. + */ + overrideFocusTrapEl: (el: HTMLElement) => void; + + /** + * Updates focusable elements within the trap. + * + * @see https://github.com/focus-trap/focus-trap#trapupdatecontainerelements + */ + updateContainerElements: () => void; +} + +interface UseFocusTrapOptions { + /** + * The name of the prop that will trigger the focus trap to activate. + */ + triggerProp: keyof T; + + /** + * Options to pass to the focus-trap library. + */ + focusTrapOptions?: FocusTrapOptions; +} + +/** + * A controller for managing focus traps. + * + * Note: traps will be deactivated automatically when the component is disconnected. + * + * @param options + */ +export const useFocusTrap = ( + options: UseFocusTrapOptions, +): ReturnType> => { + return makeGenericController((component, controller) => { + let focusTrap: FocusTrap; + let focusTrapEl: HTMLElement; + const { focusTrapOptions } = options; + + controller.onConnected(() => { + if (component[options.triggerProp] && focusTrap) { + focusTrap.activate(); + } + }); + controller.onDisconnected(() => focusTrap?.deactivate()); + + return { + activate: (options?: Parameters[0]) => { + const targetEl = focusTrapEl || component.el; + + if (!targetEl.isConnected) { + return; + } + + if (!focusTrap) { + focusTrap = createFocusTrap(targetEl, createFocusTrapOptions(targetEl, focusTrapOptions)); + } + + focusTrap.activate(options); + }, + deactivate: (options?: Parameters[0]) => focusTrap?.deactivate(options), + overrideFocusTrapEl: (el: HTMLElement) => { + if (focusTrap) { + throw new Error("Focus trap already created"); + } + + focusTrapEl = el; + }, + updateContainerElements: () => { + const targetEl = focusTrapEl || component.el; + return focusTrap?.updateContainerElements(targetEl); + }, + }; + }); +}; diff --git a/packages/calcite-components/src/utils/focusTrapComponent.ts b/packages/calcite-components/src/utils/focusTrapComponent.ts index 8c5c560526e..c9ddf03c20e 100644 --- a/packages/calcite-components/src/utils/focusTrapComponent.ts +++ b/packages/calcite-components/src/utils/focusTrapComponent.ts @@ -45,22 +45,32 @@ export function connectFocusTrap(component: FocusTrapComponent, options?: Connec return; } - const focusTrapOptions: FocusTrapOptions = { + component.focusTrap = createFocusTrap(focusTrapNode, createFocusTrapOptions(el, options?.focusTrapOptions)); +} + +/** + * Helper to create the FocusTrap options. + * + * @param hostEl + * @param options + */ +export function createFocusTrapOptions(hostEl: HTMLElement, options?: FocusTrapOptions): FocusTrapOptions { + const focusTrapNode = options?.fallbackFocus || hostEl; + + return { clickOutsideDeactivates: true, fallbackFocus: focusTrapNode, setReturnFocus: (el) => { focusElement(el as FocusableElement); return false; }, - ...options?.focusTrapOptions, + ...options, // the following options are not overridable - document: el.ownerDocument, + document: hostEl.ownerDocument, tabbableOptions, trapStack: focusTrapStack, }; - - component.focusTrap = createFocusTrap(focusTrapNode, focusTrapOptions); } /**