diff --git a/packages/overlay/package.json b/packages/overlay/package.json index b0500e5a3a8..14d78ac0b74 100644 --- a/packages/overlay/package.json +++ b/packages/overlay/package.json @@ -171,6 +171,7 @@ "@spectrum-web-components/reactive-controllers": "1.7.0", "@spectrum-web-components/shared": "1.7.0", "@spectrum-web-components/theme": "1.7.0", + "@spectrum-web-components/underlay": "1.7.0", "focus-trap": "^7.6.4" }, "types": "./src/index.d.ts", diff --git a/packages/overlay/src/Overlay.ts b/packages/overlay/src/Overlay.ts index 8535f4db638..c78a0545f1a 100644 --- a/packages/overlay/src/Overlay.ts +++ b/packages/overlay/src/Overlay.ts @@ -55,6 +55,7 @@ import { import styles from './overlay.css.js'; import { FocusTrap } from 'focus-trap'; +import '@spectrum-web-components/underlay/sp-underlay.js'; const browserSupportsPopover = 'showPopover' in document.createElement('div'); @@ -420,9 +421,9 @@ export class Overlay extends ComputedOverlayBase { * Determines the value for the popover attribute based on the overlay type. * * @private - * @returns {'auto' | 'manual' | undefined} The popover value or undefined if not applicable. + * @returns {'auto' | 'manual' | 'hint' | undefined} The popover value or undefined if not applicable. */ - private get popoverValue(): 'auto' | 'manual' | undefined { + private get popoverValue(): 'auto' | 'manual' | 'hint' | undefined { const hasPopoverAttribute = 'popover' in this; if (!hasPopoverAttribute) { @@ -431,10 +432,8 @@ export class Overlay extends ComputedOverlayBase { switch (this.type) { case 'modal': - return 'auto'; - case 'page': return 'manual'; - case 'hint': + case 'page': return 'manual'; default: return this.type; @@ -542,6 +541,9 @@ export class Overlay extends ComputedOverlayBase { const focusTrap = await import('focus-trap'); this._focusTrap = focusTrap.createFocusTrap(this.dialogEl, { initialFocus: focusEl || undefined, + allowOutsideClick: (event) => { + return !event.isTrusted; + }, tabbableOptions: { getShadowRoot: true, }, @@ -554,7 +556,10 @@ export class Overlay extends ComputedOverlayBase { escapeDeactivates: false, }); - if (this.type === 'modal' || this.type === 'page') { + if ( + (this.type === 'modal' || this.type === 'page') && + this.receivesFocus !== 'false' + ) { this._focusTrap.activate(); } } @@ -1141,6 +1146,19 @@ export class Overlay extends ComputedOverlayBase { */ public override render(): TemplateResult { return html` + ${this.type === 'modal' || this.type === 'page' + ? html` + + { + this.open = false; + }} + style="--spectrum-underlay-background-color: transparent" + > + + ` + : ''} ${this.renderPopover()} `; diff --git a/packages/overlay/src/OverlayStack.ts b/packages/overlay/src/OverlayStack.ts index 4c174840f20..44193a101ea 100644 --- a/packages/overlay/src/OverlayStack.ts +++ b/packages/overlay/src/OverlayStack.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * Copyright 2025 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -27,6 +28,10 @@ class OverlayStack { stack: Overlay[] = []; + private originalBodyOverflow = ''; + + private bodyScrollBlocked = false; + constructor() { this.bindEvents(); } @@ -79,6 +84,25 @@ class OverlayStack { this.stack.splice(overlayIndex, 1); } overlay.open = false; + + this.manageBodyScroll(); + } + + /** + * Manage body scroll blocking based on modal/page overlays + */ + private manageBodyScroll(): void { + const shouldBlock = this.stack.some( + (overlay) => overlay.type === 'modal' || overlay.type === 'page' + ); + if (shouldBlock && !this.bodyScrollBlocked) { + this.originalBodyOverflow = document.body.style.overflow || ''; + document.body.style.overflow = 'hidden'; + this.bodyScrollBlocked = true; + } else if (!shouldBlock && this.bodyScrollBlocked) { + document.body.style.overflow = this.originalBodyOverflow; + this.bodyScrollBlocked = false; + } } /** @@ -151,14 +175,14 @@ class OverlayStack { private handleKeydown = (event: KeyboardEvent): void => { if (event.code !== 'Escape') return; + if (event.defaultPrevented) return; // Don't handle if already handled if (!this.stack.length) return; const last = this.stack[this.stack.length - 1]; if (last?.type === 'page') { event.preventDefault(); return; } - if (last?.type === 'manual') { - // Manual overlays should close on "Escape" key, but not when losing focus or interacting with other parts of the page. + if (last?.type === 'manual' || last?.type === 'modal') { this.closeOverlay(last); return; } @@ -213,11 +237,29 @@ class OverlayStack { const path = event.composedPath(); this.stack.forEach((overlayEl) => { const inPath = path.find((el) => el === overlayEl); + + // Check if the trigger element is inside this overlay + const triggerInOverlay = + overlay.triggerElement && + overlay.triggerElement instanceof HTMLElement && + overlayEl.contains && + overlayEl.contains(overlay.triggerElement); + console.log( + 'overlayEl.type:', + overlayEl.type, + 'triggerInOverlay:', + triggerInOverlay, + 'inPath:', + !!inPath + ); + if ( !inPath && + !triggerInOverlay && overlayEl.type !== 'manual' && overlayEl.type !== 'modal' ) { + console.log('Closing overlay:', overlayEl); this.closeOverlay(overlayEl); } }); @@ -248,6 +290,7 @@ class OverlayStack { overlay.addEventListener('beforetoggle', this.handleBeforetoggle, { once: true, }); + this.manageBodyScroll(); }); } diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index 52031d0cefe..e39dc249e83 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -33,6 +33,7 @@ import '@spectrum-web-components/overlay/overlay-trigger.js'; import '@spectrum-web-components/accordion/sp-accordion-item.js'; import '@spectrum-web-components/accordion/sp-accordion.js'; +import '@spectrum-web-components/action-menu/sp-action-menu.js'; import '@spectrum-web-components/button-group/sp-button-group.js'; import '@spectrum-web-components/menu/sp-menu-divider.js'; import '@spectrum-web-components/menu/sp-menu-group.js'; @@ -634,6 +635,208 @@ export const deepChildTooltip = (): TemplateResult => html` `; +export const debug = (): TemplateResult => { + return html` + + + Button popover + + + + + Deselect + + Select inverse + + Feather... + + Select and mask... + + + + Save selection + + + Make work path + + + + + + I'm a tooltip in a different direction + + + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos. + + + Button + + Popover content + + + Tooltip content + + + + Open menu + + + Item 1 + Item 2 + + + More Actions + Deselect + Select inverse + Feather... + Select and mask... + + Save selection + Make work path + + + +
+ + Modal overlay + + + Open menu + + + Max 20 + + + + `; +}; + +// Helper functions for the overlay functionality +const getModalOverlayContent = (): HTMLElement => { + const fragment = document.createDocumentFragment(); + render( + html` + + Modal overlay content + openAutoOverlay(event)}> + Auto overlay + + + `, + fragment + ); + return fragment.children[0] as HTMLElement; +}; + +const getAutoOverlayContent = (): HTMLElement => { + const fragment = document.createDocumentFragment(); + render( + html` + Auto overlay + `, + fragment + ); + return fragment.children[0] as HTMLElement; +}; + +const openModalOverlay = async (event: Event) => { + const trigger = event.target as HTMLElement; + const overlay = await Overlay.open(getModalOverlayContent(), { + trigger, + type: 'modal', + placement: 'bottom', + }); + const container = document.querySelector('.container'); + container?.insertAdjacentElement('afterend', overlay); +}; + +const openAutoOverlay = async (event: Event) => { + const trigger = event.target as HTMLElement; + const overlay = await Overlay.open(getAutoOverlayContent(), { + trigger, + type: 'auto', + placement: 'bottom', + }); + const container = document.querySelector('.container'); + container?.insertAdjacentElement('afterend', overlay); +}; export const deepNesting = (): TemplateResult => { const color = window.__swc_hack_knobs__.defaultColor; @@ -1068,30 +1271,6 @@ export const modalManaged = (): TemplateResult => { `; }; -export const modalWithinNonModal = (): TemplateResult => { - return html` - - - Open inline overlay - - - - - - Open modal overlay - - - - Modal overlay - - - - - - - `; -}; - export const noCloseOnResize = (args: Properties): TemplateResult => html`