Skip to content

feat: Table visual loading state#6552

Draft
duncanuszkay-d2l wants to merge 2 commits intomainfrom
dunk.table-loading
Draft

feat: Table visual loading state#6552
duncanuszkay-d2l wants to merge 2 commits intomainfrom
dunk.table-loading

Conversation

@duncanuszkay-d2l
Copy link

@duncanuszkay-d2l duncanuszkay-d2l commented Feb 4, 2026

Downstream ticket
Upstream ticket

We want to add a visual loading state to the table in the event that the data needs to be refreshed. This can occur after filters are applied, sort orders are changed, and possibly in future when we move to a new page.

I took the initial timings and design from two sources:

Since this functionality may be generally useful, I've tentatively moved it into it's own component and listed it in the docs as a related component to the backdrop.

Demo of the Daylight Docs

Recording.2026-02-05.163844.mp4

Demo of the component in practice:

In this video I cover three scenarios:

  • The animation has time to fully fade-in before it's disabled
  • The animation starts fading in before it's disabled
  • The animation is disabled during the initial delay
Recording.2026-02-05.115314.mp4

Alternatives

Alternatively, we can consider updating the backdrop component itself to be able to encompass both scenarios.

A draft of that approach can be found here: #6570

@github-actions
Copy link
Contributor

github-actions bot commented Feb 4, 2026

Thanks for the PR! 🎉

We've deployed an automatic preview for this PR - you can see your changes here:

URL https://live.d2l.dev/prs/BrightspaceUI/core/pr-6552/

Note

The build needs to finish before your changes are deployed.
Changes to the PR will automatically update the instance.

Comment on lines 4 to 9
const BACKDROP_DELAY_MS = 800;
const BACKDROP_FADE_IN_DURATION_MS = 500;
const BACKDROP_FADE_OUT_DURATION_MS = 500;
const SPINNER_DELAY_MS = BACKDROP_DELAY_MS + BACKDROP_FADE_IN_DURATION_MS;
const SPINNER_FADE_IN_DURATION_MS = 500;
const SPINNER_FADE_OUT_DURATION_MS = 500;
Copy link
Author

@duncanuszkay-d2l duncanuszkay-d2l Feb 5, 2026

Choose a reason for hiding this comment

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

Values to be discussed with design folks on the Gaudi side- some of these might be redundant if we don't plan on differentiating the values, in which case I can simply the CSS below a bit.

static get properties() {
return {
shown: { type: Boolean },
_state: { type: String, reflect: true },
Copy link
Author

Choose a reason for hiding this comment

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

Following a similar pattern to backdrop.js here using a string state

}

static get styles() {
return css`
Copy link
Author

Choose a reason for hiding this comment

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

I discussed leveraging some styles from backdrop.js by changing that module to export some styles, similar to how we export tableStyles, but ultimately found that there wasn't all that much overlap aside from the color and opacity. I didn't feel it was worth the indirection- but open to opinions here.

Copy link
Contributor

Choose a reason for hiding this comment

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

At some point in the near future we will be replacing the color with a more meaningful CSS variable that they both can use.

static get styles() {
return css`
:host, .backdrop, d2l-loading-spinner {
width: 0%;
Copy link
Author

Choose a reason for hiding this comment

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

A learning point for me- when using transitions, I can't rely on display: none to keep the table interactive while this isn't on, because going from display:none; opacity: 0 to display:block; opacity: 1 doesn't count as a change in opacity, as the display:none version didn't render at all. Bit of a hassle for fade-in components like these.

This approach to change the width instead matches the implementation in backdrop.js.


d2l-loading-spinner {
z-index: 999;
top: 100px;
Copy link
Author

Choose a reason for hiding this comment

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

Some design input is needed here- I'm not sure exactly how we want to quantify the vertical positioning of the spinner relative to the table. 100px was my 'looks fine to me' value, but maybe there's something more clever relative to the size of the viewport or something 😄

Comment on lines +55 to +56
width: 100%;
height: 100%;
Copy link
Author

Choose a reason for hiding this comment

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

Changing the width/height here is how we ensure that our components are shown- also, while these are true, the table below is non-interactive (can't highlight, etc), because these render on top.


d2l-loading-spinner[_state="showing"] {
opacity: 1;
transition: opacity ${SPINNER_FADE_IN_DURATION_MS}ms ease-in-out ${SPINNER_DELAY_MS}ms;
Copy link
Author

Choose a reason for hiding this comment

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

I've biased this implementation towards configurability for now with individual timings for fade in/fade out, but depending on what Gaudi folks think it might not be necessary, then we can define the transition just once on each element.

Comment on lines +77 to +79
@media (prefers-reduced-motion: reduce) {
* { transition: none}
}
Copy link
Author

Choose a reason for hiding this comment

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

I may need some guidance on these animation preferences before shipping this- I'm not sure exactly what I need to be responsive to


render() {
return html`
<d2l-loading-spinner _state=${this._state} size="100"></d2l-loading-spinner>
Copy link
Author

Choose a reason for hiding this comment

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

Spinner size here was chosen arbitrarily based on the example in the daylight docs- may need design guidance on the appropriate static/dynamic size.

}),
new Promise(resolve => {
loadingSpinner.addEventListener('transitionend', resolve, { once: true });
loadingSpinner.addEventListener('transitioncancel', resolve, { once: true });
Copy link
Author

Choose a reason for hiding this comment

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

Listening to the transition cancel here deals with the case where the fade-in animation wasn't complete before we started the fade-out.

If we just listen for transitionend, the promise will never enqueue in that scenario, because after the fade-in gets cancelled the fade-out is never enqueued because we're already 'faded out' in terms of opacity.

This addition receives the cancel from the fade-in animation and clears the loading state accordingly

render() {
const slot = html`<slot @slotchange="${this._handleSlotChange}"></slot>`;
const slot = html`
<d2l-table-loading-backdrop ?shown=${this.loading}></d2l-table-loading-backdrop>
Copy link
Author

Choose a reason for hiding this comment

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

This positioning of the element projects the backdrop over the scroll wrapper

Copy link
Contributor

Choose a reason for hiding this comment

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

I foresee us wanting to use this elsewhere, for example in d2l-list. I'd be inclined to consider making this more general. It might make sense to define this as a different type of backdrop alongside d2l-backdrop. Alternatively enhancing d2l-backdrop for this case, but making this a separate component helps avoid the conditional logic and behaviour since there are a few things d2l-backdrop does that this doesn't.

It could be defined alongside the CollectionMixin since both d2l-table-wrapper and d2l-list extend that, but unfortunately that currently resides outside of components.

Copy link
Author

Choose a reason for hiding this comment

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

Makes sense- I'll take a pass to generalize and we can figure out the name/location from there.

:host, .backdrop, d2l-loading-spinner {
width: 0%;
height: 0%;
position: absolute;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the d2l-table-wrapper the containing block for this? i.e. it is only rendering the backdrop over the table?

Copy link
Author

Choose a reason for hiding this comment

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

No- the containing block is the scroll wrapper

Copy link
Author

Choose a reason for hiding this comment

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

But visually yes, it only renders the backdrop over the table and leaves the rest of the page interactive

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants