diff --git a/packages/combobox/test/combobox.test.ts b/packages/combobox/test/combobox.test.ts index 8d2698caa2..ad82a9a89b 100644 --- a/packages/combobox/test/combobox.test.ts +++ b/packages/combobox/test/combobox.test.ts @@ -23,10 +23,15 @@ import { fixture, homeEvent, } from '../../../test/testing-helpers.js'; -import { executeServerCommand, sendKeys } from '@web/test-runner-commands'; +import { + executeServerCommand, + sendKeys, + setViewport, +} from '@web/test-runner-commands'; import { PickerButton } from '@spectrum-web-components/picker-button'; import { comboboxFixture, + longComboboxFixture, TestableCombobox, testActiveElement, } from './helpers.js'; @@ -367,7 +372,7 @@ describe('Combobox', () => { expect(el.shadowRoot.activeElement).to.equal(input); }); }); - describe('manage active decendent', () => { + describe('manage active descendent', () => { it('sets activeDescendant to first descendent on ArrowDown', async () => { const el = await comboboxFixture(); @@ -621,7 +626,7 @@ describe('Combobox', () => { ?.querySelector('[selected]')?.textContent ).to.equal(item.textContent); }); - it('sets the value when an item is clicked programatically', async () => { + it('sets the value when an item is clicked programmatically', async () => { const el = await comboboxFixture(); await elementUpdated(el); @@ -955,4 +960,27 @@ describe('Combobox', () => { expect(tooltipEl.open).to.be.false; expect(el.open).to.be.false; }); + + it('scrolls to fit window', async () => { + await setViewport({ width: 360, height: 640 }); + const el = await longComboboxFixture(); + + await elementUpdated(el); + + expect(el.value).to.equal(''); + expect(el.activeDescendant).to.be.undefined; + expect(el.open).to.be.false; + + const opened = oneEvent(el, 'sp-opened'); + el.focusElement.click(); + await opened; + expect(el.open).to.be.true; + + const menu = el.shadowRoot.querySelector( + '[role="listbox"]' + ) as HTMLElement; + await elementUpdated(menu); + + expect(menu.scrollHeight > window.innerHeight).to.be.true; + }); }); diff --git a/packages/combobox/test/helpers.ts b/packages/combobox/test/helpers.ts index 1cae2201cd..8b79c1b205 100644 --- a/packages/combobox/test/helpers.ts +++ b/packages/combobox/test/helpers.ts @@ -15,7 +15,7 @@ import { html } from '@open-wc/testing'; import { ComboboxOption } from '@spectrum-web-components/combobox'; import '@spectrum-web-components/combobox/sp-combobox.js'; import { MenuItem } from '@spectrum-web-components/menu'; -import { fruits } from '../stories/index.js'; +import { countries, fruits } from '../stories/index.js'; export type TestableCombobox = HTMLElement & { activeDescendant: ComboboxOption; @@ -51,6 +51,19 @@ export const comboboxFixture = async (): Promise => { return el; }; +export const longComboboxFixture = async (): Promise => { + const el = await fixture(html` + + Combobox + + `); + + return el; +}; export const testActiveElement = ( el: TestableCombobox, diff --git a/packages/overlay/src/Overlay.ts b/packages/overlay/src/Overlay.ts index d8852b1ebf..fb18217052 100644 --- a/packages/overlay/src/Overlay.ts +++ b/packages/overlay/src/Overlay.ts @@ -30,7 +30,6 @@ import { styleMap, } from '@spectrum-web-components/base/src/directives.js'; import { randomID } from '@spectrum-web-components/shared/src/random-id.js'; - import type { OpenableElement, OverlayState, @@ -57,14 +56,15 @@ import { import styles from './overlay.css.js'; -const supportsPopover = 'showPopover' in document.createElement('div'); +const browserSupportsPopover = 'showPopover' in document.createElement('div'); + +// Start the base class and add the popover or no-popover functionality +let ComputedOverlayBase = OverlayDialog(AbstractOverlay); -let OverlayFeatures = OverlayDialog(AbstractOverlay); -/* c8 ignore next 2 */ -if (supportsPopover) { - OverlayFeatures = OverlayPopover(OverlayFeatures); +if (browserSupportsPopover) { + ComputedOverlayBase = OverlayPopover(ComputedOverlayBase); } else { - OverlayFeatures = OverlayNoPopover(OverlayFeatures); + ComputedOverlayBase = OverlayNoPopover(ComputedOverlayBase); } /** @@ -74,16 +74,23 @@ if (supportsPopover) { * @fires sp-closed - announce that an overlay has compelted any exit animations * @fires slottable-request - requests to add or remove slottable content */ -export class Overlay extends OverlayFeatures { +export class Overlay extends ComputedOverlayBase { static override styles = [styles]; /** * An Overlay that is `delayed` will wait until a warm-up period of 1000ms - * has completed before opening. Once the warmup period has completed, all + * has completed before opening. Once the warm-up period has completed, all * subsequent Overlays will open immediately. When no Overlays are opened, - * a cooldown period of 1000ms will begin. Once the cooldown has completed, + * a cool-down period of 1000ms will begin. Once the cool-down has completed, * the next Overlay to be opened will be subject to the warm-up period if * provided that option. + * + * This behavior helps to manage the performance and user experience by + * preventing multiple overlays from opening simultaneously and ensuring + * a smooth transition between opening and closing overlays. + * + * @type {boolean} + * @default false */ @property({ type: Boolean }) override get delayed(): boolean { @@ -96,6 +103,10 @@ export class Overlay extends OverlayFeatures { private _delayed = false; + /** + * A reference to the dialog element within the overlay. + * This element is expected to have `showPopover` and `hidePopover` methods. + */ @query('.dialog') override dialogEl!: HTMLDialogElement & { showPopover(): void; @@ -103,7 +114,14 @@ export class Overlay extends OverlayFeatures { }; /** - * Whether the overlay is currently functional or not + * Indicates whether the overlay is currently functional or not. + * + * When set to `true`, the overlay is disabled, and any active strategy is aborted. + * The overlay will also close if it is currently open. When set to `false`, the + * overlay will re-bind events and re-open if it was previously open. + * + * @type {boolean} + * @default false */ @property({ type: Boolean }) override get disabled(): boolean { @@ -113,10 +131,12 @@ export class Overlay extends OverlayFeatures { override set disabled(disabled: boolean) { this._disabled = disabled; if (disabled) { + // Abort any active strategy and close the overlay if it is currently open this.strategy?.abort(); this.wasOpen = this.open; this.open = false; } else { + // Re-bind events and re-open the overlay if it was previously open this.bindEvents(); this.open = this.open || this.wasOpen; this.wasOpen = false; @@ -125,14 +145,26 @@ export class Overlay extends OverlayFeatures { private _disabled = false; + /** + * A query to gather all elements slotted into the default slot, excluding elements + * with the slot name "longpress-describedby-descriptor". + */ @queryAssignedElements({ flatten: true, - selector: ':not([slot="longpress-describedby-descriptor"], slot)', // gather only elements slotted into the default slot + selector: ':not([slot="longpress-describedby-descriptor"], slot)', }) override elements!: OpenableElement[]; + /** + * A reference to the parent overlay that should be force-closed, if any. + */ public parentOverlayToForceClose?: Overlay; + /** + * Determines if the overlay has a non-virtual trigger element. + * + * @returns {boolean} `true` if the trigger element is not a virtual trigger, otherwise `false`. + */ private get hasNonVirtualTrigger(): boolean { return ( !!this.triggerElement && @@ -141,15 +173,27 @@ export class Overlay extends OverlayFeatures { } /** - * The `offset` property accepts either a single number, to - * define the offset of the Overlay along the main axis from - * the trigger, or 2-tuple, to define the offset along the - * main axis and the cross axis. This option has no effect - * when there is no trigger element. + * The `offset` property accepts either a single number to define the offset of the + * Overlay along the main axis from the trigger, or a 2-tuple to define the offset + * along both the main axis and the cross axis. This option has no effect when there + * is no trigger element. + * + * @type {number | [number, number]} + * @default 0 */ @property({ type: Number }) override offset: number | [number, number] = 0; + /** + * Provides an instance of the `PlacementController` for managing the positioning + * of the overlay relative to its trigger element. + * + * If the `PlacementController` instance does not already exist, it is created and + * assigned to the `_placementController` property. + * + * @protected + * @returns {PlacementController} The `PlacementController` instance. + */ protected override get placementController(): PlacementController { if (!this._placementController) { this._placementController = new PlacementController(this); @@ -158,7 +202,12 @@ export class Overlay extends OverlayFeatures { } /** - * Whether the Overlay is projected onto the "top layer" or not. + * Indicates whether the Overlay is projected onto the "top layer" or not. + * + * When set to `true`, the overlay is open and visible. When set to `false`, the overlay is closed and hidden. + * + * @type {boolean} + * @default false */ @property({ type: Boolean, reflect: true }) override get open(): boolean { @@ -166,18 +215,28 @@ export class Overlay extends OverlayFeatures { } override set open(open: boolean) { - // Don't respond when disabled. + // Don't respond if the overlay is disabled. if (open && this.disabled) return; - // Don't respond when state not dirty + + // Don't respond if the state is not changing. if (open === this.open) return; - // Don't respond when you're in the shadow on a longpress - // Shadow occurs when the first "click" would normally close the popover + + // Don't respond if the overlay is in the shadow state during a longpress. + // The shadow state occurs when the first "click" would normally close the popover. if (this.strategy?.activelyOpening && !open) return; + + // Update the internal _open property. this._open = open; + + // Increment the open count if the overlay is opening. if (this.open) { Overlay.openCount += 1; } + + // Request an update to re-render the component if necessary. this.requestUpdate('open', !this.open); + + // Request slottable content if the overlay is opening. if (this.open) { this.requestSlottable(); } @@ -185,11 +244,19 @@ export class Overlay extends OverlayFeatures { private _open = false; + /** + * Tracks the number of overlays that have been opened. + * + * This static property is used to manage the stacking context of multiple overlays. + * + * @type {number} + * @default 1 + */ static openCount = 1; /** - * Instruct the Overlay where to place itself in - * relationship to the trigger element. + * Instruct the Overlay where to place itself in relationship to the trigger element. + * * @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"} */ @property() @@ -197,7 +264,11 @@ export class Overlay extends OverlayFeatures { /** * The state in which the last `request-slottable` event was dispatched. - * Do not allow overlays from dispatching the same state twice in a row. + * + * This property ensures that overlays do not dispatch the same state twice in a row. + * + * @type {boolean} + * @default false */ private lastRequestSlottableState = false; @@ -206,82 +277,157 @@ export class Overlay extends OverlayFeatures { * to the appropriate value based on the "type" of the overlay * when set to `"auto"`. * + * @type {'true' | 'false' | 'auto'} + * @default 'auto' */ @property({ attribute: 'receives-focus' }) override receivesFocus: 'true' | 'false' | 'auto' = 'auto'; + /** + * A reference to the slot element within the overlay. + * + * This element is used to manage the content slotted into the overlay. + * + * @type {HTMLSlotElement} + */ @query('slot') slotEl!: HTMLSlotElement; + /** + * The current state of the overlay. + * + * This property reflects the current state of the overlay, such as 'opened' or 'closed'. + * When the state changes, it triggers the appropriate actions and updates the component. + * + * @type {OverlayState} + * @default 'closed' + */ @state() override get state(): OverlayState { return this._state; } override set state(state) { + // Do not respond if the state is not changing. if (state === this.state) return; + const oldState = this.state; + this._state = state; + + // Complete the opening strategy if the state is 'opened' or 'closed'. if (this.state === 'opened' || this.state === 'closed') { this.strategy?.shouldCompleteOpen(); } + + // Request an update to re-render the component if necessary. this.requestUpdate('state', oldState); } override _state: OverlayState = 'closed'; + /** + * The interaction strategy for opening the overlay. + * This can be a ClickController, HoverController, or LongpressController. + */ public strategy?: ClickController | HoverController | LongpressController; + /** + * The padding around the tip of the overlay. + * This property defines the padding around the tip of the overlay, which can be used to adjust its positioning. + * + * @type {number} + */ @property({ type: Number, attribute: 'tip-padding' }) tipPadding?: number; /** * An optional ID reference for the trigger element combined with the optional - * interaction (click | hover | longpress) by which the overlay shold open - * the overlay with an `@`: e.g. `trigger@click` opens the overlay when an - * element with the ID "trigger" is clicked. + * interaction (click | hover | longpress) by which the overlay should open. + * The format is `trigger@interaction`, e.g., `trigger@click` opens the overlay + * when an element with the ID "trigger" is clicked. + * + * @type {string} */ @property() trigger?: string; /** * An element reference for the trigger element that the overlay should relate to. + * This property is not reflected as an attribute. + * + * @type {HTMLElement | VirtualTrigger | null} */ @property({ attribute: false }) override triggerElement: HTMLElement | VirtualTrigger | null = null; /** * The specific interaction to listen for on the `triggerElement` to open the overlay. + * This property is not reflected as an attribute. + * + * @type {TriggerInteraction} */ @property({ attribute: false }) triggerInteraction?: TriggerInteraction; /** * Configures the open/close heuristics of the Overlay. + * * @type {"auto" | "hint" | "manual" | "modal" | "page"} + * @default "auto" */ @property() override type: OverlayTypes = 'auto'; + /** + * Tracks whether the overlay was previously open. + * This is used to restore the open state when re-enabling the overlay. + * + * @type {boolean} + * @default false + */ protected wasOpen = false; + /** + * Provides an instance of the `ElementResolutionController` for managing the element + * that the overlay should be associated with. If the instance does not already exist, + * it is created and assigned to the `_elementResolver` property. + * + * @protected + * @returns {ElementResolutionController} The `ElementResolutionController` instance. + */ protected override get elementResolver(): ElementResolutionController { if (!this._elementResolver) { this._elementResolver = new ElementResolutionController(this); } + return this._elementResolver; } + /** + * Determines if the overlay uses a dialog. + * Returns `true` if the overlay type is "modal" or "page". + * + * @private + * @returns {boolean} `true` if the overlay uses a dialog, otherwise `false`. + */ private get usesDialog(): boolean { return this.type === 'modal' || this.type === 'page'; } + /** + * 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. + */ private get popoverValue(): 'auto' | 'manual' | undefined { const hasPopoverAttribute = 'popover' in this; + if (!hasPopoverAttribute) { return undefined; } - /* c8 ignore next 9 */ + switch (this.type) { case 'modal': case 'page': @@ -293,22 +439,44 @@ export class Overlay extends OverlayFeatures { } } - protected get requiresPosition(): boolean { + /** + * Determines if the overlay requires positioning based on its type and state. + * + * @protected + * @returns {boolean} True if the overlay requires positioning, otherwise false. + */ + protected get requiresPositioning(): boolean { // Do not position "page" overlays as they should block the entire UI. if (this.type === 'page' || !this.open) return false; - // Do not position content without a trigger element, what would you position it in relation to? - // Do not automatically position content, unless it is a "hint". + + // Do not position content without a trigger element, as there is nothing to position it relative to. + // Do not automatically position content unless it is a "hint". if (!this.triggerElement || (!this.placement && this.type !== 'hint')) return false; + return true; } + /** + * Manages the positioning of the overlay relative to its trigger element. + * + * This method calculates the necessary parameters for positioning the overlay, + * such as offset, placement, and tip padding, and then delegates the actual + * positioning to the `PlacementController`. + * + * @protected + * @override + */ protected override managePosition(): void { - if (!this.requiresPosition || !this.open) return; + // Do not proceed if positioning is not required or the overlay is not open. + if (!this.requiresPositioning || !this.open) return; const offset = this.offset || 0; + const trigger = this.triggerElement as HTMLElement; + const placement = (this.placement as Placement) || 'right'; + const tipPadding = this.tipPadding; this.placementController.placeOverlay(this.dialogEl, { @@ -320,42 +488,80 @@ export class Overlay extends OverlayFeatures { }); } + /** + * Manages the process of opening the popover. + * + * This method handles the necessary steps to open the popover, including managing delays, + * ensuring the popover is in the DOM, making transitions, and applying focus. + * + * @protected + * @override + * @returns {Promise} A promise that resolves when the popover has been fully opened. + */ protected override async managePopoverOpen(): Promise { + // Call the base class method to handle any initial setup. super.managePopoverOpen(); + const targetOpenState = this.open; - /* c8 ignore next 3 */ + + // Ensure the open state has not changed before proceeding. if (this.open !== targetOpenState) { return; } + + // Manage any delays before opening the popover. await this.manageDelay(targetOpenState); + if (this.open !== targetOpenState) { return; } + + // Ensure the popover is in the DOM before proceeding. await this.ensureOnDOM(targetOpenState); - /* c8 ignore next 3 */ + if (this.open !== targetOpenState) { return; } + + // Make any necessary transitions for opening the popover. const focusEl = await this.makeTransition(targetOpenState); + if (this.open !== targetOpenState) { return; } + + // Apply focus to the appropriate element after opening the popover. await this.applyFocus(targetOpenState, focusEl); } + /** + * Applies focus to the appropriate element after the popover has been opened. + * + * This method handles the focus management for the overlay, ensuring that the correct + * element receives focus based on the overlay's type and state. + * + * @protected + * @override + * @param {boolean} targetOpenState - The target open state of the overlay. + * @param {HTMLElement | null} focusEl - The element to focus after opening the popover. + * @returns {Promise} A promise that resolves when the focus has been applied. + */ protected override async applyFocus( targetOpenState: boolean, focusEl: HTMLElement | null ): Promise { - // Do not move focus when explicitly told not to - // and when the Overlay is a "hint" + // Do not move focus when explicitly told not to or when the overlay is a "hint". if (this.receivesFocus === 'false' || this.type === 'hint') { return; } + // Wait for the next two animation frames to ensure the DOM is updated. await nextFrame(); await nextFrame(); + + // If the open state has changed during the delay, do not proceed. if (targetOpenState === this.open && !this.open) { + // If the overlay is closing and the trigger element is still focused, return focus to the trigger element. if ( this.hasNonVirtualTrigger && this.contains((this.getRootNode() as Document).activeElement) @@ -364,22 +570,41 @@ export class Overlay extends OverlayFeatures { } return; } + + // Apply focus to the specified focus element. focusEl?.focus(); } + /** + * Returns focus to the trigger element if the overlay is closed. + * + * This method ensures that focus is returned to the trigger element when the overlay is closed, + * unless the overlay is of type "hint" or the focus is already outside the overlay. + * + * @protected + * @override + */ protected override returnFocus(): void { + // Do not proceed if the overlay is open or if the overlay type is "hint". if (this.open || this.type === 'hint') return; - // If the focus remains inside of the overlay or - // a slotted descendent of the overlay you need to return - // focus back to the trigger. + /** + * Retrieves the ancestors of the currently focused element. + * + * @returns {HTMLElement[]} An array of ancestor elements. + */ const getAncestors = (): HTMLElement[] => { const ancestors: HTMLElement[] = []; + // eslint-disable-next-line @spectrum-web-components/document-active-element let currentNode = document.activeElement; + + // Traverse the shadow DOM to find the active element. while (currentNode?.shadowRoot?.activeElement) { currentNode = currentNode.shadowRoot.activeElement; } + + // Traverse the DOM tree to collect ancestor elements. while (currentNode) { const ancestor = currentNode.assignedSlot || @@ -392,6 +617,8 @@ export class Overlay extends OverlayFeatures { } return ancestors; }; + + // Check if focus should be returned to the trigger element. if ( this.receivesFocus !== 'false' && !!(this.triggerElement as HTMLElement)?.focus && @@ -400,43 +627,73 @@ export class Overlay extends OverlayFeatures { // eslint-disable-next-line @spectrum-web-components/document-active-element document.activeElement === document.body) ) { + // Return focus to the trigger element. (this.triggerElement as HTMLElement).focus(); } } + /** + * Handles the focus out event to close the overlay if the focus moves outside of it. + * + * This method ensures that the overlay is closed when the focus moves to an element + * outside of the overlay, unless the focus is moved to a related element. + * + * @private + * @param {FocusEvent} event - The focus out event. + */ private closeOnFocusOut = (event: FocusEvent): void => { - // If you don't know where the focus went, we can't do anyting here. + // If the related target (newly focused element) is not known, do nothing. if (!event.relatedTarget) { - // this.open = false; return; } + + // Create a custom event to query the relationship of the newly focused element. const relationEvent = new Event('overlay-relation-query', { bubbles: true, composed: true, }); + + // Add an event listener to the related target to handle the custom event. event.relatedTarget.addEventListener( relationEvent.type, (event: Event) => { + // If the newly focused element is not within the overlay, close the overlay. if (!event.composedPath().includes(this)) { this.open = false; } } ); + + // Dispatch the custom event to the related target. event.relatedTarget.dispatchEvent(relationEvent); }; + /** + * Manages the process of opening or closing the overlay. + * + * This method handles the necessary steps to open or close the overlay, including updating the state, + * managing the overlay stack, and handling focus events. + * + * @protected + * @param {boolean} oldOpen - The previous open state of the overlay. + * @returns {Promise} A promise that resolves when the overlay has been fully managed. + */ protected async manageOpen(oldOpen: boolean): Promise { + // Prevent entering the manage workflow if the overlay is not connected to the DOM. // The `.showPopover()` and `.showModal()` events will error on content that is not connected to the DOM. - // Prevent from entering the manage workflow in order to avoid this. if (!this.isConnected && this.open) return; + // Wait for the component to finish updating if it has not already done so. if (!this.hasUpdated) { await this.updateComplete; } if (this.open) { + // Add the overlay to the overlay stack. overlayStack.add(this); + if (this.willPreventClose) { + // Add an event listener to handle the pointerup event and toggle the 'not-immediately-closable' class. document.addEventListener( 'pointerup', () => { @@ -455,21 +712,29 @@ export class Overlay extends OverlayFeatures { } } else { if (oldOpen) { + // Dispose of the overlay if it was previously open. this.dispose(); } + + // Remove the overlay from the overlay stack. overlayStack.remove(this); } + + // Update the state of the overlay based on the open property. if (this.open && this.state !== 'opened') { this.state = 'opening'; } else if (!this.open && this.state !== 'closed') { this.state = 'closing'; } + // Manage the dialog or popover based on the overlay type. if (this.usesDialog) { this.manageDialogOpen(); } else { this.managePopoverOpen(); } + + // Handle focus events for auto type overlays. if (this.type === 'auto') { const listenerRoot = this.getRootNode() as Document; if (this.open) { @@ -488,11 +753,26 @@ export class Overlay extends OverlayFeatures { } } + /** + * Binds event handling strategies to the overlay based on the specified trigger interaction. + * + * This method sets up the appropriate event handling strategy for the overlay, ensuring that + * it responds correctly to user interactions such as clicks, hovers, or long presses. + * + * @protected + */ protected bindEvents(): void { + // Abort any existing strategy to ensure a clean setup. this.strategy?.abort(); this.strategy = undefined; + + // Return early if there is no non-virtual trigger element. if (!this.hasNonVirtualTrigger) return; + + // Return early if no trigger interaction is specified. if (!this.triggerInteraction) return; + + // Set up a new event handling strategy based on the specified trigger interaction. this.strategy = new strategies[this.triggerInteraction]( this.triggerElement as HTMLElement, { @@ -501,12 +781,30 @@ export class Overlay extends OverlayFeatures { ); } + /** + * Handles the `beforetoggle` event to manage the overlay's state. + * + * This method checks the new state of the event and calls `handleBrowserClose` + * if the new state is not 'open'. + * + * @protected + * @param {Event & { newState: string }} event - The `beforetoggle` event with the new state. + */ protected handleBeforetoggle(event: Event & { newState: string }): void { if (event.newState !== 'open') { this.handleBrowserClose(event); } } + /** + * Handles the browser's close event to manage the overlay's state. + * + * This method stops the propagation of the event and closes the overlay if it is not + * actively opening. If the overlay is actively opening, it calls `manuallyKeepOpen`. + * + * @protected + * @param {Event} event - The browser's close event. + */ protected handleBrowserClose(event: Event): void { event.stopPropagation(); if (!this.strategy?.activelyOpening) { @@ -516,57 +814,116 @@ export class Overlay extends OverlayFeatures { this.manuallyKeepOpen(); } + /** + * Manually keeps the overlay open. + * + * This method sets the overlay to open, allows placement updates, and manages the open state. + * + * @public + * @override + */ public override manuallyKeepOpen(): void { this.open = true; this.placementController.allowPlacementUpdate = true; this.manageOpen(false); } + /** + * Handles the `slotchange` event to manage the overlay's state. + * + * This method checks if there are any elements in the slot. If there are no elements, + * it releases the description from the strategy. If there are elements and the trigger + * is non-virtual, it prepares the description for the trigger element. + * + * @protected + */ protected handleSlotchange(): void { if (!this.elements.length) { + // Release the description if there are no elements in the slot. this.strategy?.releaseDescription(); } else if (this.hasNonVirtualTrigger) { + // Prepare the description for the trigger element if it is non-virtual. this.strategy?.prepareDescription( this.triggerElement as HTMLElement ); } } + /** + * Determines whether the overlay should prevent closing. + * + * This method checks the `willPreventClose` flag and resets it to `false`. + * It returns the value of the `willPreventClose` flag. + * + * @public + * @returns {boolean} `true` if the overlay should prevent closing, otherwise `false`. + */ public shouldPreventClose(): boolean { const shouldPreventClose = this.willPreventClose; this.willPreventClose = false; return shouldPreventClose; } + /** + * Requests slottable content for the overlay. + * + * This method dispatches a `SlottableRequestEvent` to request or remove slottable content + * based on the current open state of the overlay. It ensures that the same state is not + * dispatched twice in a row. + * + * @protected + * @override + */ protected override requestSlottable(): void { + // Do not dispatch the same state twice in a row. if (this.lastRequestSlottableState === this.open) { return; } + + // Force a reflow if the overlay is closing. if (!this.open) { document.body.offsetHeight; } + /** * @ignore */ + // Dispatch a custom event to request or remove slottable content based on the open state. this.dispatchEvent( new SlottableRequestEvent( 'overlay-content', this.open ? {} : removeSlottableRequest ) ); + + // Update the last request slottable state. this.lastRequestSlottableState = this.open; } + /** + * Lifecycle method called before the component updates. + * + * This method handles various tasks before the component updates, such as setting an ID, + * managing the open state, resolving the trigger element, and binding events. + * + * @override + * @param {PropertyValues} changes - The properties that have changed. + */ override willUpdate(changes: PropertyValues): void { + // Ensure the component has an ID attribute. if (!this.hasAttribute('id')) { this.setAttribute( 'id', `${this.tagName.toLowerCase()}-${randomID()}` ); } + + // Manage the open state if the 'open' property has changed. if (changes.has('open') && (this.hasUpdated || this.open)) { this.manageOpen(changes.get('open')); } + + // Resolve the trigger element if the 'trigger' property has changed. if (changes.has('trigger')) { const [id, interaction] = this.trigger?.split('@') || []; this.elementResolver.selector = id ? `#${id}` : ''; @@ -576,69 +933,125 @@ export class Overlay extends OverlayFeatures { | 'hover' | undefined; } - // Merge multiple possible calls to `bindEvents()`. + + // Initialize oldTrigger to track the previous trigger element. let oldTrigger: HTMLElement | false | undefined = false; + + // Check if the element resolver has been updated. if (changes.has(elementResolverUpdatedSymbol)) { + // Store the current trigger element. oldTrigger = this.triggerElement as HTMLElement; + // Update the trigger element from the element resolver. this.triggerElement = this.elementResolver.element; } + + // Check if the 'triggerElement' property has changed. if (changes.has('triggerElement')) { + // Store the old trigger element. oldTrigger = changes.get('triggerElement'); } + + // If the trigger element has changed, bind the new events. if (oldTrigger !== false) { this.bindEvents(); } } + /** + * Lifecycle method called after the component updates. + * + * This method handles various tasks after the component updates, such as updating the placement + * attribute, resetting the overlay position, and clearing the overlay position based on the state. + * + * @override + * @param {PropertyValues} changes - The properties that have changed. + */ protected override updated(changes: PropertyValues): void { + // Call the base class method to handle any initial setup. super.updated(changes); + + // Check if the 'placement' property has changed. if (changes.has('placement')) { if (this.placement) { + // Set the 'actual-placement' attribute on the dialog element. this.dialogEl.setAttribute('actual-placement', this.placement); } else { + // Remove the 'actual-placement' attribute from the dialog element. this.dialogEl.removeAttribute('actual-placement'); } + + // If the overlay is open and the 'placement' property has changed, reset the overlay position. if (this.open && typeof changes.get('placement') !== 'undefined') { this.placementController.resetOverlayPosition(); } } + + // Check if the 'state' property has changed and the overlay is closed. if ( changes.has('state') && this.state === 'closed' && typeof changes.get('state') !== 'undefined' ) { + // Clear the overlay position. this.placementController.clearOverlayPosition(); } } + /** + * Renders the content of the overlay. + * + * This method returns a template result containing a slot element. The slot element + * listens for the `slotchange` event to manage the overlay's state. + * + * @protected + * @returns {TemplateResult} The template result containing the slot element. + */ protected renderContent(): TemplateResult { return html` `; } + /** + * Generates a style map for the dialog element. + * + * This method returns an object containing CSS custom properties for the dialog element. + * The `--swc-overlay-open-count` custom property is set to the current open count of overlays. + * + * @private + * @returns {StyleInfo} The style map for the dialog element. + */ private get dialogStyleMap(): StyleInfo { return { '--swc-overlay-open-count': Overlay.openCount.toString(), }; } + /** + * Renders the dialog element for the overlay. + * + * This method returns a template result containing a dialog element. The dialog element + * includes various attributes and event listeners to manage the overlay's state and behavior. + * + * @protected + * @returns {TemplateResult} The template result containing the dialog element. + */ protected renderDialog(): TemplateResult { /** - * `--swc-overlay-open-count` is applied to mimic the single stack + * The `--swc-overlay-open-count` custom property is applied to mimic the single stack * nature of the top layer in browsers that do not yet support it. * - * The value should always be the full number of overlays ever opened - * which will be added to `--swc-overlay-z-index-base` which can be - * provided by a consuming developer but defaults to 1000 to beat as - * much stacking as possible durring fallback delivery. - **/ + * The value should always represent the total number of overlays that have ever been opened. + * This value will be added to the `--swc-overlay-z-index-base` custom property, which can be + * provided by a consuming developer. By default, `--swc-overlay-z-index-base` is set to 1000 + * to ensure that the overlay stacks above most other elements during fallback delivery. + */ return html` { this.open = false; }); + + // Bind events if the component has already updated. if (this.hasUpdated) { this.bindEvents(); } } + /** + * Lifecycle method called when the component is removed from the DOM. + * + * This method releases the description from the strategy and updates the 'open' property. + * + * @override + */ override disconnectedCallback(): void { + // Release the description from the strategy. this.strategy?.releaseDescription(); + // Update the 'open' property to false. this.open = false; super.disconnectedCallback(); } diff --git a/packages/overlay/src/PlacementController.ts b/packages/overlay/src/PlacementController.ts index ddf3a3900a..a55b0ea055 100644 --- a/packages/overlay/src/PlacementController.ts +++ b/packages/overlay/src/PlacementController.ts @@ -41,17 +41,31 @@ type OverlayOptionsV1 = { type?: 'modal' | 'page' | 'hint' | 'auto' | 'manual'; }; +/** + * Rounds a number by the device pixel ratio (DPR). + * + * @param {number} [num] - The number to round. + * @returns {number} The rounded number. + */ function roundByDPR(num?: number): number { if (typeof num === 'undefined') return 0; const dpr = window.devicePixelRatio || 1; - return Math.round(num * dpr) / dpr ?? -10000; + return Math.round(num * dpr) / dpr; } +// Minimum distance required between the overlay and the edge of the container. // See: https://spectrum.adobe.com/page/popover/#Container-padding const REQUIRED_DISTANCE_TO_EDGE = 8; +// Minimum height for the overlay. // See: https://github.com/adobe/spectrum-web-components/issues/910 const MIN_OVERLAY_HEIGHT = 100; +/** + * Gets fallback placements for the overlay based on the initial placement. + * + * @param {Placement} placement - The initial placement of the overlay. + * @returns {Placement[]} An array of fallback placements. + */ const getFallbackPlacements = (placement: Placement): Placement[] => { const fallbacks: Record = { left: ['right', 'bottom', 'top'], @@ -70,23 +84,76 @@ const getFallbackPlacements = (placement: Placement): Placement[] => { return fallbacks[placement] ?? [placement]; }; +/** + * Symbol used to indicate that the placement has been updated. + */ export const placementUpdatedSymbol = Symbol('placement updated'); +/** + * Controller for managing the placement of an overlay. + * + * This class implements the ReactiveController interface and provides methods + * for managing the positioning and constraints of an overlay element. + */ export class PlacementController implements ReactiveController { + /** + * Function to clean up resources when the controller is no longer needed. + * + * @private + */ private cleanup?: () => void; + /** + * Initial height of the overlay. + * + * @type {number} + */ initialHeight?: number; + /** + * Indicates whether the overlay is constrained by available space. + * + * @type {boolean} + */ isConstrained?: boolean; + /** + * The host element that uses this controller. + * + * @private + * @type {ReactiveElement & { elements: OpenableElement[] }} + */ private host!: ReactiveElement & { elements: OpenableElement[] }; + /** + * Options for configuring the overlay placement. + * + * @private + * @type {OverlayOptionsV1} + */ private options!: OverlayOptionsV1; + /** + * A WeakMap to store the original placements of overlay elements. + * + * @private + * @type {WeakMap} + */ private originalPlacements = new WeakMap(); + /** + * The target element for the overlay. + * + * @private + * @type {HTMLElement} + */ private target!: HTMLElement; + /** + * Creates an instance of the PlacementController. + * + * @param {ReactiveElement & { elements: OpenableElement[] }} host - The host element that uses this controller. + */ constructor(host: ReactiveElement & { elements: OpenableElement[] }) { this.host = host; // Add the controller after the MutationObserver has been created in preparation @@ -94,14 +161,26 @@ export class PlacementController implements ReactiveController { this.host.addController(this); } + /** + * Places the overlay relative to the target element. + * + * This method sets up the necessary configurations and event listeners to manage the + * positioning and constraints of the overlay element. + * + * @param {HTMLElement} [target=this.target] - The target element for the overlay. + * @param {OverlayOptionsV1} [options=this.options] - The options for configuring the overlay placement. + * @returns {Promise} A promise that resolves when the overlay has been placed. + */ public async placeOverlay( target: HTMLElement = this.target, options: OverlayOptionsV1 = this.options ): Promise { + // Set the target and options for the overlay. this.target = target; this.options = options; if (!target || !options) return; + // Set up auto-update for ancestor resize events. const cleanupAncestorResize = autoUpdate( options.trigger, target, @@ -112,6 +191,8 @@ export class PlacementController implements ReactiveController { layoutShift: false, } ); + + // Set up auto-update for element resize events. const cleanupElementResize = autoUpdate( options.trigger, target, @@ -120,15 +201,19 @@ export class PlacementController implements ReactiveController { ancestorScroll: false, } ); + + // Define the cleanup function to remove event listeners and reset placements. this.cleanup = () => { this.host.elements?.forEach((element) => { element.addEventListener( 'sp-closed', () => { const placement = this.originalPlacements.get(element); + if (placement) { element.setAttribute('placement', placement); } + this.originalPlacements.delete(element); }, { once: true } @@ -139,28 +224,61 @@ export class PlacementController implements ReactiveController { }; } - allowPlacementUpdate = false; - + /** + * Flag to allow or disallow placement updates. + * + * @type {boolean} + */ + public allowPlacementUpdate = false; + + /** + * Closes the overlay if an ancestor element is updated. + * + * This method checks if placement updates are allowed and if the overlay type is not 'modal'. + * If these conditions are met and a cleanup function is defined, it dispatches a 'close' event + * on the target element to close the overlay. + */ closeForAncestorUpdate = (): void => { if ( !this.allowPlacementUpdate && this.options.type !== 'modal' && this.cleanup ) { + // Dispatch a 'close' event to close the overlay. this.target.dispatchEvent(new Event('close', { bubbles: true })); } + + // Reset the flag to disallow placement updates. this.allowPlacementUpdate = false; }; - updatePlacement = (): void => { + /** + * Updates the placement of the overlay. + * + * This method calls the computePlacement method to recalculate the overlay's position. + * + * @private + */ + private updatePlacement = (): void => { this.computePlacement(); }; + /** + * Computes the placement of the overlay relative to the target element. + * + * This method calculates the necessary positioning and constraints for the overlay element + * using various middleware functions. It updates the overlay's style and attributes based + * on the computed position. + * + * @returns {Promise} A promise that resolves when the placement has been computed. + */ async computePlacement(): Promise { const { options, target } = this; + // Wait for document fonts to be ready before computing placement. await (document.fonts ? document.fonts.ready : Promise.resolve()); + // Determine the flip middleware based on the type of trigger element. const flipMiddleware = !(options.trigger instanceof HTMLElement) ? flip({ padding: REQUIRED_DISTANCE_TO_EDGE, @@ -168,14 +286,17 @@ export class PlacementController implements ReactiveController { }) : flip(); + // Extract main axis and cross axis offsets from options. const [mainAxis = 0, crossAxis = 0] = Array.isArray(options?.offset) ? options.offset : [options.offset, 0]; + // Find the tip element within the host elements. const tipElement = this.host.elements.find( (el) => el.tipElement )?.tipElement; + // Define middleware functions for positioning and constraints. const middleware = [ offset({ mainAxis, @@ -220,6 +341,8 @@ export class PlacementController implements ReactiveController { ] : []), ]; + + // Compute the position of the overlay using the defined middleware. const { x, y, placement, middlewareData } = await computePosition( options.trigger, target, @@ -229,13 +352,18 @@ export class PlacementController implements ReactiveController { strategy: 'fixed', } ); + + // Update the overlay's style with the computed position. Object.assign(target.style, { top: '0px', left: '0px', translate: `${roundByDPR(x)}px ${roundByDPR(y)}px`, }); + // Set the 'actual-placement' attribute on the target element. target.setAttribute('actual-placement', placement); + + // Update the placement attribute for each host element. this.host.elements?.forEach((element) => { if (!this.originalPlacements.has(element)) { this.originalPlacements.set( @@ -246,6 +374,7 @@ export class PlacementController implements ReactiveController { element.setAttribute('placement', placement); }); + // Update the tip element's style with the computed arrow position. if (tipElement && middlewareData.arrow) { const { x: arrowX, y: arrowY } = middlewareData.arrow; @@ -265,25 +394,45 @@ export class PlacementController implements ReactiveController { } } + /** + * Clears the overlay's position styles. + * + * This method removes the max-height and max-width styles from the target element, + * and resets the initial height and constrained state of the overlay. + */ public clearOverlayPosition(): void { if (!this.target) { return; } + // Remove max-height and max-width styles from the target element. this.target.style.removeProperty('max-height'); this.target.style.removeProperty('max-width'); + // Reset the initial height and constrained state. this.initialHeight = undefined; this.isConstrained = false; } + /** + * Resets the overlay's position. + * + * This method clears the overlay's position, forces a reflow, and recomputes the placement. + */ public resetOverlayPosition = (): void => { if (!this.target || !this.options) return; + // Clear the overlay's position. this.clearOverlayPosition(); - // force paint + // Force a reflow. this.host.offsetHeight; + // Recompute the placement. this.computePlacement(); }; + /** + * Lifecycle method called when the host element is connected to the DOM. + * + * This method sets up an event listener to reset the overlay's position when the 'sp-update-overlays' event is dispatched. + */ hostConnected(): void { document.addEventListener( 'sp-update-overlays', @@ -291,16 +440,29 @@ export class PlacementController implements ReactiveController { ); } + /** + * Lifecycle method called when the host element is updated. + * + * This method cleans up resources if the overlay is not open. + */ hostUpdated(): void { if (!(this.host as Overlay).open) { + // Clean up resources if the overlay is not open. this.cleanup?.(); this.cleanup = undefined; } } + /** + * Lifecycle method called when the host element is disconnected from the DOM. + * + * This method removes the event listener and cleans up resources. + */ hostDisconnected(): void { + // Clean up resources. this.cleanup?.(); this.cleanup = undefined; + // Remove the event listener. document.removeEventListener( 'sp-update-overlays', this.resetOverlayPosition diff --git a/packages/overlay/src/overlay.css b/packages/overlay/src/overlay.css index 0fd608f2e3..b3423e4792 100644 --- a/packages/overlay/src/overlay.css +++ b/packages/overlay/src/overlay.css @@ -50,6 +50,7 @@ governing permissions and limitations under the License. inset: auto; top: 0; left: 0; + display: flex; --sp-overlay-open: true; }