Skip to content

Commit

Permalink
feat(dialog, modal, popover, sheet): add options prop to customize fo…
Browse files Browse the repository at this point in the history
…cus-trap (#11453)

**Related Issue:** #11345 

## Summary

Adds `focusTrapOptions` option to allow additional customization of
focus-trapping components. It supports the following options from
https://github.com/focus-trap/focus-trap#createoptions:

* `initialFocus` – would replace popover's internal
initialFocusTrapFocus prop
* `allowOutsideClick` – supports
#10682
* `returnFocusOnDeactivate` – supports
#10682

And the following custom prop:

* `extraContainers` – allows specifying extra elements (nodes or
selectors) to focus trap (e.g., anything appending to the body, etc)
when creating/activating the trap. **Note**: if specified, elements must
exist when the focus trap is activated, if extra containers are created
afterwards, users can use `updateFocusTrapElements(extraContainers)`

This also enhances `updatesFocusTrapElements()` to accept extra
containers to allow in the focus trap if these are created after the
trap is activated.

### Notes

* A subset of https://github.com/focus-trap/focus-trap#createoptions
options are exposed as certain configurations might break component
functionality.
* `extraContainers` gets used both when creating and updating the focus
trap target containers
* Tidies up types
  • Loading branch information
jcfranco authored Feb 6, 2025
1 parent 81e3a52 commit 454f8e8
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 162 deletions.
26 changes: 21 additions & 5 deletions packages/calcite-components/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -70,7 +70,7 @@ export class Dialog extends LitElement implements OpenCloseComponent, LoadableCo

private dragPosition: DialogDragPosition = { ...initialDragPosition };

focusTrap = useFocusTrap<Dialog>({
focusTrap = useFocusTrap<this>({
triggerProp: "open",
focusTrapOptions: {
// scrim closes on click, so we let it take over
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<void> {
this.focusTrap.updateContainerElements();
async updateFocusTrapElements(
extraContainers?: ExtendedFocusTrapOptions["extraContainers"],
): Promise<void> {
this.focusTrap.updateContainerElements(extraContainers);
}

/** When defined, provides a condition to disable focus trapping. When `true`, prevents focus trapping. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,7 @@ export class InputTimePicker
<calcite-popover
autoClose={true}
focusTrapDisabled={this.focusTrapDisabled}
initialFocusTrapFocus={false}
focusTrapOptions={{ initialFocus: false }}
label={messages.chooseTime}
lang={this.messages._lang}
oncalcitePopoverBeforeClose={this.popoverBeforeCloseHandler}
Expand Down
87 changes: 38 additions & 49 deletions packages/calcite-components/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,6 @@ import {
slotChangeGetAssignedElements,
slotChangeHasAssignedElement,
} from "../../utils/dom";
import {
activateFocusTrap,
connectFocusTrap,
deactivateFocusTrap,
FocusTrap,
FocusTrapComponent,
updateFocusTrapElements,
} from "../../utils/focusTrapComponent";
import {
componentFocusable,
LoadableComponent,
Expand All @@ -38,6 +30,7 @@ import { Kind, Scale } from "../interfaces";
import { getIconScale } from "../../utils/component";
import { logger } from "../../utils/logger";
import { useT9n } from "../../controllers/useT9n";
import { ExtendedFocusTrapOptions, useFocusTrap } from "../../controllers/useFocusTrap";
import T9nStrings from "./assets/t9n/messages.en.json";
import { CSS, ICONS, SLOTS } from "./resources";
import { styles } from "./modal.scss";
Expand All @@ -61,10 +54,7 @@ let initialDocumentOverflowStyle: string = "";
* @slot secondary - A slot for adding a secondary button.
* @slot back - A slot for adding a back button.
*/
export class Modal
extends LitElement
implements OpenCloseComponent, FocusTrapComponent, LoadableComponent
{
export class Modal extends LitElement implements OpenCloseComponent, LoadableComponent {
// #region Static Members

static override styles = styles;
Expand All @@ -81,7 +71,21 @@ export class Modal
this.updateSizeCssVars();
});

focusTrap: FocusTrap;
focusTrap = useFocusTrap<this>({
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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<void> {
updateFocusTrapElements(this);
async updateFocusTrapElements(
extraContainers?: ExtendedFocusTrapOptions["extraContainers"],
): Promise<void> {
this.focusTrap.updateContainerElements(extraContainers);
}

// #endregion
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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)) ||
Expand All @@ -324,7 +326,6 @@ export class Modal
this.removeOverflowHiddenClass();
this.mutationObserver?.disconnect();
this.cssVarObserver?.disconnect();
deactivateFocusTrap(this);
}

// #endregion
Expand All @@ -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<HTMLElement>(event)[0];
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 454f8e8

Please sign in to comment.