-
Notifications
You must be signed in to change notification settings - Fork 29
Add apply button and dirty state #6751
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4b0d4a3
b428c26
947a2f4
e0f9b17
afac333
1703061
b2903cf
76f1418
1d1bf11
8b57f61
9cac473
5e9287c
3346616
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
@@ -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; } | ||
| } | ||
|
|
@@ -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') | ||
| ) | ||
| ) { | ||
| 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
@@ -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(); | ||
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -195,7 +269,6 @@ class LoadingBackdrop extends LitElement { | |
|
|
||
| containingBlock.setAttribute('inert', 'inert'); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| customElements.define('d2l-backdrop-loading', LoadingBackdrop); | ||
There was a problem hiding this comment.
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