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);
}