diff --git a/.vdiff.json b/.vdiff.json index 4adefefd9d1..739d6b4f9d9 100644 --- a/.vdiff.json +++ b/.vdiff.json @@ -7,7 +7,7 @@ ], "system": { "platform": "linux", - "release": "6.17.0-1018-azure", + "release": "6.17.0-1020-azure", "arch": "x64" } } diff --git a/components/backdrop/backdrop-dirty-overlay.js b/components/backdrop/backdrop-dirty-overlay.js deleted file mode 100644 index 9cc8ca4cd47..00000000000 --- a/components/backdrop/backdrop-dirty-overlay.js +++ /dev/null @@ -1,73 +0,0 @@ -import '../button/button-subtle.js'; -import { css, html, LitElement } from 'lit'; -import { bodyCompactStyles } from '../typography/styles.js'; -import { getFocusRingStyles } from '../../helpers/focus.js'; - -const overlayStyles = css` - .action-slot::slotted(*) { - display: inline; - } - :host { - align-items: center; - border: 1px solid var(--d2l-color-mica); - border-radius: 0.3rem; - column-gap: 0.75rem; - display: block; - display: flex; - flex-wrap: wrap; - padding: 1.2rem 1.5rem; - } - - .d2l-backdrop-dirty-overlay { - --d2l-focus-ring-offset: 3px; - border-radius: 0.3rem; - margin: 0; - } - - .d2l-backdrop-dirty-overlay-action { - vertical-align: top; - } -`; - -/** - * The `d2l-backdrop-dirty-overlay` component is used to render a dialog with text and an action over another element that needs to be refreshed after user input. - */ -class BackdropDirtyOverlay extends LitElement { - - static properties = { - /** - * The text displayed on the dirty state overlay. - * @type {string} - */ - description: { type: String, required: true }, - - /** - * The text displayed on the button of the dirty state overlay when the 'dirty' dataState is set. - * @type {string} - */ - action: { type: String, required: true } - }; - - static styles = [bodyCompactStyles, overlayStyles, getFocusRingStyles('.d2l-backdrop-dirty-overlay')]; - - render() { - return html` -

${this.description}

- - - `; - } - #handleActionClick(e) { - e.stopPropagation(); - - /** Dispatched when the action button on the overlay is clicked */ - this.dispatchEvent(new CustomEvent('d2l-backdrop-dirty-overlay-action', { bubbles: true, composed: true })); - } - -} - -customElements.define('d2l-backdrop-dirty-overlay', BackdropDirtyOverlay); diff --git a/components/backdrop/backdrop-loading.js b/components/backdrop/backdrop-loading.js index c783bfc18c5..7bb1528da9a 100644 --- a/components/backdrop/backdrop-loading.js +++ b/components/backdrop/backdrop-loading.js @@ -1,10 +1,10 @@ +import './backdrop-stale-overlay.js'; import '../colors/colors.js'; import '../loading-spinner/loading-spinner.js'; -import './backdrop-dirty-overlay.js'; -import { css, html, LitElement, nothing } from 'lit'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { freshness, FreshnessMixin } from '../../mixins/freshness/freshness-mixin.js'; import { getComposedChildren, getComposedParent } from '../../helpers/dom.js'; import { LocalizeCoreElement } from '../../helpers/localize-core-element.js'; - import { PropertyRequiredMixin } from '../../mixins/property-required/property-required-mixin.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -12,7 +12,6 @@ const BACKDROP_DELAY_MS = 800; const FADE_DURATION_MS = 500; const SPINNER_DELAY_MS = FADE_DURATION_MS; const LOADING_ANNOUNCEMENT_DELAY = 1000; - const LOADING_SPINNER_SIZE = 50; const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -20,56 +19,31 @@ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; /** * A component for displaying a semi-transparent backdrop and a loading spinner over the containing element */ -class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitElement)) { +class BackdropLoading extends PropertyRequiredMixin(FreshnessMixin(LocalizeCoreElement(LitElement))) { static properties = { - /** - * The state of data in the element being overlaid. Set to 'clean' when the data represents the user's latest selections, 'dirty' when the data does not represent the user's latest selections, and 'loading' if the data is being actively refreshed - * @type {'clean'|'dirty'|'loading'} - */ - dataState: { - reflect: true, - type: String - }, /** * Used to identify content that the backdrop should make inert * @type {string} */ for: { type: String, required: true }, - /** - * The text displayed on the dirty state overlay when the 'dirty' dataState is set. - * @type {string} - */ - dirtyText: { - reflect: true, - attribute: 'dirty-text', - type: String - }, - /** - * The text displayed on the button of the dirty state overlay when the 'dirty' dataState is set. - * @type {string} - */ - dirtyButtonText: { - reflect: true, - attribute: 'dirty-button-text', - type: String - }, - _state: { type: String, reflect: true }, - _spinnerTop: { state: true }, - _ariaContent: { state: true } + _ariaContent: { state: true }, + _loadingSpinnerTop: { state: true }, + _staleOverlayTop: { state: true }, + _state: { type: String, reflect: true } }; static styles = [css` - #visible { + .backdrop-container { display: none; } :host([_state="showing"]), :host([_state="shown"]), :host([_state="hiding"]), - :host([_state="showing"]) #visible, - :host([_state="shown"]) #visible, - :host([_state="hiding"]) #visible { + :host([_state="showing"]) .backdrop-container, + :host([_state="shown"]) .backdrop-container, + :host([_state="hiding"]) .backdrop-container { display: flex; inset: 0; justify-content: center; @@ -99,13 +73,13 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme opacity: 1; transition: opacity ${FADE_DURATION_MS}ms ease-in ${SPINNER_DELAY_MS}ms; } - :host([_state="shown"][dataState="dirty"]) d2l-loading-spinner, + :host([_state="shown"][freshness="${unsafeCSS(freshness.stale)}"]) d2l-loading-spinner, :host([_state="hiding"]) d2l-loading-spinner { opacity: 0; transition: opacity ${FADE_DURATION_MS}ms ease-out; } - d2l-backdrop-dirty-overlay { + d2l-backdrop-stale-overlay { background-color: var(--d2l-theme-backdrop-dialog-color); height: fit-content; justify-content: center; @@ -114,12 +88,12 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme top: 0; z-index: 1000; } - :host([_state="shown"]) d2l-backdrop-dirty-overlay { + :host([_state="shown"]) d2l-backdrop-stale-overlay { opacity: 1; transition: opacity ${FADE_DURATION_MS}ms ease-in; } - :host([_state="shown"][dataState="loading"]) d2l-backdrop-dirty-overlay, - :host([_state="hiding"]) d2l-backdrop-dirty-overlay { + :host([_state="shown"][freshness="${unsafeCSS(freshness.loading)}"]) d2l-backdrop-stale-overlay, + :host([_state="hiding"]) d2l-backdrop-stale-overlay { opacity: 0; transition: opacity ${FADE_DURATION_MS}ms ease-out; } @@ -131,46 +105,46 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme constructor() { super(); - this.dataState = 'clean'; - this._state = 'hidden'; - this._spinnerTop = 0; - this._dirtyDialogTop = 0; this._ariaContent = ''; + this._loadingSpinnerTop = 0; + this._staleOverlayTop = 0; + this._state = 'hidden'; } render() { const backdropVisible = this._state !== 'hidden'; + const backdropContainer = backdropVisible ? html`
+
+ +
` : nothing; + + const backdropStaleOverlay = backdropVisible ? html` + ` : nothing; + return html` - ${backdropVisible ? - html`
-
- -
` : nothing - } -
- ${backdropVisible ? - html`` : nothing } - - ${this._ariaContent} - + ${backdropContainer} +
+ ${backdropStaleOverlay} + ${this._ariaContent}
`; } + updated(changedProperties) { - if (changedProperties.get('_state') && changedProperties.get('_state') === 'hidden') - { - this.#centerLoadingSpinnerAndDialog(); + super.updated(changedProperties); + + if (changedProperties.get('_state') && changedProperties.get('_state') === 'hidden') { + this.#updatePosition(); } if (changedProperties.has('_state')) { if (this._state === 'showing') { - if (this.dataState === 'loading') { + if (this.freshness === freshness.loading) { setTimeout(() => { if (this._state === 'showing') this._state = 'shown'; }, BACKDROP_DELAY_MS); @@ -180,26 +154,27 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme } } } + willUpdate(changedProperties) { - if (changedProperties.has('dataState') && changedProperties.get('dataState') !== undefined) { - this.#clearLiveArea(); + super.willUpdate(changedProperties); + + if (changedProperties.has('freshness') && changedProperties.get('freshness') !== undefined) { + this.#clearLiveRegionContent(); - const oldState = changedProperties.get('dataState'); - const newState = this.dataState; + const oldState = changedProperties.get('freshness'); + const newState = this.freshness; - // Calculate announcements - if (newState === 'loading') { - this.#setLiveArea(this.localize('components.backdrop-loading.loadingAnnouncement'), { delay: LOADING_ANNOUNCEMENT_DELAY }); - } else if (oldState === 'loading' && newState === 'clean') { - this.#setLiveArea(this.localize('components.backdrop-loading.loadingCompleteAnnouncement')); + if (newState === freshness.loading) { + this.#setLiveRegionContent(this.localize('components.backdrop-loading.loadingAnnouncement'), { delay: LOADING_ANNOUNCEMENT_DELAY }); + } else if (oldState === freshness.loading && newState === freshness.fresh) { + this.#setLiveRegionContent(this.localize('components.backdrop-loading.loadingCompleteAnnouncement')); } - // Update backdrop - if (oldState === 'clean') { + if (oldState === freshness.fresh) { this.#show(); - } else if (newState === 'clean') { + } else if (newState === freshness.fresh) { this.#fade(); - } else if (oldState === 'loading' && newState === 'dirty') { + } else if (oldState === freshness.loading && newState === freshness.stale) { setTimeout(() => { if (this._state === 'showing') this._state = 'shown'; }, BACKDROP_DELAY_MS); @@ -207,52 +182,19 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme } } - async #centerLoadingSpinnerAndDialog() { - if (this._state === 'hidden') { return; } - - const loadingSpinner = this.shadowRoot.querySelector('d2l-loading-spinner'); - if (!loadingSpinner) { return; } - - const boundingRect = this.shadowRoot.querySelector('#visible').getBoundingClientRect(); - - // Calculate the centerpoint of the visible portion of the element - const upperVisibleBound = Math.max(0, boundingRect.top); - const lowerVisibleBound = Math.min(window.innerHeight, boundingRect.bottom); - const visibleHeight = lowerVisibleBound - upperVisibleBound; - const centeringOffset = (visibleHeight / 4); - - // Calculate if an offset is required to move to the top of the viewport before centering - const topOffset = Math.max(0, -boundingRect.top); // measures the distance below the top of the viewport, which is negative if the element starts above the viewport - - // Adjust for the size of the spinner - const spinnerSizeOffset = LOADING_SPINNER_SIZE / 2; - - this._spinnerTop = centeringOffset + topOffset - spinnerSizeOffset; + #announcementTimeoutId; - // Adjust for the size of the dirty dialog - const dirtyOverlay = this.shadowRoot.querySelector('d2l-backdrop-dirty-overlay'); - if (dirtyOverlay) { - await this.shadowRoot.querySelector('d2l-empty-state-action-button')?.getUpdateComplete(); - const dirtyDialogSizeOffset = dirtyOverlay.getBoundingClientRect().height / 2; - - this._dirtyDialogTop = centeringOffset + topOffset - dirtyDialogSizeOffset; - } - } - - #clearLiveArea() { + #clearLiveRegionContent() { this._ariaContent = ''; - if (this.announcementTimeout) { - clearTimeout(this.announcementTimeout); - } - - this.announcementTimeout = null; + if (this.#announcementTimeoutId) clearTimeout(this.#announcementTimeoutId); + this.#announcementTimeoutId = null; } #fade() { let hideImmediately = reduceMotion || this._state === 'showing'; if (this._state === 'shown') { - const currentOpacity = getComputedStyle(this.shadowRoot.querySelector('#d2l-live-region')).opacity; + const currentOpacity = getComputedStyle(this.shadowRoot.querySelector('.backdrop-stale-container')).opacity; hideImmediately ||= (currentOpacity === '0'); } @@ -266,14 +208,10 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme #getBackdropTarget() { const parent = getComposedParent(this); - const targetedChildren = getComposedChildren( - parent, - (elem) => elem.id === this.for, - false - ); - - if (targetedChildren.length === 0) { throw new Error(`Backdrop cannot find sibling identified by 'for' property with value ${this.for}`);} - + const targetedChildren = getComposedChildren(parent, (elem) => elem.id === this.for, false); + if (targetedChildren.length === 0) { + throw new Error(`Backdrop cannot find sibling identified by 'for' property with value ${this.for}`); + } return targetedChildren[0]; } @@ -287,24 +225,53 @@ class LoadingBackdrop extends PropertyRequiredMixin(LocalizeCoreElement(LitEleme this._state = 'hidden'; const containingBlock = this.#getBackdropTarget(); - if (containingBlock.dataset.initiallyInert !== '1') containingBlock.removeAttribute('inert'); } - #setLiveArea(content, { delay } = {}) { - this.announcementTimeout = setTimeout(() => this._ariaContent = content, delay || 0); + #setLiveRegionContent(content, { delay } = {}) { + this.#announcementTimeoutId = setTimeout(() => this._ariaContent = content, delay || 0); } #show() { this._state = reduceMotion ? 'shown' : 'showing'; const containingBlock = this.#getBackdropTarget(); - if (containingBlock.getAttribute('inert') !== null) containingBlock.dataset.initiallyInert = '1'; - containingBlock.setAttribute('inert', 'inert'); } + async #updatePosition() { + if (this._state === 'hidden') { return; } + + const loadingSpinner = this.shadowRoot.querySelector('d2l-loading-spinner'); + if (!loadingSpinner) { return; } + + const backdropContainerRect = this.shadowRoot.querySelector('.backdrop-container').getBoundingClientRect(); + + // Calculate the centerpoint of the visible portion of the element + const upperVisibleBound = Math.max(0, backdropContainerRect.top); + const lowerVisibleBound = Math.min(window.innerHeight, backdropContainerRect.bottom); + const visibleHeight = lowerVisibleBound - upperVisibleBound; + const centeringOffset = (visibleHeight / 4); + + // Calculate if an offset is required to move to the top of the viewport before centering + const topOffset = Math.max(0, -backdropContainerRect.top); // measures the distance below the top of the viewport, which is negative if the element starts above the viewport + + // Adjust for the size of the spinner + const spinnerSizeOffset = LOADING_SPINNER_SIZE / 2; + + this._loadingSpinnerTop = centeringOffset + topOffset - spinnerSizeOffset; + + // Adjust for the size of the stale overlay + const staleOverlay = this.shadowRoot.querySelector('d2l-backdrop-stale-overlay'); + if (staleOverlay) { + await this.shadowRoot.querySelector('d2l-empty-state-action-button')?.getUpdateComplete(); + const staleOverlaySizeOffset = staleOverlay.getBoundingClientRect().height / 2; + + this._staleOverlayTop = centeringOffset + topOffset - staleOverlaySizeOffset; + } + } + } -customElements.define('d2l-backdrop-loading', LoadingBackdrop); +customElements.define('d2l-backdrop-loading', BackdropLoading); diff --git a/components/backdrop/backdrop-stale-overlay.js b/components/backdrop/backdrop-stale-overlay.js new file mode 100644 index 00000000000..3634fe1b427 --- /dev/null +++ b/components/backdrop/backdrop-stale-overlay.js @@ -0,0 +1,54 @@ +import '../button/button-subtle.js'; +import { css, html, LitElement } from 'lit'; +import { bodyCompactStyles } from '../typography/styles.js'; + +/** + * A component to render as an overlay over another element with stale data. + */ +class BackdropStaleOverlay extends LitElement { + + static properties = { + /** + * The action button text + * @type {string} + */ + buttonText: { type: String, attribute: 'button-text', required: true }, + /** + * The text displayed on the overlay + * @type {string} + */ + text: { type: String, required: true } + }; + + static styles = [bodyCompactStyles, css` + :host { + align-items: center; + border: 1px solid var(--d2l-color-mica); + border-radius: 0.3rem; + column-gap: 0.75rem; + display: flex; + flex-wrap: wrap; + padding: 1.2rem 1.5rem; + } + `]; + + render() { + return html` +

${this.text}

+ + + `; + } + + #handleActionClick(e) { + e.stopPropagation(); + + /** Dispatched when the action button on the overlay is clicked */ + this.dispatchEvent(new CustomEvent('d2l-backdrop-stale-overlay-action', { bubbles: true, composed: true })); + } + +} + +customElements.define('d2l-backdrop-stale-overlay', BackdropStaleOverlay); diff --git a/components/backdrop/demo/backdrop-loading.html b/components/backdrop/demo/backdrop-loading.html index ccabd50e2fa..464ade4b47a 100644 --- a/components/backdrop/demo/backdrop-loading.html +++ b/components/backdrop/demo/backdrop-loading.html @@ -8,6 +8,8 @@ import '../../demo/demo-page.js'; import '../../button/button.js'; import '../backdrop-loading.js'; + import { freshness } from '../../../mixins/freshness/freshness-mixin.js'; + window.backdropLoadingFreshness = freshness;