From 6d82ab7b279c735bf012a02614540f4f966dc031 Mon Sep 17 00:00:00 2001 From: dlescarbeau Date: Wed, 28 Jan 2026 12:19:48 -0500 Subject: [PATCH 1/2] (#2105) Updates modal component events - removes events for overlap and escape key close - custom close event now passes the close action to the event details Closes #2105 (#2105) Updates modal component events - removes events for overlap and escape key close - custom close event now passes the close action to the event details Closes #2105 --- .../usa-modal/__tests__/modal.events.test.ts | 4 +- .../modal.close.esc.event-details.ts | 8 -- .../modal.close.event-details.ts | 10 ++- .../modal.close.outside.event-details.ts | 8 -- .../event-details/modal.event-details.ts | 1 + .../event-details/modal.open.event-details.ts | 4 +- .../src/components/usa-modal/index.ts | 4 - .../components/usa-modal/modal.component.ts | 85 ++++++++++--------- .../usa-modal/usa-modal.default.stories.jsx | 4 +- 9 files changed, 61 insertions(+), 67 deletions(-) delete mode 100644 packages/ncids-js/src/components/usa-modal/event-details/modal.close.esc.event-details.ts delete mode 100644 packages/ncids-js/src/components/usa-modal/event-details/modal.close.outside.event-details.ts diff --git a/packages/ncids-js/src/components/usa-modal/__tests__/modal.events.test.ts b/packages/ncids-js/src/components/usa-modal/__tests__/modal.events.test.ts index c5341a18c..6fa2a1dcf 100644 --- a/packages/ncids-js/src/components/usa-modal/__tests__/modal.events.test.ts +++ b/packages/ncids-js/src/components/usa-modal/__tests__/modal.events.test.ts @@ -242,12 +242,12 @@ describe('USA Modal - Events', () => { const overlay = document.querySelectorAll('.usa-modal-overlay'); await user.click(overlay[0]); - await expect(ModalCloseOverlayEvent).toHaveBeenCalledTimes(1); + await expect(modalClosedEvent).toHaveBeenCalledTimes(2); await user.click(openers[0]); await expect(modalOpenedEvent).toHaveBeenCalledTimes(3); await user.keyboard('[Escape]'); - await expect(ModalCloseEscapeEvent).toHaveBeenCalledTimes(1); + await expect(modalClosedEvent).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.esc.event-details.ts b/packages/ncids-js/src/components/usa-modal/event-details/modal.close.esc.event-details.ts deleted file mode 100644 index dc32b4f18..000000000 --- a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.esc.event-details.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ModalEventDetails } from './modal.event-details'; - -/** - * Custom event details for the `modal:close.esc` event. - */ -export type ModalCloseEscEventDetails = ModalEventDetails & { - target: HTMLElement; -}; diff --git a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.event-details.ts b/packages/ncids-js/src/components/usa-modal/event-details/modal.close.event-details.ts index 2cfeea6d8..3f583552a 100644 --- a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.event-details.ts +++ b/packages/ncids-js/src/components/usa-modal/event-details/modal.close.event-details.ts @@ -1,8 +1,16 @@ import { ModalEventDetails } from './modal.event-details'; +export enum ModalCloseAction { + KEY_ESCAPE = 'escape', + CLICK_OUTSIDE = 'outside', + CLOSE_BUTTON = 'close', + FOOTER_BUTTON = 'footer', + OTHER_BUTTON = 'other', +} + /** * Custom event details for the `modal:close` event. */ export type ModalCloseEventDetails = ModalEventDetails & { - target: HTMLElement; + closeAction?: ModalCloseAction; }; diff --git a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.outside.event-details.ts b/packages/ncids-js/src/components/usa-modal/event-details/modal.close.outside.event-details.ts deleted file mode 100644 index 875fc7125..000000000 --- a/packages/ncids-js/src/components/usa-modal/event-details/modal.close.outside.event-details.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ModalEventDetails } from './modal.event-details'; - -/** - * Custom event details for the `modal:close.outside` event. - */ -export type ModalCloseOutsideEventDetails = ModalEventDetails & { - target: HTMLElement; -}; diff --git a/packages/ncids-js/src/components/usa-modal/event-details/modal.event-details.ts b/packages/ncids-js/src/components/usa-modal/event-details/modal.event-details.ts index f9cf83af5..f67e603a5 100644 --- a/packages/ncids-js/src/components/usa-modal/event-details/modal.event-details.ts +++ b/packages/ncids-js/src/components/usa-modal/event-details/modal.event-details.ts @@ -4,4 +4,5 @@ export type ModalEventDetails = { /** The `.usa-modal` element. */ modal: HTMLElement; + target: HTMLElement; }; diff --git a/packages/ncids-js/src/components/usa-modal/event-details/modal.open.event-details.ts b/packages/ncids-js/src/components/usa-modal/event-details/modal.open.event-details.ts index 5b71c3568..6431b973b 100644 --- a/packages/ncids-js/src/components/usa-modal/event-details/modal.open.event-details.ts +++ b/packages/ncids-js/src/components/usa-modal/event-details/modal.open.event-details.ts @@ -3,6 +3,4 @@ import { ModalEventDetails } from './modal.event-details'; /** * Custom event details for the `modal:open` event. */ -export type ModalOpenEventDetails = ModalEventDetails & { - target: HTMLElement; -}; +export type ModalOpenEventDetails = ModalEventDetails; diff --git a/packages/ncids-js/src/components/usa-modal/index.ts b/packages/ncids-js/src/components/usa-modal/index.ts index 43fdf44b1..ccb13c558 100644 --- a/packages/ncids-js/src/components/usa-modal/index.ts +++ b/packages/ncids-js/src/components/usa-modal/index.ts @@ -10,14 +10,10 @@ export { USAModal } from './modal.component'; import type { ModalEventDetails } from './event-details/modal.event-details'; import type { ModalCloseEventDetails } from './event-details/modal.close.event-details'; -import type { ModalCloseOutsideEventDetails } from './event-details/modal.close.outside.event-details'; -import type { ModalCloseEscEventDetails } from './event-details/modal.close.esc.event-details'; import type { ModalOpenEventDetails } from './event-details/modal.open.event-details'; export type { ModalEventDetails, ModalCloseEventDetails, - ModalCloseOutsideEventDetails, - ModalCloseEscEventDetails, ModalOpenEventDetails, }; diff --git a/packages/ncids-js/src/components/usa-modal/modal.component.ts b/packages/ncids-js/src/components/usa-modal/modal.component.ts index 5fdab6a4b..e3932632e 100644 --- a/packages/ncids-js/src/components/usa-modal/modal.component.ts +++ b/packages/ncids-js/src/components/usa-modal/modal.component.ts @@ -15,7 +15,7 @@ * import '@nciocpl/ncids-js/usa-modal/auto-init'; * ``` * - * ## Advanced Options + * ## Advanced Options * If you need access to the modal instance to further customize your site, * you can manually initialize the modal: * @@ -81,9 +81,8 @@ * * // add handleModalOpen to button or link * modalElements.addEventListener('click', (e) => modal.handleModalOpen(e), false); - * - * * ``` + * * ## HTML Events * * The modal component will dispatch the following @@ -93,8 +92,6 @@ * * - `usa-modal:open`: Dispatched when the modal is opened. Includes details about the modal and the triggering element. * - `usa-modal:close`: Dispatched when the modal is closed from an element with the `data-close-modal` attribute. - * - `usa-modal:close:outside`: Dispatched when the modal is closed by clicking outside the modal (on the overlay). - * - `usa-modal:close:escape`: Dispatched when the modal is closed by pressing the Escape key. * * These events provide hooks for integrating with analytics or other JavaScript logic to enhance user interaction tracking. */ @@ -106,8 +103,7 @@ import { FocusTrap } from '../../utils/focus-trap'; import { scrollbarWidth } from './utils/scrollbar-width'; import { ModalOpenEventDetails } from './event-details/modal.open.event-details'; import { ModalCloseEventDetails } from './event-details/modal.close.event-details'; -import { ModalCloseOutsideEventDetails } from './event-details/modal.close.outside.event-details'; -import { ModalCloseEscEventDetails } from './event-details/modal.close.esc.event-details'; +import { ModalCloseAction } from './event-details/modal.close.event-details'; export class USAModal { /** The .usa-modal element. */ @@ -243,6 +239,15 @@ export class USAModal { USAModal._components.set(this.modal, this); } + /** + * Gets the modal HTMLElement. + * + * @returns The modal HTMLElement. + */ + public getModalElement(): HTMLElement { + return this.modal; + } + /** * Configures the modal wrapper element with necessary attributes for accessibility. * This includes setting ARIA attributes and ensuring the wrapper is visible. @@ -434,20 +439,37 @@ export class USAModal { */ public handleModalClose(event: Event): void { const mouseEvent = event as MouseEvent; - // Make sure we're only clicking on the close element - if (mouseEvent.target == mouseEvent.currentTarget) { - this.deActivateModal(); - - this.modal.dispatchEvent( - new CustomEvent('usa-modal:close', { - bubbles: true, - detail: { - modal: this.modal, - target: mouseEvent.target, - }, - }) - ); + // Check which button was clicked, close or footer buttons + const closeButton = mouseEvent.currentTarget as HTMLElement; + // Determine what button was clicked to close the modal + if (closeButton.classList.contains('usa-modal__close')) { + // If the close button was clicked + this.dispatchCloseEvent(ModalCloseAction.CLOSE_BUTTON, event); + } else if (closeButton.closest('.usa-modal__footer')) { + // If the button that was clicked was in the modal footer + this.dispatchCloseEvent(ModalCloseAction.FOOTER_BUTTON, event); + } else { + // Other button inside the modal (like a custom button in the content) + this.dispatchCloseEvent(ModalCloseAction.OTHER_BUTTON, event); } + this.deActivateModal(); + } + + private dispatchCloseEvent( + closeAction: ModalCloseAction, + event: Event + ): void { + const evt = event as Event; + this.modal.dispatchEvent( + new CustomEvent('usa-modal:close', { + bubbles: true, + detail: { + modal: this.modal, + target: evt.target as HTMLElement, + closeAction: closeAction, + }, + }) + ); } /** @@ -457,19 +479,10 @@ export class USAModal { */ private handleModalCloseOutside(event: Event): void { const mouseEvent = event as MouseEvent; - // Make sure we're only clicking on the close element + // Make sure we're only clicking on the overlay element if (mouseEvent.target == mouseEvent.currentTarget) { + this.dispatchCloseEvent(ModalCloseAction.CLICK_OUTSIDE, event); this.deActivateModal(); - - this.modal.dispatchEvent( - new CustomEvent('usa-modal:close:outside', { - bubbles: true, - detail: { - modal: this.modal, - target: mouseEvent.target, - }, - }) - ); } } @@ -487,16 +500,8 @@ export class USAModal { Dismisses the modal if it is visible. */ if (this.modal) { + this.dispatchCloseEvent(ModalCloseAction.KEY_ESCAPE, event); this.deActivateModal(); - - this.modal.dispatchEvent( - new CustomEvent('usa-modal:close:escape', { - bubbles: true, - detail: { - target: keyboardEvent.target, - }, - }) - ); } break; diff --git a/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.default.stories.jsx b/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.default.stories.jsx index bd58c65fa..4d524c7ca 100644 --- a/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.default.stories.jsx +++ b/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.default.stories.jsx @@ -8,7 +8,9 @@ export default { title: 'Components/Modal/Default', component: Component, parameters: { - ncidsInitJs: () => USAModal.createAll(), + ncidsInitJs: () => { + USAModal.createAll(); + }, css, }, }; From c3058e91a56074ab296c88cf19ee1cbea8f1af0e Mon Sep 17 00:00:00 2001 From: dlescarbeau Date: Tue, 17 Feb 2026 12:14:24 -0500 Subject: [PATCH 2/2] (#2107) Updates modal to accept shadowDOM in config - Allows the modal to be isolated from the rest of the page Closes #2107 --- .../src/components/usa-modal/modal-config.ts | 2 + .../components/usa-modal/modal.component.ts | 55 ++++++++++++-- packages/ncids-js/src/utils/focus-trap.ts | 20 ++++- .../content/two-modals-one-shadow.twig | 4 + ...sa-modal.variant.config.shadow.stories.jsx | 73 +++++++++++++++++++ 5 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 testing/ncids-css-testing/src/stories/components/usa-modal/content/two-modals-one-shadow.twig create mode 100644 testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.variant.config.shadow.stories.jsx diff --git a/packages/ncids-js/src/components/usa-modal/modal-config.ts b/packages/ncids-js/src/components/usa-modal/modal-config.ts index cd88cc4de..4d7b140f0 100644 --- a/packages/ncids-js/src/components/usa-modal/modal-config.ts +++ b/packages/ncids-js/src/components/usa-modal/modal-config.ts @@ -9,6 +9,7 @@ * id: string; * forced: false, * modifier: 'usa-modal--lg', + * shadow: null * }; * ``` */ @@ -16,4 +17,5 @@ export type ModalConfig = { id: string; forced?: boolean; modifier: string; + shadow?: ShadowRoot | null; }; diff --git a/packages/ncids-js/src/components/usa-modal/modal.component.ts b/packages/ncids-js/src/components/usa-modal/modal.component.ts index e3932632e..15479a460 100644 --- a/packages/ncids-js/src/components/usa-modal/modal.component.ts +++ b/packages/ncids-js/src/components/usa-modal/modal.component.ts @@ -151,6 +151,9 @@ export class USAModal { /** Is this a forced action modal. */ private isForced: string; + /** The container of the modal, either document.body or the shadowRoot */ + private modalContext: HTMLElement | ShadowRoot; + /** The Header element for updating. */ private modalHeading: HTMLElement; @@ -212,12 +215,22 @@ export class USAModal { this.modal = modal as HTMLElement; + // Get the shadow root from the blank modal, if it exists. + const modalShadowRootElement = document.getElementById( + 'modal-shadow-container' + ); + + // Set the modalContext to the shadow root if it exists, otherwise default to document.body + this.modalContext = + modalShadowRootElement?.shadowRoot instanceof ShadowRoot + ? modalShadowRootElement.shadowRoot + : document.body; + // header element for updating later this.modalHeading = - (document.getElementById(`${this.modalId}-heading`) as HTMLElement) || - null; + (modal.querySelector(`#${this.modalId}-heading`) as HTMLElement) || null; // get the parent of the describedBy attribute - this.modalBody = document.getElementById(`${this.modalId}-description`) + this.modalBody = modal.querySelector(`#${this.modalId}-description`) ?.parentElement as HTMLElement; this.modalFooter = (modal.getElementsByClassName('usa-modal__footer')[0] as HTMLElement) || @@ -373,8 +386,10 @@ export class USAModal { this.overlayElement.appendChild(this.modal); // append the overlay to the wrapper this.wrapperElement.appendChild(this.overlayElement); - // append the wrapper to the body - document.body.appendChild(this.wrapperElement); + + // Append the wrapper to the modal context, which + // may be the document body or a shadow root + this.modalContext.appendChild(this.wrapperElement); // activate focus trap inside the modal this.focusTrap.toggleTrap(true, this.modal); @@ -455,6 +470,11 @@ export class USAModal { this.deActivateModal(); } + /** + * Handles the dispatching of the custom close event with details about how the modal was closed. + * @param closeAction the means in which the modal was closed + * @param event the event captured + */ private dispatchCloseEvent( closeAction: ModalCloseAction, event: Event @@ -597,10 +617,24 @@ export class USAModal { } emptyModal.appendChild(modalContent); - // Append to body for modal - document.body.appendChild(emptyModal); + // If the config specifies a shadow DOM, + // attach the modal to the shadow root specified. + if (config.shadow instanceof ShadowRoot) { + // Get a reference of the shadow root to append the modal wrapper to + const shadowRoot = config.shadow; + // Give it an ID so we can grab it later to append the modal wrapper + shadowRoot.host.setAttribute('id', 'modal-shadow-container'); + + // Add the modal to the shadow root + shadowRoot.appendChild(emptyModal); + + // Append the shadow root's container to the body + document.body.appendChild(shadowRoot.host); + } else { + // Otherwise, append the modal directly to the body. + document.body.appendChild(emptyModal); + } - // return modal object return emptyModal as HTMLElement; } @@ -778,6 +812,11 @@ export class USAModal { this.overlayElement.remove(); this.wrapperElement.remove(); + // Remove the shadow root container if it exists + if (this.modalContext instanceof ShadowRoot) { + this.modalContext.host.remove(); + } + // check for buttons in modal and remove listeners const closeButtons = Array.from( this.modal.querySelectorAll('[data-close-modal]') diff --git a/packages/ncids-js/src/utils/focus-trap.ts b/packages/ncids-js/src/utils/focus-trap.ts index 8818e3274..4d9db3084 100644 --- a/packages/ncids-js/src/utils/focus-trap.ts +++ b/packages/ncids-js/src/utils/focus-trap.ts @@ -91,15 +91,27 @@ export class FocusTrap { } if (eventKey.shiftKey) { // if shift key pressed for shift + tab combination - if (document.activeElement === this.firstFocusableElement) { - // add focus for the last focusable element + if ( + document.activeElement === this.firstFocusableElement || + document.activeElement?.shadowRoot !== null + ) { + // If the activeElement is the first focusable element, move the focus to the last focusable element. + // OR if the activeElement is inside a shadow root, move the focus to the first focusable element. + // This allows tabbing through the modal content without accidentally tabbing out of the modal + // when focus is on an element inside a shadow root. this.lastFocusableElement.focus(); eventKey.preventDefault(); } } else { // if tab key is pressed - if (document.activeElement === this.lastFocusableElement) { - // add focus for the first focusable element + if ( + document.activeElement === this.lastFocusableElement || + document.activeElement?.shadowRoot !== null + ) { + // If the activeElement is the last focusable element, move the focus to the first focusable element. + // OR if the activeElement is inside a shadow root, move the focus to the first focusable element. + // This allows tabbing through the modal content without accidentally tabbing out of the modal + // when focus is on an element inside a shadow root. this.firstFocusableElement.focus(); eventKey.preventDefault(); } diff --git a/testing/ncids-css-testing/src/stories/components/usa-modal/content/two-modals-one-shadow.twig b/testing/ncids-css-testing/src/stories/components/usa-modal/content/two-modals-one-shadow.twig new file mode 100644 index 000000000..93f30a67e --- /dev/null +++ b/testing/ncids-css-testing/src/stories/components/usa-modal/content/two-modals-one-shadow.twig @@ -0,0 +1,4 @@ +
+ + +
diff --git a/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.variant.config.shadow.stories.jsx b/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.variant.config.shadow.stories.jsx new file mode 100644 index 000000000..f875f6d56 --- /dev/null +++ b/testing/ncids-css-testing/src/stories/components/usa-modal/usa-modal.variant.config.shadow.stories.jsx @@ -0,0 +1,73 @@ +import Component from './content/two-modals-one-shadow.twig'; +import css from './index.scss?inline'; + +import { USAModal } from '@nciocpl/ncids-js/usa-modal'; +const NcidsContent = { + modal01: { + trigger: { + text: 'Open Shadow Modal', + }, + }, + modal02: { + trigger: { + text: 'Open Modal Without Shadow', + }, + }, +}; + +// Create the shadow DOM container and style for the modal +const shadowRoot = document.createElement('div').attachShadow({ mode: 'open' }); +const styleTag = document.createElement('style'); +styleTag.textContent = css; +shadowRoot.appendChild(styleTag); + +const shadowConfig = { + id: 'modal-shadow', + modifier: '', + title: 'shadow example', + shadow: shadowRoot, +}; + +const shadowContent = { + title: 'Modal Shadow Example', + content: 'This modal should be attached to the shadow DOM of the body.', +}; + +const modalConfig = { + id: 'modal-no-shadow', + modifier: '', + title: 'modal example without shadow', +}; + +const modalContent = { + title: 'Modal Example (No Shadow)', + content: 'This modal should be attached to the body.', +}; + +export default { + title: 'Components/Modal/Variants', + component: Component, + parameters: { + ncidsInitJs: () => { + const noShadowButton = document.querySelector('[data-async-modal="456"]'); + const modal = USAModal.createConfig(modalConfig); + modal.updateDialog(modalContent); + noShadowButton.addEventListener( + 'click', + (e) => modal2.handleModalOpen(e), + false + ); + const shadowButton = document.querySelector('[data-async-modal="123"]'); + const shadowModal = USAModal.createConfig(shadowConfig); + shadowModal.updateDialog(shadowContent); + shadowButton.addEventListener( + 'click', + (e) => modal.handleModalOpen(e), + false + ); + }, + css, + }, +}; + +export const ConfigShadow = { args: NcidsContent };