@browser.style/async-loader
Version:
Async Loader Component
204 lines (184 loc) • 5.85 kB
JavaScript
const styles = new CSSStyleSheet();
styles.replaceSync(`
:host * { box-sizing: border-box; }
:host(:not([inline])) {
background: var(--async-loader-bg, color-mix(in oklab, Canvas 80%, transparent 20%));
block-size: 100dvh;
border: 0;
color: var(--async-loader-c, CanvasText);
color-scheme: light dark;
grid-template-rows: repeat(3, 1fr);
inline-size: 100dvw;
padding: var(--async-loader-p, 1rem);
}
:host(:not([inline])[popover]:popover-open) {
display: grid;
}
:host::part(close) {
background: #0000;
border: 0;
border-radius: 50%;
block-size: 3.5rem;
color: inherit;
font-size: 1.5rem;
grid-row: 1;
inline-size: 3.5rem;
padding: 1rem;
place-self: start end;
}
:host::part(close):focus-visible,
:host::part(close):hover {
background: var(--async-loader-close-bg, light-dark(#f3f3f3, #333));
outline: none;
}
:host::part(error) {
background: var(--async-loader-error-bg, light-dark(CanvasText, Canvas));
border-radius: 0.25rem;
color: var(--async-loader-error-c, light-dark(Canvas, CanvasText));
grid-row: 3;
padding: 1ch 2ch;
place-self: end center;
text-align: center;
}
:host::part(icon) {
aspect-ratio: 1;
block-size: 1em;
fill: none;
pointer-events: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
:host::part(spinner) {
animation: spin 1s linear infinite;
aspect-ratio: 1;
background: var(--async-loader-spinner-accent, light-dark(#007bff, #337dcc)) ;
block-size: var(--async-loader-spinner-sz, 3.5rem);
border: 0;
border-radius: var(--async-loader-spinner-bdrs, 50%);
grid-row: 2;
mask: conic-gradient(#0000 10%,#000), linear-gradient(#000 0 0) content-box;
mask-composite: subtract;
overflow: hidden;
padding: var(--async-loader-spinner-p, calc(var(--async-loader-spinner-sz, 3.5rem) / 10));
place-self: center;
}
/* Inline */
:host([inline]) {
--async-loader-spinner-accent: currentColor;
--async-loader-spinner-p: 2px;
--async-loader-spinner-sz: 1rem;
}
:host([inline])::part(close),
:host([inline])::part(error) {
display: none;
}
:host :where([part="status-failed"]):not([hidden]),
:host :where([part="status-success"]):not([hidden]) {
display: grid;
place-content: center;
}
spin {
to { rotate: 1turn; }
}
`);
export default class AsyncLoader extends HTMLElement {
#elements = {};
#root;
#timeoutId = null;
#loading = false;
get isInline() {
return this.hasAttribute('inline');
}
constructor() {
super();
this.#root = this.attachShadow({ mode: 'open' });
this.#root.adoptedStyleSheets = [styles];
}
connectedCallback() {
this.#root.innerHTML = `
<button type="button" part="close"${this.hasAttribute('allowclose') ? '' : ' hidden'}>
<svg part="icon" viewBox="0 0 24 24"><path d="M18 6l-12 12"/><path d="M6 6l12 12"/></svg>
</button>
<div part="spinner" role="progressbar"></div>
<div part="status-success" hidden>
<slot name="success"><svg viewBox="0 0 24 24" part="icon"><path d="M5 12l5 5l10 -10"/></svg></slot>
</div>
<div part="status-failed" hidden>
<slot name="failed"><svg viewBox="0 0 24 24" part="icon"><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M12 9v4"/><path d="M12 16v.01"/></svg></slot>
</div>
<output part="error" role="alert" hidden></output>
`;
this.#elements = {
close: this.#root.querySelector('[part="close"]'),
error: this.#root.querySelector('[part="error"]'),
spinner: this.#root.querySelector('[part="spinner"]'),
status: {
failed: this.#root.querySelector('[part="status-failed"]'),
success: this.#root.querySelector('[part="status-success"]')
}
};
this.hidden = this.isInline;
if (!this.isInline) {
const popover = this.getAttribute('popover');
this.setAttribute('popover', popover || 'manual');
}
this.addEventListener('loader:start', this.startLoading);
this.addEventListener('loader:stop', this.stopLoading);
this.#elements.close.addEventListener('click', () => this.stopLoading());
}
startLoading = () => {
if (this.#loading) return;
if (this.isInline) {
this.hidden = false;
} else {
this.showPopover();
}
this.#loading = true;
this.#elements.close.hidden = !this.hasAttribute('allowclose');
this.#elements.error.hidden = true;
this.#elements.error.part = `error ${this.getAttribute('errortype') || ''}`;
this.#elements.error.value = '';
this.#elements.spinner.style.animationPlayState = 'running';
const timeout = this.getAttribute('timeout');
if (timeout) {
this.#timeoutId = setTimeout(() => this.handleTimeout(), parseInt(timeout));
}
this.dispatchEvent(new CustomEvent('loader:started', { bubbles: true }));
};
stopLoading = (hasError = false) => {
if (hasError instanceof Event) hasError = false;
this.#timeoutId && clearTimeout(this.#timeoutId);
this.#timeoutId = null;
this.#loading = false;
if (this.isInline) {
this.#elements.spinner.hidden = true;
this.#elements.status.success.hidden = hasError;
this.#elements.status.failed.hidden = !hasError;
} else {
this.togglePopover(false);
}
this.#elements.error.value = '';
this.dispatchEvent(new CustomEvent('loader:stopped', { bubbles: true }));
};
handleTimeout = () => {
const error = this.getAttribute('errormsg') || 'Operation timed out';
this.handleError(new Error(error));
};
handleError = (error) => {
if (this.isInline) {
this.stopLoading(true);
}
this.#elements.close.hidden = false;
this.#elements.error.hidden = false;
this.#elements.error.value = error.message;
this.#loading = false;
this.#elements.spinner.style.animationPlayState = 'paused';
this.dispatchEvent(new CustomEvent('loader:error', {
bubbles: true,
detail: { error }
}));
};
}
customElements.define('async-loader', AsyncLoader);