diff --git a/packages/calcite-components/src/components/dialog/dialog.tsx b/packages/calcite-components/src/components/dialog/dialog.tsx index eea354ce3e1..f5439d08d51 100644 --- a/packages/calcite-components/src/components/dialog/dialog.tsx +++ b/packages/calcite-components/src/components/dialog/dialog.tsx @@ -20,7 +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 { ExtendedFocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; import T9nStrings from "./assets/t9n/messages.en.json"; import { CSS, @@ -70,7 +70,7 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo private dragPosition: DialogDragPosition = { ...initialDragPosition }; - focusTrap = useFocusTrap({ + focusTrap = useFocusTrap({ triggerProp: "open", focusTrapOptions: { // scrim closes on click, so we let it take over @@ -161,6 +161,16 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo */ @property({ reflect: true }) escapeDisabled = false; + /** + * 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). + */ + @property() focusTrapOptions; + /** The component header text. */ @property() heading: string; @@ -272,10 +282,16 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo return this.panelEl.value?.setFocus() ?? focusFirstTabbable(this.el); } - /** Updates the element(s) that are used within the focus-trap of the component. */ + /** + * Updates the element(s) that are included in the focus-trap of the component. + * + * @param extraContainers - Additional elements to include in the focus trap. This is useful for including elements that may have related parts rendered outside the main focus trapping element. + */ @method() - async updateFocusTrapElements(): Promise { - this.focusTrap.updateContainerElements(); + async updateFocusTrapElements( + extraContainers?: ExtendedFocusTrapOptions["extraContainers"], + ): Promise { + this.focusTrap.updateContainerElements(extraContainers); } /** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */ diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx index 6bc77864710..547c98960df 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.tsx @@ -1079,7 +1079,7 @@ export class InputTimePicker ({ + triggerProp: "open", + focusTrapOptions: { + // scrim closes on click, so we let it take over + clickOutsideDeactivates: false, + escapeDeactivates: (event) => { + if (!event.defaultPrevented && !this.escapeDisabled) { + this.open = false; + event.preventDefault(); + } + + return false; + }, + }, + })(this); private ignoreOpenChange = false; @@ -152,6 +156,16 @@ export class Modal /** When `true`, prevents focus trapping. */ @property({ reflect: true }) focusTrapDisabled = false; + /** + * 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). + */ + @property() focusTrapOptions; + /** Sets the component to always be fullscreen. Overrides `widthScale` and `--calcite-modal-width` / `--calcite-modal-height`. */ @property({ reflect: true }) fullscreen: boolean; @@ -230,10 +244,16 @@ export class Modal focusFirstTabbable(this.el); } - /** Updates the element(s) that are used within the focus-trap of the component. */ + /** + * Updates the element(s) that are included in the focus-trap of the component. + * + * @param extraContainers - Additional elements to include in the focus trap. This is useful for including elements that may have related parts rendered outside the main focus trapping element. + */ @method() - async updateFocusTrapElements(): Promise { - updateFocusTrapElements(this); + async updateFocusTrapElements( + extraContainers?: ExtendedFocusTrapOptions["extraContainers"], + ): Promise { + this.focusTrap.updateContainerElements(extraContainers); } // #endregion @@ -265,20 +285,6 @@ export class Modal this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); this.cssVarObserver?.observe(this.el, { attributeFilter: ["style"] }); this.updateSizeCssVars(); - connectFocusTrap(this, { - focusTrapOptions: { - // scrim closes on click, so we let it take over - clickOutsideDeactivates: false, - escapeDeactivates: (event) => { - if (!event.defaultPrevented && !this.escapeDisabled) { - this.open = false; - event.preventDefault(); - } - - return false; - }, - }, - }); } async load(): Promise { @@ -299,10 +305,6 @@ export class Modal To account for this semantics change, the checks for (this.hasUpdated || value != defaultValue) was added in this method Please refactor your code to reduce the need for this check. Docs: https://qawebgis.esri.com/arcgis-components/?path=/docs/lumina-transition-from-stencil--docs#watching-for-property-changes */ - if (changes.has("focusTrapDisabled") && (this.hasUpdated || this.focusTrapDisabled !== false)) { - this.handleFocusTrapDisabled(this.focusTrapDisabled); - } - if ( (changes.has("hasBack") && (this.hasUpdated || this.hasBack !== false)) || (changes.has("hasPrimary") && (this.hasUpdated || this.hasPrimary !== false)) || @@ -324,7 +326,6 @@ export class Modal this.removeOverflowHiddenClass(); this.mutationObserver?.disconnect(); this.cssVarObserver?.disconnect(); - deactivateFocusTrap(this); } // #endregion @@ -346,18 +347,6 @@ export class Modal } }; - private handleFocusTrapDisabled(focusTrapDisabled: boolean): void { - if (!this.open) { - return; - } - - if (focusTrapDisabled) { - deactivateFocusTrap(this); - } else { - activateFocusTrap(this); - } - } - private handleHeaderSlotChange(event: Event): void { this.titleEl = slotChangeGetAssignedElements(event)[0]; } @@ -390,7 +379,7 @@ export class Modal onOpen(): void { this.transitionEl?.classList.remove(CSS.openingIdle, CSS.openingActive); this.calciteModalOpen.emit(); - activateFocusTrap(this); + this.focusTrap.activate(); } onBeforeClose(): void { @@ -401,7 +390,7 @@ export class Modal onClose(): void { this.transitionEl?.classList.remove(CSS.closingIdle, CSS.closingActive); this.calciteModalClose.emit(); - deactivateFocusTrap(this); + this.focusTrap.deactivate(); } private toggleModal(value: boolean): void { diff --git a/packages/calcite-components/src/components/popover/popover.tsx b/packages/calcite-components/src/components/popover/popover.tsx index f15172ecaf0..65b0ff638b2 100644 --- a/packages/calcite-components/src/components/popover/popover.tsx +++ b/packages/calcite-components/src/components/popover/popover.tsx @@ -11,7 +11,6 @@ import { JsxNode, setAttribute, } from "@arcgis/lumina"; -import { FocusTargetOrFalse } from "focus-trap"; import { connectFloatingUI, defaultOffsetDistance, @@ -27,14 +26,6 @@ import { ReferenceElement, reposition, } from "../../utils/floating-ui"; -import { - activateFocusTrap, - connectFocusTrap, - deactivateFocusTrap, - FocusTrap, - FocusTrapComponent, - updateFocusTrapElements, -} from "../../utils/focusTrapComponent"; import { focusFirstTabbable, queryElementRoots, toAriaBoolean } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { onToggleOpenCloseComponent, OpenCloseComponent } from "../../utils/openCloseComponent"; @@ -51,6 +42,7 @@ import { FloatingArrow } from "../functional/FloatingArrow"; import { getIconScale } from "../../utils/component"; import { useT9n } from "../../controllers/useT9n"; import type { Action } from "../action/action"; +import { useFocusTrap } from "../../controllers/useFocusTrap"; import PopoverManager from "./PopoverManager"; import T9nStrings from "./assets/t9n/messages.en.json"; import { ARIA_CONTROLS, ARIA_EXPANDED, CSS, defaultPopoverPlacement } from "./resources"; @@ -67,7 +59,7 @@ const manager = new PopoverManager(); /** @slot - A slot for adding custom content. */ export class Popover extends LitElement - implements FloatingUIComponent, OpenCloseComponent, FocusTrapComponent, LoadableComponent + implements FloatingUIComponent, OpenCloseComponent, LoadableComponent { // #region Static Members @@ -85,7 +77,20 @@ export class Popover floatingEl: HTMLDivElement; - focusTrap: FocusTrap; + focusTrap = useFocusTrap({ + triggerProp: "open", + focusTrapOptions: { + allowOutsideClick: true, + escapeDeactivates: (event) => { + if (!event.defaultPrevented) { + this.open = false; + event.preventDefault(); + } + + return false; + }, + }, + })(this); private guid = `calcite-popover-${guid()}`; @@ -126,18 +131,22 @@ export class Popover /** When `true`, prevents focus trapping. */ @property({ reflect: true }) focusTrapDisabled = false; + /** + * 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). + */ + @property() focusTrapOptions; + /** The component header text. */ @property() heading: string; /** Specifies the heading level of the component's `heading` for proper document structure, without affecting visual styling. */ @property({ type: Number, reflect: true }) headingLevel: HeadingLevel; - /** - * Specifies whether focus should move to the popover when the focus trap is activated. - * `@internal` - */ - @property({ type: Boolean }) initialFocusTrapFocus: FocusTargetOrFalse; - /** * Accessible name for the component. * @@ -251,7 +260,7 @@ export class Popover /** Updates the element(s) that are used within the focus-trap of the component. */ @method() async updateFocusTrapElements(): Promise { - updateFocusTrapElements(this); + this.focusTrap.updateContainerElements(); } // #endregion @@ -277,21 +286,6 @@ export class Popover override connectedCallback(): void { this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); this.setFilteredPlacements(); - connectFocusTrap(this, { - focusTrapEl: this.el, - focusTrapOptions: { - allowOutsideClick: true, - escapeDeactivates: (event) => { - if (!event.defaultPrevented) { - this.open = false; - event.preventDefault(); - } - - return false; - }, - initialFocus: this.initialFocusTrapFocus, - }, - }); // we set up the ref element in the next frame to ensure PopoverManager // event handlers are invoked after connect (mainly for `components` output target) @@ -307,10 +301,6 @@ export class Popover To account for this semantics change, the checks for (this.hasUpdated || value != defaultValue) was added in this method Please refactor your code to reduce the need for this check. Docs: https://qawebgis.esri.com/arcgis-components/?path=/docs/lumina-transition-from-stencil--docs#watching-for-property-changes */ - if (changes.has("focusTrapDisabled") && (this.hasUpdated || this.focusTrapDisabled !== false)) { - this.handleFocusTrapDisabled(this.focusTrapDisabled); - } - if (changes.has("flipPlacements")) { this.flipPlacementsHandler(); } @@ -348,25 +338,12 @@ export class Popover this.mutationObserver?.disconnect(); this.removeReferences(); disconnectFloatingUI(this); - deactivateFocusTrap(this); } // #endregion // #region Private Methods - private handleFocusTrapDisabled(focusTrapDisabled: boolean): void { - if (!this.open) { - return; - } - - if (focusTrapDisabled) { - deactivateFocusTrap(this); - } else { - activateFocusTrap(this); - } - } - private flipPlacementsHandler(): void { this.setFilteredPlacements(); this.reposition(true); @@ -486,7 +463,7 @@ export class Popover onOpen(): void { this.calcitePopoverOpen.emit(); - activateFocusTrap(this); + this.focusTrap.activate(); } onBeforeClose(): void { @@ -496,7 +473,7 @@ export class Popover onClose(): void { this.calcitePopoverClose.emit(); hideFloatingUI(this); - deactivateFocusTrap(this); + this.focusTrap.deactivate(); } private storeArrowEl(el: SVGSVGElement): void { diff --git a/packages/calcite-components/src/components/sheet/sheet.tsx b/packages/calcite-components/src/components/sheet/sheet.tsx index 484cf125517..374393abe2a 100644 --- a/packages/calcite-components/src/components/sheet/sheet.tsx +++ b/packages/calcite-components/src/components/sheet/sheet.tsx @@ -13,14 +13,6 @@ import { setAttribute, } from "@arcgis/lumina"; import { ensureId, focusFirstTabbable, getElementDir, isPixelValue } from "../../utils/dom"; -import { - activateFocusTrap, - connectFocusTrap, - deactivateFocusTrap, - FocusTrap, - FocusTrapComponent, - updateFocusTrapElements, -} from "../../utils/focusTrapComponent"; import { componentFocusable, LoadableComponent, @@ -34,6 +26,7 @@ import { Height, LogicalFlowPosition, Scale, Width } from "../interfaces"; import { CSS_UTILITY } from "../../utils/resources"; import { clamp } from "../../utils/math"; import { useT9n } from "../../controllers/useT9n"; +import { ExtendedFocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap"; import { CSS, sheetResizeStep, sheetResizeShiftStep } from "./resources"; import { DisplayMode, ResizeValues } from "./interfaces"; import T9nStrings from "./assets/t9n/messages.en.json"; @@ -44,11 +37,9 @@ declare global { "calcite-sheet": Sheet; } } + /** @slot - A slot for adding custom content. */ -export class Sheet - extends LitElement - implements OpenCloseComponent, FocusTrapComponent, LoadableComponent -{ +export class Sheet extends LitElement implements OpenCloseComponent, LoadableComponent { // #region Static Members static override styles = styles; @@ -61,7 +52,21 @@ export class Sheet private contentId: string; - focusTrap: FocusTrap; + focusTrap = useFocusTrap({ + triggerProp: "open", + focusTrapOptions: { + // scrim closes on click, so we let it take over + clickOutsideDeactivates: false, + escapeDeactivates: (event) => { + if (!event.defaultPrevented && !this.escapeDisabled) { + this.open = false; + event.preventDefault(); + } + + return false; + }, + }, + })(this); private ignoreOpenChange = false; @@ -137,6 +142,16 @@ export class Sheet /** When `true`, prevents focus trapping. */ @property({ reflect: true }) focusTrapDisabled = false; + /** + * 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). + */ + @property() focusTrapOptions; + /** * When `position` is `"block-start"` or `"block-end"`, specifies the height of the component. * @@ -210,10 +225,16 @@ export class Sheet focusFirstTabbable(this.el); } - /** Updates the element(s) that are used within the focus-trap of the component. */ + /** + * Updates the element(s) that are included in the focus-trap of the component. + * + * @param extraContainers - Additional elements to include in the focus trap. This is useful for including elements that may have related parts rendered outside the main focus trapping element. + */ @method() - async updateFocusTrapElements(): Promise { - updateFocusTrapElements(this); + async updateFocusTrapElements( + extraContainers?: ExtendedFocusTrapOptions["extraContainers"], + ): Promise { + this.focusTrap.updateContainerElements(extraContainers); } // #endregion @@ -243,20 +264,6 @@ export class Sheet 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: false, - escapeDeactivates: (event) => { - if (!event.defaultPrevented && !this.escapeDisabled) { - this.open = false; - event.preventDefault(); - } - - return false; - }, - }, - }); this.setupInteractions(); } @@ -273,10 +280,6 @@ export class Sheet To account for this semantics change, the checks for (this.hasUpdated || value != defaultValue) was added in this method Please refactor your code to reduce the need for this check. Docs: https://qawebgis.esri.com/arcgis-components/?path=/docs/lumina-transition-from-stencil--docs#watching-for-property-changes */ - if (changes.has("focusTrapDisabled") && (this.hasUpdated || this.focusTrapDisabled !== false)) { - this.handleFocusTrapDisabled(this.focusTrapDisabled); - } - if (changes.has("opened") && (this.hasUpdated || this.opened !== false)) { onToggleOpenCloseComponent(this); } @@ -297,7 +300,6 @@ export class Sheet override disconnectedCallback(): void { this.removeOverflowHiddenClass(); this.mutationObserver?.disconnect(); - deactivateFocusTrap(this); this.embedded = false; this.cleanupInteractions(); } @@ -321,18 +323,6 @@ export class Sheet } }; - private handleFocusTrapDisabled(focusTrapDisabled: boolean): void { - if (!this.open) { - return; - } - - if (focusTrapDisabled) { - deactivateFocusTrap(this); - } else { - activateFocusTrap(this); - } - } - private toggleSheet(value: boolean): void { if (this.ignoreOpenChange) { return; @@ -528,7 +518,7 @@ export class Sheet onOpen(): void { this.calciteSheetOpen.emit(); - activateFocusTrap(this); + this.focusTrap.activate(); } onBeforeClose(): void { @@ -537,7 +527,7 @@ export class Sheet onClose(): void { this.calciteSheetClose.emit(); - deactivateFocusTrap(this); + this.focusTrap.deactivate(); } private setResizeHandleEl(el: HTMLDivElement): void { diff --git a/packages/calcite-components/src/controllers/useFocusTrap.ts b/packages/calcite-components/src/controllers/useFocusTrap.ts index 90d5398ae3f..2b54d9e8f5b 100644 --- a/packages/calcite-components/src/controllers/useFocusTrap.ts +++ b/packages/calcite-components/src/controllers/useFocusTrap.ts @@ -28,7 +28,7 @@ export interface UseFocusTrap { * * @see https://github.com/focus-trap/focus-trap#trapupdatecontainerelements */ - updateContainerElements: () => void; + updateContainerElements: (extraContainers?: ExtendedFocusTrapOptions["extraContainers"]) => void; } interface UseFocusTrapOptions { @@ -44,7 +44,7 @@ interface UseFocusTrapOptions { } interface FocusTrapComponent extends LitElement { - /** + /* * When `true` prevents focus trapping. */ focusTrapDisabled?: boolean; @@ -53,6 +53,38 @@ interface FocusTrapComponent extends LitElement { * When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */ focusTrapDisabledOverride?: () => boolean; + + /** + * Additional options to configure the focus trap. + */ + focusTrapOptions?: ExtendedFocusTrapOptions; +} + +export type ExtendedFocusTrapOptions = + /** + * @see https://github.com/focus-trap/focus-trap#createoptions + */ + Pick & { + /** + * 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]; + }; + +function getEffectiveContainerElements( + targetEl: HTMLElement, + { focusTrapOptions }: FocusTrapComponent, + extraContainers?: ExtendedFocusTrapOptions["extraContainers"], +) { + if (!focusTrapOptions?.extraContainers && !extraContainers) { + return targetEl; + } + + return [targetEl, ...toContainerArray(focusTrapOptions?.extraContainers), ...toContainerArray(extraContainers)]; +} + +function toContainerArray(containers: ExtendedFocusTrapOptions["extraContainers"] = []) { + return Array.isArray(containers) ? containers : [containers]; } /** @@ -68,13 +100,14 @@ export const useFocusTrap = ( return makeGenericController((component, controller) => { let focusTrap: FocusTrap; let focusTrapEl: HTMLElement; - const { focusTrapOptions } = options; + const internalFocusTrapOptions = options.focusTrapOptions; controller.onConnected(() => { if (component[options.triggerProp] && focusTrap) { focusTrap.activate(); } }); + controller.onDisconnected(() => focusTrap?.deactivate()); return { @@ -86,7 +119,15 @@ export const useFocusTrap = ( } if (!focusTrap) { - focusTrap = createFocusTrap(targetEl, createFocusTrapOptions(targetEl, focusTrapOptions)); + const effectiveFocusTrapOptions = { + ...internalFocusTrapOptions, + ...component.focusTrapOptions, + }; + + focusTrap = createFocusTrap( + getEffectiveContainerElements(targetEl, component), + createFocusTrapOptions(targetEl, effectiveFocusTrapOptions), + ); } if ( @@ -105,9 +146,9 @@ export const useFocusTrap = ( focusTrapEl = el; }, - updateContainerElements: () => { + updateContainerElements: (extraContainers?: ExtendedFocusTrapOptions["extraContainers"]) => { const targetEl = focusTrapEl || component.el; - return focusTrap?.updateContainerElements(targetEl); + return focusTrap?.updateContainerElements(getEffectiveContainerElements(targetEl, component, extraContainers)); }, }; });