Skip to content
153 changes: 113 additions & 40 deletions components/backdrop/backdrop-loading.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,50 @@
import '../colors/colors.js';
import '../loading-spinner/loading-spinner.js';
import '../empty-state/empty-state-action-button.js';
import '../empty-state/empty-state-simple.js';
import '../offscreen/offscreen.js';
import { css, html, LitElement, nothing } from 'lit';
import { getComposedChildren, getComposedParent } from '../../helpers/dom.js';
import { LocalizeCoreElement } from '../../helpers/localize-core-element.js';
import { styleMap } from 'lit/directives/style-map.js';

const BACKDROP_DELAY_MS = 800;
const FADE_DURATION_MS = 500;
const SPINNER_DELAY_MS = FADE_DURATION_MS;
const LOADING_ANNOUNCEMENT_DELAY = 1000;
const DIRTY_ANNOUNCEMENT_DELAY = 1000;

const LOADING_SPINNER_MINIMUM_BUFFER = 100;
const LOADING_SPINNER_SIZE = 50;

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 LitElement {
class LoadingBackdrop extends LocalizeCoreElement(LitElement) {

static get properties() {
return {
/**
* Used to control whether the loading backdrop is shown
* @type {boolean}
* 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'}
*/
shown: { type: Boolean },
dataState: {
reflect: true,
type: String
},
/**
* Used to identify content that the backdrop should make inert
* @type {boolean}
*/
for: { type: String },
_state: { type: String, reflect: true },
_spinnerTop: { state: true }
_spinnerTop: { state: true },
_ariaContent: { state: true }
};
}

static get styles() {
return css`
:host {
#visible {
display: none;
height: 100%;
justify-content: center;
Expand All @@ -46,9 +53,9 @@ class LoadingBackdrop extends LitElement {
width: 100%;
z-index: 999;
}
:host([_state="showing"]),
:host([_state="shown"]),
:host([_state="hiding"]) {
:host([_state="showing"]) #visible,
:host([_state="shown"]) #visible,
:host([_state="hiding"]) #visible {
display: flex;
}

Expand All @@ -68,17 +75,34 @@ class LoadingBackdrop extends LitElement {
d2l-loading-spinner {
opacity: 0;
position: absolute;
transition: opacity ${FADE_DURATION_MS}ms ease-in ${SPINNER_DELAY_MS}ms;
transition: opacity ${FADE_DURATION_MS}ms ease-in;
}
:host([_state="shown"]) d2l-loading-spinner {
:host([_state="shown"][dataState="loading"]) d2l-loading-spinner {
opacity: 1;
}

:host([_state="hiding"]) .d2l-backdrop,
:host([_state="hiding"]) d2l-empty-state-simple,
:host([dataState="loading"]) d2l-empty-state-simple,
:host([_state="hiding"]) d2l-loading-spinner {
transition: opacity ${FADE_DURATION_MS}ms ease-out;
}

d2l-empty-state-simple {
background-color: var(--d2l-table-controls-background-color, white);
top: 0;
opacity: 0;
height: fit-content;
justify-content: center;
position: relative;
z-index: 1000;
transition: opacity ${FADE_DURATION_MS}ms ease-in;
}

:host([_state="shown"][dataState="dirty"]) d2l-empty-state-simple {
opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
* { transition: none; }
}
Expand All @@ -87,72 +111,116 @@ class LoadingBackdrop extends LitElement {

constructor() {
super();
this.shown = false;
this.dataState = 'clean';
this._state = 'hidden';
this._spinnerTop = LOADING_SPINNER_MINIMUM_BUFFER;
this._spinnerTop = 0;
this._dirtyDialogTop = 0;
this._ariaContent = '';
}

render() {
if (this._state === 'hidden') return nothing;
return html`
<div class="backdrop" @transitionend="${this.#handleTransitionEnd}" @transitioncancel="${this.#hide}"></div>
<d2l-loading-spinner style=${styleMap({ top: `${this._spinnerTop}px` })} size="${LOADING_SPINNER_SIZE}"></d2l-loading-spinner>
${this._state === 'hidden' ? nothing :
html`<div id="visible">
<div class="backdrop" @transitionend="${this.#handleTransitionEnd}" @transitioncancel="${this.#handleTransitionEnd}"></div>
<d2l-loading-spinner style=${styleMap({ top: `${this._spinnerTop}px` })} size="${LOADING_SPINNER_SIZE}"></d2l-loading-spinner>
<d2l-empty-state-simple style=${styleMap({ top: `${this._dirtyDialogTop}px` })} description="${this.localize('components.backdrop-loading.dirtyDialogDescription')}">
<d2l-empty-state-action-button @d2l-empty-state-action=${this.#handleApplyButton} text="${this.localize('components.backdrop-loading.dirtyDialogAction')}"></d2l-empty-state-action-button>
</d2l-empty-state-simple>
</div>`
}
<d2l-offscreen aria-live="polite">${this._ariaContent}</d2l-offscreen>
`;
}
updated(changedProperties) {
if (changedProperties.has('dataState') &&
changedProperties.get('dataState') === 'clean' &&
(
(reduceMotion && this._state === 'shown') ||
(!reduceMotion && this._state === 'showing') ||
(this.dataState === 'loading')
)
) {
Comment on lines +136 to +143
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only want to center if we're moving from a hidden/clean state into a non-hidden state, we don't want to center again when moving from the dialog to the loading spinner

this.#centerLoadingSpinner();
}

if (changedProperties.has('_state')) {
if (this._state === 'showing') {
setTimeout(() => {
if (this._state === 'showing') this._state = 'shown';
}, BACKDROP_DELAY_MS);
Comment on lines -105 to -107
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've decided to remove this delay and instead show the backdrop as soon as the data becomes dirty

this._state = 'shown';
}
}

if (changedProperties.has('shown') && (
(reduceMotion && this._state === 'shown') || (!reduceMotion && this._state === 'showing')
)) {
this.#centerLoadingSpinner();
}
Comment on lines -111 to -115
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This centering behavior is re-addressed in a later commit

}
willUpdate(changedProperties) {
if (changedProperties.has('shown')) {
if (this.shown) {
if (changedProperties.has('dataState') && changedProperties.get('dataState') !== undefined) {
this.#clearLiveArea();

const oldState = changedProperties.get('dataState');
const newState = this.dataState;

// Calculate announcements
if (newState === 'loading') {
this.#setLiveArea(this.localize('components.backdrop-loading.loadingAnnouncement'), { delay: LOADING_ANNOUNCEMENT_DELAY });
} else if (newState === 'dirty') {
this.#setLiveArea(this.localize('components.backdrop-loading.dirtyAnnouncement'), { delay: DIRTY_ANNOUNCEMENT_DELAY });
} else if (oldState === 'loading' && newState === 'clean') {
this.#setLiveArea(this.localize('components.backdrop-loading.loadingCompleteAnnouncement'));
}

// Update backdrop
if (oldState === 'clean') {
this.#show();
} else if (changedProperties.get('shown') !== undefined) {
} else if (newState === 'clean') {
this.#fade();
} else if (oldState === 'loading' && newState === 'dirty') {
this._state = 'shown';
}
}
}

#centerLoadingSpinner() {
async #centerLoadingSpinner() {
if (this._state === 'hidden') { return; }

const loadingSpinner = this.shadowRoot.querySelector('d2l-loading-spinner');
if (!loadingSpinner) { return; }

const boundingRect = this.getBoundingClientRect();
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 / 2;
const centeringOffset = (visibleHeight / 4);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From design feedback on the demo, we're moving to a 25% offset instead of centering (50%).


// 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;

const newPosition = centeringOffset + topOffset - spinnerSizeOffset;
this._spinnerTop = Math.max(LOADING_SPINNER_MINIMUM_BUFFER, newPosition);
// Adjust for the size of the dirty dialog
await this.shadowRoot.querySelector('d2l-empty-state-simple').getUpdateComplete();
await this.shadowRoot.querySelector('d2l-empty-state-action-button')?.getUpdateComplete();
const dirtyDialogSizeOffset = this.shadowRoot.querySelector('d2l-empty-state-simple').getBoundingClientRect().height / 2;

this._spinnerTop = centeringOffset + topOffset - spinnerSizeOffset;
this._dirtyDialogTop = centeringOffset + topOffset - dirtyDialogSizeOffset;
Comment on lines +205 to +206
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm removing the minimum buffer, when the table get's small it's fine to overlay the headers as per design's demo feedback

}

#clearLiveArea() {
this._ariaContent = '';

if (this.announcementTimeout) {
clearTimeout(this.announcementTimeout);
}

this.announcementTimeout = null;
}

#fade() {
let hideImmediately = reduceMotion || this._state === 'showing';
if (this._state === 'shown') {
const currentOpacity = getComputedStyle(this.shadowRoot.querySelector('.backdrop')).opacity;
hideImmediately ||= (currentOpacity === '0');
if (this._state === 'shown' || this._state === 'loading') {
const backdrop = this.shadowRoot.querySelector('.backdrop');
hideImmediately ||= backdrop && getComputedStyle(backdrop).opacity === '0';
}

if (hideImmediately) {
Expand All @@ -174,6 +242,9 @@ class LoadingBackdrop extends LitElement {

return targetedChildren.length === 0 ? parent : targetedChildren[0];
}
#handleApplyButton() {
this.dispatchEvent(new CustomEvent('d2l-apply-button-click', { bubbles: true, composed: true }));
}
#handleTransitionEnd() {
if (this._state === 'hiding') {
this.#hide();
Expand All @@ -186,6 +257,9 @@ class LoadingBackdrop extends LitElement {

if (containingBlock.dataset.initiallyInert !== '1') containingBlock.removeAttribute('inert');
}
#setLiveArea(content, { delay } = {}) {
this.announcementTimeout = setTimeout(() => this._ariaContent = content, delay || 0);
}
#show() {
this._state = reduceMotion ? 'shown' : 'showing';

Expand All @@ -195,7 +269,6 @@ class LoadingBackdrop extends LitElement {

containingBlock.setAttribute('inert', 'inert');
}

}

customElements.define('d2l-backdrop-loading', LoadingBackdrop);
12 changes: 9 additions & 3 deletions components/backdrop/test/backdrop-loading.vdiff.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ const template = html`
`;

describe('backdrop-loading', () => {
it('not shown', async() => {
it('clean', async() => {
const elem = await fixture(template);
await expect(elem).to.be.golden();
});

it('shown', async() => {
it('dirty', async() => {
const elem = await fixture(template);
elem.querySelector('d2l-backdrop-loading').shown = true;
elem.querySelector('d2l-backdrop-loading').dataState = 'dirty';
await expect(elem).to.be.golden();
});

it('loading', async() => {
const elem = await fixture(template);
elem.querySelector('d2l-backdrop-loading').dataState = 'loading';
await expect(elem).to.be.golden();
});
});
20 changes: 12 additions & 8 deletions components/table/demo/table-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import '../../selection/selection-action.js';
import '../../selection/selection-action-dropdown.js';
import '../../selection/selection-action-menu-item.js';
import '../../selection/selection-input.js';
import '../../inputs/input-radio.js';
import '../../inputs/input-radio-group.js';

import { css, html, nothing } from 'lit';
import { tableStyles, TableWrapper } from '../table-wrapper.js';
Expand Down Expand Up @@ -81,6 +83,7 @@ class TestTable extends DemoPassthroughMixin(TableWrapper, 'd2l-table-wrapper')
this._data = data();
this._sortField = undefined;
this._sortDesc = false;
this.dataState = 'clean';
}

render() {
Expand All @@ -97,11 +100,11 @@ class TestTable extends DemoPassthroughMixin(TableWrapper, 'd2l-table-wrapper')
icon="tier1:${this.stickyHeaders ? 'check' : 'close-default'}"
@d2l-selection-action-click="${this._toggleStickyHeaders}"
></d2l-selection-action>
<d2l-selection-action
text="Loading"
icon="tier1:${this.loading ? 'check' : 'close-default'}"
@d2l-selection-action-click="${this._toggleLoading}"
></d2l-selection-action>
<d2l-input-radio-group style="align-content:center" label="Date State" horizontal label-hidden name="dataState" @change=${this._handleDataStateChange}>
<d2l-input-radio label="Clean" value="clean" ?checked=${this.dataState === 'clean'}></d2l-input-radio>
<d2l-input-radio label="Dirty" value="dirty" ?checked=${this.dataState === 'dirty'}></d2l-input-radio>
<d2l-input-radio label="Loading" value="loading" ?checked=${this.dataState === 'loading'}></d2l-input-radio>
</d2l-input-radio-group>
</d2l-table-controls>

<table class="d2l-table">
Expand Down Expand Up @@ -167,6 +170,10 @@ class TestTable extends DemoPassthroughMixin(TableWrapper, 'd2l-table-wrapper')
`;
}

_handleDataStateChange(e) {
this.dataState = e.detail.value;
}

async _handlePagerLoadMore(e) {
const pageSize = e.target.pageSize;
await new Promise(resolve => setTimeout(resolve, 1000));
Expand Down Expand Up @@ -256,9 +263,6 @@ class TestTable extends DemoPassthroughMixin(TableWrapper, 'd2l-table-wrapper')
this.requestUpdate();
}

_toggleLoading() {
this.loading = !this.loading;
}
_toggleStickyControls() {
this.stickyControls = !this.stickyControls;
}
Expand Down
12 changes: 6 additions & 6 deletions components/table/table-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,12 +311,12 @@ export class TableWrapper extends PageableMixin(SelectionMixin(LitElement)) {
type: Boolean,
},
/**
* Whether or not to display a loading backdrop. Set this property when the content in the table is being refreshed.
* @type {boolean}
* The state of data in the table. 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'}
*/
loading: {
dataState: {
reflect: true,
type: Boolean
type: String
},
};
}
Expand Down Expand Up @@ -388,7 +388,7 @@ export class TableWrapper extends PageableMixin(SelectionMixin(LitElement)) {
this._tableIntersectionObserver = null;
this._tableMutationObserver = null;
this._tableScrollers = {};
this.loading = false;
this.dataState = 'clean';

this._excludeStickyColumnsFromScrollCalculations = getFlag('GAUD-9530-exclude-sticky-columns-from-scroll-calculations', false);
}
Expand Down Expand Up @@ -422,7 +422,7 @@ export class TableWrapper extends PageableMixin(SelectionMixin(LitElement)) {
const slot = html`
<div style="position:relative">
<slot id="table-slot" @slotchange="${this._handleSlotChange}"></slot>
<d2l-backdrop-loading for="table-slot" ?shown=${this.loading}></d2l-backdrop-loading>
<d2l-backdrop-loading for="table-slot" dataState=${this.dataState}></d2l-backdrop-loading>
</div>
`;
const useScrollWrapper = this.stickyHeadersScrollWrapper || !this.stickyHeaders;
Expand Down
Loading
Loading