Skip to content
This repository was archived by the owner on Mar 24, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/pages/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ New versions of Shoelace are released as-needed and generally occur when a criti

- Improved performance of `<sl-select>` when using a large number of options [#2318]
- Updated the Japanese translation [#2329]
- Adjust `<sl-alert>` to create the toast stack when used only, making it usable in SSR environments. [#2359]
- Adjust `scrollend-polyfill` so it only runs on the client to make it usable in SSR environments. [#2359]
- Fixed a bug with radios in `<sl-dialog>` focus trapping.

## 2.19.1
Expand Down
25 changes: 17 additions & 8 deletions src/components/alert/alert.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import SlIconButton from '../icon-button/icon-button.component.js';
import styles from './alert.styles.js';
import type { CSSResultGroup } from 'lit';

const toastStack = Object.assign(document.createElement('div'), { className: 'sl-toast-stack' });

/**
* @summary Alerts are used to display important messages inline or as toast notifications.
* @documentation https://shoelace.style/components/alert
Expand Down Expand Up @@ -50,6 +48,17 @@ export default class SlAlert extends ShoelaceElement {
private readonly hasSlotController = new HasSlotController(this, 'icon', 'suffix');
private readonly localize = new LocalizeController(this);

private static currentToastStack: HTMLDivElement;

private static get toastStack() {
if (!this.currentToastStack) {
this.currentToastStack = Object.assign(document.createElement('div'), {
className: 'sl-toast-stack'
});
}
return this.currentToastStack;
}

@query('[part~="base"]') base: HTMLElement;

@query('.alert__countdown-elapsed') countdownElement: HTMLElement;
Expand Down Expand Up @@ -195,11 +204,11 @@ export default class SlAlert extends ShoelaceElement {
async toast() {
return new Promise<void>(resolve => {
this.handleCountdownChange();
if (toastStack.parentElement === null) {
document.body.append(toastStack);
if (SlAlert.toastStack.parentElement === null) {
document.body.append(SlAlert.toastStack);
}

toastStack.appendChild(this);
SlAlert.toastStack.appendChild(this);

// Wait for the toast stack to render
requestAnimationFrame(() => {
Expand All @@ -211,12 +220,12 @@ export default class SlAlert extends ShoelaceElement {
this.addEventListener(
'sl-after-hide',
() => {
toastStack.removeChild(this);
SlAlert.toastStack.removeChild(this);
resolve();

// Remove the toast stack from the DOM when there are no more alerts
if (toastStack.querySelector('sl-alert') === null) {
toastStack.remove();
if (SlAlert.toastStack.querySelector('sl-alert') === null) {
SlAlert.toastStack.remove();
}
},
{ once: true }
Expand Down
93 changes: 50 additions & 43 deletions src/internal/scrollend-polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,54 +26,61 @@ const decorate = <T, M extends keyof T>(
} as MethodOf<T, M>;
};

const isSupported = 'onscrollend' in window;
(() => {
// SSR environments should not apply the polyfill
if (typeof window === 'undefined') {
return;
}

if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
const isSupported = 'onscrollend' in window;

const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
};

const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};

document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);

decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scrollend') return;
if (!isSupported) {
const pointers = new Set();
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();

const handleScrollEnd = debounce(() => {
if (!pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.dispatchEvent(new Event('scrollend'));
} else {
// otherwise let's wait a bit more
handleScrollEnd();
const handlePointerDown = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.add(touch.identifier);
}
}, 100);
};

addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
scrollHandlers.set(this, handleScrollEnd);
});

decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scrollend') return;

const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
}
});
}
const handlePointerUp = (event: TouchEvent) => {
for (const touch of event.changedTouches) {
pointers.delete(touch.identifier);
}
};

document.addEventListener('touchstart', handlePointerDown, true);
document.addEventListener('touchend', handlePointerUp, true);
document.addEventListener('touchcancel', handlePointerUp, true);

decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
if (type !== 'scrollend') return;

const handleScrollEnd = debounce(() => {
if (!pointers.size) {
// If no pointer is active in the scroll area then the scroll has ended
this.dispatchEvent(new Event('scrollend'));
} else {
// otherwise let's wait a bit more
handleScrollEnd();
}
}, 100);

addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
scrollHandlers.set(this, handleScrollEnd);
});

decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
if (type !== 'scrollend') return;

const scrollHandler = scrollHandlers.get(this);
if (scrollHandler) {
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
}
});
}
})();

// Without an import or export, TypeScript sees vars in this file as global
export {};
Loading