diff --git a/package.json b/package.json index cafb66d31e..291d7ba000 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,7 @@ ], "output": [ "packages/**/*.js", + "packages/**/*.dev.js", "projects/**/*.js", "packages/**/*.js.map", "projects/**/*.js.map", diff --git a/packages/overlay/package.json b/packages/overlay/package.json index 1387e6d9e3..cedd2cb1d2 100644 --- a/packages/overlay/package.json +++ b/packages/overlay/package.json @@ -115,6 +115,10 @@ "development": "./src/slottable-request-event.dev.js", "default": "./src/slottable-request-event.js" }, + "./src/strategies.js": { + "development": "./src/strategies.dev.js", + "default": "./src/strategies.js" + }, "./active-overlay.js": { "development": "./active-overlay.dev.js", "default": "./active-overlay.js" diff --git a/packages/overlay/src/AbstractOverlay.ts b/packages/overlay/src/AbstractOverlay.ts index 375704eaac..0dbd2ea4da 100644 --- a/packages/overlay/src/AbstractOverlay.ts +++ b/packages/overlay/src/AbstractOverlay.ts @@ -357,13 +357,15 @@ export class AbstractOverlay extends SpectrumElement { const options = optionsV1; AbstractOverlay.applyOptions(overlay, { ...options, - delayed: options.delayed || overlayContent.hasAttribute('delayed'), + delayed: + options.delayed || overlayContent.hasAttribute('delayed'), trigger: options.virtualTrigger || trigger, - type: interaction === 'modal' - ? 'modal' - : interaction === 'hover' - ? 'hint' - : 'auto' + type: + interaction === 'modal' + ? 'modal' + : interaction === 'hover' + ? 'hint' + : 'auto', }); trigger.insertAdjacentElement('afterend', overlay); await overlay.updateComplete; @@ -375,7 +377,7 @@ export class AbstractOverlay extends SpectrumElement { overlay.append(overlayContent); AbstractOverlay.applyOptions(overlay, { ...options, - delayed: options.delayed || overlayContent.hasAttribute('delayed') + delayed: options.delayed || overlayContent.hasAttribute('delayed'), }); overlay.updateComplete.then(() => { // Do we want to "open" this path, or leave that to the consumer? @@ -384,7 +386,10 @@ export class AbstractOverlay extends SpectrumElement { return overlay; } - static applyOptions(overlay: Overlay, options: OverlayOptions): void { + static applyOptions( + overlay: AbstractOverlay, + options: OverlayOptions + ): void { overlay.delayed = !!options.delayed; overlay.receivesFocus = options.receivesFocus ?? 'auto'; overlay.triggerElement = options.trigger || null; diff --git a/packages/overlay/src/ClickController.ts b/packages/overlay/src/ClickController.ts index fbcf3c91ab..5e715eb5ff 100644 --- a/packages/overlay/src/ClickController.ts +++ b/packages/overlay/src/ClickController.ts @@ -28,13 +28,13 @@ export class ClickController extends InteractionController { handleClick(): void { if (!this.preventNextToggle) { - this.host.open = !this.host.open; + this.open = !this.open; } this.preventNextToggle = false; } handlePointerdown(): void { - this.preventNextToggle = this.host.open; + this.preventNextToggle = this.open; } override init(): void { diff --git a/packages/overlay/src/HoverController.ts b/packages/overlay/src/HoverController.ts index 7d55989cdf..82e1048b38 100644 --- a/packages/overlay/src/HoverController.ts +++ b/packages/overlay/src/HoverController.ts @@ -37,14 +37,14 @@ export class HoverController extends InteractionController { if (!document.activeElement?.matches(':focus-visible')) { return; } - this.host.open = true; + this.open = true; this.focusedin = true; } handleTargetFocusout(): void { this.focusedin = false; if (this.pointerentered) return; - this.host.open = false; + this.open = false; } handleTargetPointerenter(): void { @@ -52,8 +52,8 @@ export class HoverController extends InteractionController { clearTimeout(this.hoverTimeout); this.hoverTimeout = undefined; } - if (this.host.disabled) return; - this.host.open = true; + if (this.overlay?.disabled) return; + this.open = true; this.pointerentered = true; } @@ -76,11 +76,11 @@ export class HoverController extends InteractionController { override prepareDescription(): void { // require "content" to apply relationship - if (!this.host.elements.length) return; + if (!this.overlay.elements.length) return; const triggerRoot = this.target.getRootNode(); - const contentRoot = this.host.elements[0].getRootNode(); - const overlayRoot = this.host.getRootNode(); + const contentRoot = this.overlay.elements[0].getRootNode(); + const overlayRoot = this.overlay.getRootNode(); if (triggerRoot === overlayRoot) { this.prepareOverlayRelativeDescription(); } else if (triggerRoot === contentRoot) { @@ -92,7 +92,7 @@ export class HoverController extends InteractionController { const releaseDescription = conditionAttributeWithId( this.target, 'aria-describedby', - [this.host.id] + [this.overlay.id] ); this.releaseDescription = () => { releaseDescription(); @@ -102,10 +102,10 @@ export class HoverController extends InteractionController { private prepareContentRelativeDescription(): void { const elementIds: string[] = []; - const appliedIds = this.host.elements.map((el) => { + const appliedIds = this.overlay.elements.map((el) => { elementIds.push(el.id); if (!el.id) { - el.id = `${this.host.tagName.toLowerCase()}-helper-${randomID()}`; + el.id = `${this.overlay.tagName.toLowerCase()}-helper-${randomID()}`; } return el.id; }); @@ -117,7 +117,7 @@ export class HoverController extends InteractionController { ); this.releaseDescription = () => { releaseDescription(); - this.host.elements.map((el, index) => { + this.overlay.elements.map((el, index) => { el.id = this.elementIds[index]; }); this.releaseDescription = noop; @@ -130,7 +130,7 @@ export class HoverController extends InteractionController { if (this.focusedin && triggerElement.matches(':focus-visible')) return; this.hoverTimeout = setTimeout(() => { - this.host.open = false; + this.open = false; }, HOVER_DELAY); } @@ -159,12 +159,22 @@ export class HoverController extends InteractionController { () => this.handleTargetPointerleave(), { signal } ); - this.host.addEventListener( + if (this.overlay) { + this.initOverlay(); + } + } + + override initOverlay(): void { + if (!this.abortController) { + return; + } + const { signal } = this.abortController; + this.overlay.addEventListener( 'pointerenter', () => this.handleHostPointerenter(), { signal } ); - this.host.addEventListener( + this.overlay.addEventListener( 'pointerleave', () => this.handleHostPointerleave(), { signal } diff --git a/packages/overlay/src/InteractionController.ts b/packages/overlay/src/InteractionController.ts index 996586f247..d50c1e9dc7 100644 --- a/packages/overlay/src/InteractionController.ts +++ b/packages/overlay/src/InteractionController.ts @@ -19,6 +19,12 @@ export enum InteractionTypes { 'longpress', } +export type ControllerOptions = { + overlay?: AbstractOverlay; + handleOverlayReady?: (overlay: AbstractOverlay) => void; + isPersistent?: boolean; +}; + export class InteractionController implements ReactiveController { abortController!: AbortController; @@ -26,14 +32,71 @@ export class InteractionController implements ReactiveController { return false; } - type!: InteractionTypes; + private handleOverlayReady?: (overlay: AbstractOverlay) => void; + + public get open(): boolean { + return this.overlay?.open ?? false; + } + + /** + * Set `open` against the associated Overlay lazily. + */ + public set open(open: boolean) { + if (this.overlay) { + // If there already is an Overlay, apply the value of `open` directly. + this.overlay.open = open; + return; + } + if (!open) { + // When `open` moves to `false` and there is not yet an Overlay, + // assume that no Overlay and a closed Overlay are the same and return early. + return; + } + // When there is no Overlay and `open` is moving to `true`, lazily import/create + // an Overlay and apply that state to it. + customElements + .whenDefined('sp-overlay') + .then(async (): Promise => { + const { Overlay } = await import('./Overlay.js'); + this.overlay = new Overlay(); + this.overlay.open = true; + }); + import('@spectrum-web-components/overlay/sp-overlay.js'); + } + + public get overlay(): AbstractOverlay { + return this._overlay; + } - constructor(public host: AbstractOverlay, public target: HTMLElement, private isPersistent = false) { - this.host.addController(this); + public set overlay(overlay: AbstractOverlay | undefined) { + if (!overlay) return; + if (this.overlay === overlay) return; + if (this.overlay) { + this.overlay.removeController(this); + } + this._overlay = overlay; + this.overlay.addController(this); + this.initOverlay(); this.prepareDescription(this.target); + this.handleOverlayReady?.(this.overlay); + } + + private _overlay!: AbstractOverlay; + + protected isPersistent = false; + + type!: InteractionTypes; + + constructor( + public target: HTMLElement, + { overlay, isPersistent, handleOverlayReady }: ControllerOptions + ) { + this.isPersistent = !!isPersistent; + this.handleOverlayReady = handleOverlayReady; if (this.isPersistent) { this.init(); } + this.overlay = overlay; } prepareDescription(_: HTMLElement): void {} @@ -47,6 +110,11 @@ export class InteractionController implements ReactiveController { // Abstract init() method. } + /* c8 ignore next 3 */ + initOverlay(): void { + // Abstract initOverlay() method. + } + abort(): void { this.releaseDescription(); this.abortController?.abort(); diff --git a/packages/overlay/src/LongpressController.ts b/packages/overlay/src/LongpressController.ts index f12f3117d0..7d6ea954ca 100644 --- a/packages/overlay/src/LongpressController.ts +++ b/packages/overlay/src/LongpressController.ts @@ -18,7 +18,10 @@ import { conditionAttributeWithId } from '@spectrum-web-components/base/src/cond import { randomID } from '@spectrum-web-components/shared/src/random-id.js'; import { noop } from './AbstractOverlay.js'; -import { InteractionController, InteractionTypes } from './InteractionController.js'; +import { + InteractionController, + InteractionTypes, +} from './InteractionController.js'; const LONGPRESS_DURATION = 300; export const LONGPRESS_INSTRUCTIONS = { @@ -48,7 +51,7 @@ export class LongpressController extends InteractionController { private timeout!: ReturnType; handleLongpress(): void { - this.host.open = true; + this.open = true; this.longpressState = this.longpressState === 'potential' ? 'opening' : 'pressed'; } @@ -83,7 +86,8 @@ export class LongpressController extends InteractionController { // or `pointerup` should move the `longpressState` to // `null` so that the earlier event can void the "light // dismiss" and keep the Overlay open. - this.longpressState = this.host.state === 'opening' ? 'pressed' : null; + this.longpressState = + this.overlay?.state === 'opening' ? 'pressed' : null; document.removeEventListener('pointerup', this.handlePointerup); document.removeEventListener('pointercancel', this.handlePointerup); }; @@ -123,7 +127,7 @@ export class LongpressController extends InteractionController { // do not reapply until target is recycled this.releaseDescription !== noop || // require "longpress content" to apply relationship - !this.host.elements.length + !this.overlay.elements.length ) { return; } @@ -134,13 +138,13 @@ export class LongpressController extends InteractionController { longpressDescription.textContent = LONGPRESS_INSTRUCTIONS[messageType]; longpressDescription.slot = 'longpress-describedby-descriptor'; const triggerParent = trigger.getRootNode() as HTMLElement; - const overlayParent = this.host.getRootNode() as HTMLElement; + const overlayParent = this.overlay.getRootNode() as HTMLElement; // Manage the placement of the helper element in an accessible place with // the lowest chance of negatively affecting the layout of the page. if (triggerParent === overlayParent) { // Trigger and Overlay in same DOM tree... // Append helper element to Overlay. - this.host.append(longpressDescription); + this.overlay.append(longpressDescription); } else { // If Trigger in , hide helper longpressDescription.hidden = !('host' in triggerParent); diff --git a/packages/overlay/src/Overlay.ts b/packages/overlay/src/Overlay.ts index b1d764a551..c21e5cb0a3 100644 --- a/packages/overlay/src/Overlay.ts +++ b/packages/overlay/src/Overlay.ts @@ -45,10 +45,11 @@ import { OverlayNoPopover } from './OverlayNoPopover.js'; import { overlayStack } from './OverlayStack.js'; import { VirtualTrigger } from './VirtualTrigger.js'; import { PlacementController } from './PlacementController.js'; -import { ClickController } from './ClickController.js'; -import { HoverController } from './HoverController.js'; -import { LongpressController } from './LongpressController.js'; +import type { ClickController } from './ClickController.js'; +import type { HoverController } from './HoverController.js'; +import type { LongpressController } from './LongpressController.js'; export { LONGPRESS_INSTRUCTIONS } from './LongpressController.js'; +import { strategies } from './strategies.js'; import { removeSlottableRequest, SlottableRequestEvent, @@ -66,12 +67,6 @@ if (supportsPopover) { OverlayFeatures = OverlayNoPopover(OverlayFeatures); } -export const strategies = { - click: ClickController, - longpress: LongpressController, - hover: HoverController, -}; - /** * @element sp-overlay * @@ -498,8 +493,10 @@ export class Overlay extends OverlayFeatures { if (!this.hasNonVirtualTrigger) return; if (!this.triggerInteraction) return; this.strategy = new strategies[this.triggerInteraction]( - this, - this.triggerElement as HTMLElement + this.triggerElement as HTMLElement, + { + overlay: this, + } ); } diff --git a/packages/overlay/src/overlay-trigger-directive.ts b/packages/overlay/src/overlay-trigger-directive.ts index ca444a1c85..fbd778a1fe 100644 --- a/packages/overlay/src/overlay-trigger-directive.ts +++ b/packages/overlay/src/overlay-trigger-directive.ts @@ -16,17 +16,17 @@ import { TemplateResult, } from '@spectrum-web-components/base'; import { directive } from 'lit/async-directive.js'; -import { Overlay, strategies } from './Overlay.js'; +import { strategies } from './strategies.js'; import { OverlayOptions, TriggerInteraction } from './overlay-types.js'; import { ClickController } from './ClickController.js'; import { HoverController } from './HoverController.js'; import { LongpressController } from './LongpressController.js'; -import '../sp-overlay.js'; import { removeSlottableRequest, SlottableRequestEvent, } from './slottable-request-event.js'; import { SlottableRequestDirective } from './slottable-request-directive.js'; +import { AbstractOverlay } from './AbstractOverlay.js'; export type InsertionOptions = { el: HTMLElement | (() => HTMLElement); @@ -41,8 +41,8 @@ export type OverlayTriggerOptions = { }; export class OverlayTriggerDirective extends SlottableRequestDirective { - private overlay = new Overlay(); - private strategy?: ClickController | HoverController | LongpressController; + private overlay!: AbstractOverlay; + private strategy!: ClickController | HoverController | LongpressController; protected defaultOptions: OverlayTriggerOptions = { triggerInteraction: 'click', @@ -92,11 +92,15 @@ export class OverlayTriggerDirective extends SlottableRequestDirective { this.strategy?.abort(); this.strategy = new strategies[ triggerInteraction as TriggerInteraction - ](this.overlay, this.target, true); + ](this.target, { + isPersistent: true, + handleOverlayReady: (overlay: AbstractOverlay) => { + this.listenerHost = this.overlay = overlay; + this.init(); + }, + }); } - this.listenerHost = this.overlay; - this.init(); - this.overlay.open = options?.open ?? false; + this.strategy.open = options?.open ?? false; } override handleSlottableRequest(event: SlottableRequestEvent): void { @@ -109,7 +113,7 @@ export class OverlayTriggerDirective extends SlottableRequestDirective { if (willRemoveSlottable) { this.overlay.remove(); } else { - Overlay.applyOptions(this.overlay, { + AbstractOverlay.applyOptions(this.overlay, { ...this.options, trigger: this.target, }); diff --git a/packages/overlay/src/slottable-request-directive.ts b/packages/overlay/src/slottable-request-directive.ts index fc22bf2b89..4c7ff4d59f 100644 --- a/packages/overlay/src/slottable-request-directive.ts +++ b/packages/overlay/src/slottable-request-directive.ts @@ -85,7 +85,7 @@ export class SlottableRequestDirective extends AsyncDirective { } override disconnected(): void { - this.listeners.abort(); + this.listeners?.abort(); } /* c8 ignore next 3 */ diff --git a/packages/overlay/src/strategies.ts b/packages/overlay/src/strategies.ts new file mode 100644 index 0000000000..c7d6061411 --- /dev/null +++ b/packages/overlay/src/strategies.ts @@ -0,0 +1,21 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { ClickController } from './ClickController.js'; +import { HoverController } from './HoverController.js'; +import { LongpressController } from './LongpressController.js'; + +export const strategies = { + click: ClickController, + longpress: LongpressController, + hover: HoverController, +}; diff --git a/tasks/ts-tools.js b/tasks/ts-tools.js index db09f15c16..2849ccc08d 100644 --- a/tasks/ts-tools.js +++ b/tasks/ts-tools.js @@ -19,6 +19,11 @@ const relativeImportRegex = RegExp( 'import([^;]+)["|\'](?![a-zA-Z@])(..+)(? { - let js = await fs.promises.readFile(args.path, 'utf8'); - js = js.replace(relativeImportRegex, "import$1'$2.dev.js'"); - const contents = js.replace( + const js = await fs.promises.readFile(args.path, 'utf8'); + const relativeImportsProcessed = js.replace( + relativeImportRegex, + "import$1'$2.dev.js'" + ); + const relativeDynamicImportsProcessed = + relativeImportsProcessed.replace( + relativeDynamicImportRegex, + "import('$1.dev.js')" + ); + const contents = relativeDynamicImportsProcessed.replace( relativeExportRegex, "export$1'$2.dev.js'" );