diff --git a/src/lib/backdrop/backdrop-constants.ts b/src/lib/backdrop/backdrop-constants.ts index 9ffada1cbc..1f08b42ec2 100644 --- a/src/lib/backdrop/backdrop-constants.ts +++ b/src/lib/backdrop/backdrop-constants.ts @@ -20,6 +20,7 @@ const selectors = { ROOT: '.forge-backdrop' }; +/** @deprecated - These are internal constants that will be removed/moved in the future. Please avoid using them. */ export const BACKDROP_CONSTANTS = { elementName, observedAttributes, diff --git a/src/lib/backdrop/backdrop.html b/src/lib/backdrop/backdrop.html deleted file mode 100644 index a3a11295ad..0000000000 --- a/src/lib/backdrop/backdrop.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/src/lib/backdrop/backdrop.scss b/src/lib/backdrop/backdrop.scss index 9041f211bd..936555409b 100644 --- a/src/lib/backdrop/backdrop.scss +++ b/src/lib/backdrop/backdrop.scss @@ -42,13 +42,17 @@ :host([visible]) { .forge-backdrop { @include visible; + } +} - &.entering { - @include entering; - } +:host(:state(entering)) { + .forge-backdrop { + @include entering; + } +} - &.exiting { - @include exiting; - } +:host(:state(exiting)) { + .forge-backdrop { + @include exiting; } } diff --git a/src/lib/backdrop/backdrop.test.ts b/src/lib/backdrop/backdrop.test.ts index 0b091b1c60..6d66523a27 100644 --- a/src/lib/backdrop/backdrop.test.ts +++ b/src/lib/backdrop/backdrop.test.ts @@ -1,8 +1,8 @@ import { expect } from '@esm-bundle/chai'; -import { fixture, html } from '@open-wc/testing'; +import { elementUpdated, fixture, html } from '@open-wc/testing'; import { getShadowElement } from '@tylertech/forge-core'; import { sendMouse } from '@web/test-runner-commands'; -import { BACKDROP_CONSTANTS, IBackdropComponent } from '../backdrop'; +import { IBackdropComponent } from '../backdrop'; import { task } from '../core/utils/utils'; import './backdrop'; @@ -30,6 +30,7 @@ describe('Backdrop', () => { const harness = await createFixture(); harness.backdropElement.visible = true; + await elementUpdated(harness.backdropElement); await harness.enterAnimation(); expect(harness.isVisible).to.be.true; @@ -38,7 +39,8 @@ describe('Backdrop', () => { it('should show when visible attribute is set', async () => { const harness = await createFixture(); - harness.backdropElement.setAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE, ''); + harness.backdropElement.setAttribute('visible', ''); + await elementUpdated(harness.backdropElement); await harness.enterAnimation(); expect(harness.isVisible).to.be.true; @@ -47,7 +49,8 @@ describe('Backdrop', () => { it('should hide when visible attribute is removed', async () => { const harness = await createFixture({ visible: true }); - harness.backdropElement.removeAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE); + harness.backdropElement.removeAttribute('visible'); + await elementUpdated(harness.backdropElement); await harness.exitAnimation(); expect(harness.isVisible).to.be.false; @@ -61,7 +64,7 @@ describe('Backdrop', () => { await harness.fadeIn(); expect(harness.isVisible).to.be.true; - expect(harness.rootElement.classList.contains(BACKDROP_CONSTANTS.classes.EXITING)).to.be.false; + expect(harness.backdropElement.matches(':state(exiting)')).to.be.false; }); it('should fade out', async () => { @@ -73,13 +76,14 @@ describe('Backdrop', () => { await harness.exitAnimation(); expect(harness.isVisible).to.be.false; - expect(harness.rootElement.classList.contains(BACKDROP_CONSTANTS.classes.EXITING)).to.be.false; + expect(harness.backdropElement.matches(':state(exiting)')).to.be.false; }); it('should show immediately when calling show() method', async () => { const harness = await createFixture(); harness.backdropElement.show(); + await elementUpdated(harness.backdropElement); await harness.enterAnimation(); expect(harness.isVisible).to.be.true; @@ -89,6 +93,7 @@ describe('Backdrop', () => { const harness = await createFixture({ visible: true }); harness.backdropElement.hide(); + await elementUpdated(harness.backdropElement); await harness.exitAnimation(); expect(harness.isVisible).to.be.false; @@ -98,21 +103,23 @@ describe('Backdrop', () => { const harness = await createFixture({ fixed: true }); expect(harness.backdropElement.fixed).to.be.true; - expect(harness.backdropElement.hasAttribute(BACKDROP_CONSTANTS.attributes.FIXED)).to.be.true; + expect(harness.backdropElement.hasAttribute('fixed')).to.be.true; }); it('should toggle fixed attribute', async () => { const harness = await createFixture(); harness.backdropElement.fixed = true; + await elementUpdated(harness.backdropElement); expect(harness.backdropElement.fixed).to.be.true; - expect(harness.backdropElement.hasAttribute(BACKDROP_CONSTANTS.attributes.FIXED)).to.be.true; + expect(harness.backdropElement.hasAttribute('fixed')).to.be.true; harness.backdropElement.fixed = false; + await elementUpdated(harness.backdropElement); expect(harness.backdropElement.fixed).to.be.false; - expect(harness.backdropElement.hasAttribute(BACKDROP_CONSTANTS.attributes.FIXED)).to.be.false; + expect(harness.backdropElement.hasAttribute('fixed')).to.be.false; }); }); @@ -120,25 +127,19 @@ class BackdropHarness { constructor(public backdropElement: IBackdropComponent) {} public get rootElement(): HTMLElement { - return getShadowElement(this.backdropElement, BACKDROP_CONSTANTS.selectors.ROOT); + return getShadowElement(this.backdropElement, '.forge-backdrop'); } public get isVisible(): boolean { - return ( - this.backdropElement.visible && - this.backdropElement.hasAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE) && - getComputedStyle(this.rootElement).opacity === '0.54' - ); + return this.backdropElement.visible && this.backdropElement.hasAttribute('visible') && getComputedStyle(this.rootElement).opacity === '0.54'; } - public fadeIn(): Promise { - this.backdropElement.fadeIn(); - return this.enterAnimation(); + public async fadeIn(): Promise { + await this.backdropElement.fadeIn(); } - public fadeOut(): Promise { - this.backdropElement.fadeOut(); - return this.exitAnimation(); + public async fadeOut(): Promise { + await this.backdropElement.fadeOut(); } public async click(): Promise { diff --git a/src/lib/backdrop/backdrop.ts b/src/lib/backdrop/backdrop.ts index 161925c0cc..4a3677c2b9 100644 --- a/src/lib/backdrop/backdrop.ts +++ b/src/lib/backdrop/backdrop.ts @@ -1,11 +1,12 @@ -import { attachShadowTemplate, coerceBoolean, customElement, getShadowElement } from '@tylertech/forge-core'; -import { BaseComponent, IBaseComponent } from '../core/base/base-component'; -import { BACKDROP_CONSTANTS } from './backdrop-constants'; +import { html, PropertyValues, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { CUSTOM_ELEMENT_NAME_PROPERTY } from '@tylertech/forge-core'; +import { BaseLitElement } from '../core/base/base-lit-element'; +import { toggleState } from '../core/utils/utils'; -import template from './backdrop.html'; import styles from './backdrop.scss'; -export interface IBackdropComponent extends IBaseComponent { +export interface IBackdropComponent extends BaseLitElement { visible: boolean; fixed: boolean; show(): void; @@ -20,17 +21,16 @@ declare global { } } +export const BACKDROP_TAG_NAME: keyof HTMLElementTagNameMap = 'forge-backdrop'; +const STATE_VISIBLE = 'visible'; +const STATE_ENTERING = 'entering'; +const STATE_EXITING = 'exiting'; + /** * @tag forge-backdrop * * @summary Backdrops provide a semi-transparent overlay behind modal content like dialogs and drawers. * - * @property {boolean} [visible=false] - Whether the backdrop is visible. - * @property {boolean} [fixed=false] - Whether the backdrop uses "fixed" or "relative" positioning. - * - * @attribute {boolean} [visible=false] - Whether the backdrop is visible. - * @attribute {boolean} [fixed=false] - Whether the backdrop uses "fixed" or "relative" positioning. - * * @cssproperty --forge-backdrop-background - The backdrop background color. * @cssproperty --forge-backdrop-opacity - The backdrop opacity. * @cssproperty --forge-backdrop-z-index - The backdrop z-index. @@ -40,134 +40,145 @@ declare global { * @cssproperty --forge-backdrop-exit-animation-easing - The animation easing for the exit animation. * * @csspart root - The root element of the backdrop. + * + * @state visible - whether or not the backdrop is visible + * @state entering - applied during enter animation + * @state exiting - applied during exit animation */ -@customElement({ - name: BACKDROP_CONSTANTS.elementName -}) -export class BackdropComponent extends BaseComponent { - public static get observedAttributes(): string[] { - return Object.values(BACKDROP_CONSTANTS.observedAttributes); - } - - private _visible = false; - private _fixed = false; - private _rootElement: HTMLElement; - private _animationController: AbortController | undefined; +@customElement(BACKDROP_TAG_NAME) +export class BackdropComponent extends BaseLitElement implements IBackdropComponent { + public static styles = unsafeCSS(styles); + + /** @deprecated Used for compatibility with legacy Forge @customElement decorator. */ + public static [CUSTOM_ELEMENT_NAME_PROPERTY] = BACKDROP_TAG_NAME; + + /** + * Controls whether or not the backdrop is visible. + * @default false + * @attribute + */ + @property({ type: Boolean }) + public visible = false; + + /** + * Controls whether the backdrop uses "fixed" or "relative" positioning. + * @default false + * @attribute + */ + @property({ type: Boolean, reflect: true }) + public fixed = false; + + #internals: ElementInternals; + #animationController?: AbortController; constructor() { super(); - attachShadowTemplate(this, template, styles); - this._rootElement = getShadowElement(this, BACKDROP_CONSTANTS.selectors.ROOT); + this.#internals = this.attachInternals(); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#cancelAnimation(); } - public disconnectedCallback(): void { - if (this._animationController) { - this._animationController.abort(); - this._animationController = undefined; + public override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has(STATE_VISIBLE)) { + // Default behavior: animate if component has rendered at least once + if (this.hasUpdated) { + void this.#animateVisibility(this.visible); + } else { + // No animation on initial render + this.toggleAttribute(STATE_VISIBLE, this.visible); + } } + } - this.classList.remove(BACKDROP_CONSTANTS.classes.ENTERING, BACKDROP_CONSTANTS.classes.EXITING); + /** Immediately shows without animation. */ + public show(): void { + this.#cancelAnimation(); + this.visible = true; + this.toggleAttribute(STATE_VISIBLE, true); } - public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { - switch (name) { - case BACKDROP_CONSTANTS.attributes.VISIBLE: - this.visible = coerceBoolean(newValue); - break; - case BACKDROP_CONSTANTS.attributes.FIXED: - this.fixed = coerceBoolean(newValue); - break; - } + /** Immediately hides without animation. */ + public hide(): void { + this.#cancelAnimation(); + this.visible = false; + this.toggleAttribute(STATE_VISIBLE, false); } - private async _applyVisibility(visible: boolean, { animate } = { animate: true }): Promise { - if (this._visible === visible) { - return; - } + /** Shows with enter animation. */ + public async fadeIn(): Promise { + this.visible = true; + await this.#animateVisibility(true); + } - this._visible = visible; + /** Hides with exit animation. */ + public async fadeOut(): Promise { + this.visible = false; + await this.#animateVisibility(false); + } - if (!this.isConnected) { - this.toggleAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE, this._visible); - return Promise.resolve(); - } + public override render(): TemplateResult { + return html`
`; + } - if (!animate) { - this.toggleAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE, this._visible); + async #animateVisibility(visible: boolean): Promise { + if (!this.isConnected) { return; } - const isVisible = this._visible; - const className = isVisible ? BACKDROP_CONSTANTS.classes.ENTERING : BACKDROP_CONSTANTS.classes.EXITING; + this.#cancelAnimation(); - if (this._animationController) { - this._animationController.abort(); - this._rootElement.classList.remove(BACKDROP_CONSTANTS.classes.ENTERING, BACKDROP_CONSTANTS.classes.EXITING); + // Set visible attribute for enter animations before starting + if (visible) { + this.toggleAttribute(STATE_VISIBLE, true); } - this._animationController = new AbortController(); - - const animationComplete = new Promise(resolve => { - this._rootElement.addEventListener( - 'animationend', - () => { - if (!isVisible) { - this.removeAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE); - } - this._rootElement.classList.remove(className); - resolve(); - }, - { once: true, signal: this._animationController?.signal } - ); - }); - - if (isVisible) { - this.setAttribute(BACKDROP_CONSTANTS.attributes.VISIBLE, ''); - } + // Set animation state + toggleState(this.#internals, STATE_ENTERING, visible); + toggleState(this.#internals, STATE_EXITING, !visible); - this._rootElement.classList.add(className); + await this.updateComplete; - return animationComplete; - } - - /** Immediately shows the backdrop by setting the `visibility` to `true` without animations. */ - public show(): void { - this._applyVisibility(true, { animate: false }); - } - - /** Immediately hides the backdrop by setting the `visibility` to `false` without animations. */ - public hide(): void { - this._applyVisibility(false, { animate: false }); - } + // Get the root element from shadow DOM to listen for animation events + const rootElement = this.shadowRoot?.querySelector(`.${BACKDROP_TAG_NAME}`) as HTMLElement; + if (!rootElement) { + return; + } - /** Sets the `visibility` to `true` and animates in. */ - public fadeIn(): Promise { - return this._applyVisibility(true); - } + // Wait for CSS animation to complete + this.#animationController = new AbortController(); + const signal = this.#animationController.signal; + + try { + await new Promise((resolve, reject) => { + rootElement.addEventListener('animationend', () => resolve(), { once: true, signal }); + signal.addEventListener('abort', () => reject(new Error('Animation cancelled'))); + }); + } catch { + // Animation was cancelled + return; + } - /** Sets the `visibility` to `false` and animates out. */ - public fadeOut(): Promise { - return this._applyVisibility(false); - } + // Clean up animation states + toggleState(this.#internals, STATE_ENTERING, false); + toggleState(this.#internals, STATE_EXITING, false); - public get visible(): boolean { - return this._visible; - } - public set visible(value: boolean) { - value = Boolean(value); - if (this._visible !== value) { - this._applyVisibility(value); + // Remove visible attribute for exit animations after completion + if (!visible) { + this.toggleAttribute(STATE_VISIBLE, false); } - } - public get fixed(): boolean { - return this._fixed; + this.#animationController = undefined; } - public set fixed(value: boolean) { - value = Boolean(value); - if (this._fixed !== value) { - this._fixed = value; - this.toggleAttribute(BACKDROP_CONSTANTS.attributes.FIXED, this._fixed); + + #cancelAnimation(): void { + if (this.#animationController) { + this.#animationController.abort(); + this.#animationController = undefined; } + toggleState(this.#internals, STATE_ENTERING, false); + toggleState(this.#internals, STATE_EXITING, false); } } diff --git a/src/lib/backdrop/index.ts b/src/lib/backdrop/index.ts index 3c1259b80c..b2e5a59794 100644 --- a/src/lib/backdrop/index.ts +++ b/src/lib/backdrop/index.ts @@ -1,9 +1,9 @@ -import { defineCustomElement } from '@tylertech/forge-core'; -import { BackdropComponent } from './backdrop'; +import { tryDefine } from '@tylertech/forge-core'; +import { BACKDROP_TAG_NAME, BackdropComponent } from './backdrop'; export * from './backdrop-constants'; export * from './backdrop'; export function defineBackdropComponent(): void { - defineCustomElement(BackdropComponent); + tryDefine(BACKDROP_TAG_NAME, BackdropComponent); }