From 2416f93a79e6aa8f2e8480a404e54b44f91a782c Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Tue, 26 Sep 2023 08:53:55 -0400 Subject: [PATCH] backport PR #1575 --- docs/pages/resources/changelog.md | 1 + src/components/dialog/dialog.component.ts | 5 +++- src/components/drawer/drawer.component.ts | 6 ++++- src/internal/modal.ts | 28 ++++++++++++++++------- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md index 9f87be4f7..6173f4b0e 100644 --- a/docs/pages/resources/changelog.md +++ b/docs/pages/resources/changelog.md @@ -21,6 +21,7 @@ New versions of Web Awesome are released as-needed and generally occur when a cr ## Next +- Added the `modal` property to `` and `` to support third-party modals [#1571] - Fixed a bug in the autoloader causing it to register non-Shoelace elements [#1563] - Fixed a bug in `` that resulted in improper spacing between the label and the required asterisk [#1540] - Removed error when a missing popup anchor is provided [#1548] diff --git a/src/components/dialog/dialog.component.ts b/src/components/dialog/dialog.component.ts index 4b3ab4cd4..d9ffac91b 100644 --- a/src/components/dialog/dialog.component.ts +++ b/src/components/dialog/dialog.component.ts @@ -60,6 +60,9 @@ import type { CSSResultGroup } from 'lit'; * @animation dialog.denyClose - The animation to use when a request to close the dialog is denied. * @animation dialog.overlay.show - The animation to use when showing the dialog's overlay. * @animation dialog.overlay.hide - The animation to use when hiding the dialog's overlay. + * @property modal - Exposes the internal modal utility that controls focus trapping. To temporarily disable focus + * trapping and allow third-party modals spawned from an active Shoelace modal, call `modal.activateExternal()` when + * the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping. */ export default class WaDialog extends WebAwesomeElement { static styles: CSSResultGroup = styles; @@ -69,8 +72,8 @@ export default class WaDialog extends WebAwesomeElement { private readonly hasSlotController = new HasSlotController(this, 'footer'); private readonly localize = new LocalizeController(this); - private modal = new Modal(this); private originalTrigger: HTMLElement | null; + public modal = new Modal(this); @query('.dialog') dialog: HTMLElement; @query('.dialog__panel') panel: HTMLElement; diff --git a/src/components/drawer/drawer.component.ts b/src/components/drawer/drawer.component.ts index 698cfff25..676891e15 100644 --- a/src/components/drawer/drawer.component.ts +++ b/src/components/drawer/drawer.component.ts @@ -68,6 +68,10 @@ import type { CSSResultGroup } from 'lit'; * @animation drawer.denyClose - The animation to use when a request to close the drawer is denied. * @animation drawer.overlay.show - The animation to use when showing the drawer's overlay. * @animation drawer.overlay.hide - The animation to use when hiding the drawer's overlay. + * + * @property modal - Exposes the internal modal utility that controls focus trapping. To temporarily disable focus + * trapping and allow third-party modals spawned from an active Shoelace modal, call `modal.activateExternal()` when + * the third-party modal opens. Upon closing, call `modal.deactivateExternal()` to restore Shoelace's focus trapping. */ export default class WaDrawer extends WebAwesomeElement { static styles: CSSResultGroup = styles; @@ -75,8 +79,8 @@ export default class WaDrawer extends WebAwesomeElement { private readonly hasSlotController = new HasSlotController(this, 'footer'); private readonly localize = new LocalizeController(this); - private modal = new Modal(this); private originalTrigger: HTMLElement | null; + public modal = new Modal(this); @query('.drawer') drawer: HTMLElement; @query('.drawer__panel') panel: HTMLElement; diff --git a/src/internal/modal.ts b/src/internal/modal.ts index 680a0e97d..ef71a37c3 100644 --- a/src/internal/modal.ts +++ b/src/internal/modal.ts @@ -5,6 +5,7 @@ let activeModals: HTMLElement[] = []; export default class Modal { element: HTMLElement; + isExternalActivated: boolean; tabDirection: 'forward' | 'backward' = 'forward'; currentFocus: HTMLElement | null; @@ -12,6 +13,7 @@ export default class Modal { this.element = element; } + /** Activates focus trapping. */ activate() { activeModals.push(this.element); document.addEventListener('focusin', this.handleFocusIn); @@ -19,6 +21,7 @@ export default class Modal { document.addEventListener('keyup', this.handleKeyUp); } + /** Deactivates focus trapping. */ deactivate() { activeModals = activeModals.filter(modal => modal !== this.element); this.currentFocus = null; @@ -27,13 +30,24 @@ export default class Modal { document.removeEventListener('keyup', this.handleKeyUp); } + /** Determines if this modal element is currently active or not. */ isActive() { // The "active" modal is always the most recent one shown return activeModals[activeModals.length - 1] === this.element; } + /** Activates external modal behavior and temporarily disables focus trapping. */ + activateExternal() { + this.isExternalActivated = true; + } + + /** Deactivates external modal behavior and re-enables focus trapping. */ + deactivateExternal() { + this.isExternalActivated = false; + } + checkFocus() { - if (this.isActive()) { + if (this.isActive() && !this.isExternalActivated) { const tabbableElements = getTabbableElements(this.element); if (!this.element.matches(':focus-within')) { const start = tabbableElements[0]; @@ -56,11 +70,9 @@ export default class Modal { return getTabbableElements(this.element).findIndex(el => el === this.currentFocus); } - /** - * Checks if the `startElement` is already focused. This is important if the modal already - * has an existing focus prior to the first tab key. - */ - startElementAlreadyFocused(startElement: HTMLElement) { + // Checks if the `startElement` is already focused. This is important if the modal already has an existing focus prior + // to the first tab key. + private startElementAlreadyFocused(startElement: HTMLElement) { for (const activeElement of activeElements()) { if (startElement === activeElement) { return true; @@ -70,8 +82,8 @@ export default class Modal { return false; } - handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Tab') return; + private handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || this.isExternalActivated) return; if (event.shiftKey) { this.tabDirection = 'backward';