/** * @fileoverview UI Loader Component - Spinning loading indicator * * @module ct-loader * @description * A simple inline spinner for visualizing pending async operations. * Optionally displays elapsed time and a stop/cancel button. * * @example * ```html * * * * * * * * * * * Loading * ``` */ import { css, html } from "lit"; import { BaseElement } from "../../core/base-element.ts"; export type LoaderSize = "sm" | "md" | "lg"; /** * CTLoader displays a spinning loading indicator. * * @tag ct-loader * @extends BaseElement * * @property {LoaderSize} size - Size variant: "sm" (12px), "md" (24px), "lg" (48px) * @property {boolean} showElapsed - Whether to display elapsed time * @property {boolean} showStop - Whether to display stop button * * @fires ct-stop - Fired when stop button is clicked * * @csspart spinner - The spinning circle SVG * @csspart elapsed - The elapsed time text * @csspart stop - The stop button */ export class CTLoader extends BaseElement { static override styles = css` :host { display: inline-flex; align-items: center; vertical-align: middle; gap: 0.375rem; } *, *::before, *::after { box-sizing: border-box; } .spinner { animation: spin 0.8s linear infinite; } /* Size variants: sm=12px, md=24px, lg=48px */ :host([size="sm"]) .spinner { width: 12px; height: 12px; } :host([size="md"]) .spinner, :host(:not([size])) .spinner { width: 24px; height: 24px; } :host([size="lg"]) .spinner { width: 48px; height: 48px; } .track { stroke: var(--ct-color-border, #e0e0e0); } .arc { stroke: var(--ct-color-primary, #000); stroke-linecap: round; } @keyframes spin { to { transform: rotate(360deg); } } .elapsed { font-size: 0.75rem; color: var(--ct-color-text-muted, #666); font-variant-numeric: tabular-nums; } .stop-button { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; border: none; border-radius: 2px; background: transparent; color: var(--ct-color-text-muted, #666); cursor: pointer; } .stop-button:hover { background: var(--ct-color-surface, #f0f0f0); color: var(--ct-color-error, #dc2626); } .stop-button svg { width: 10px; height: 10px; } /* Screen reader only */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } `; static override properties = { size: { type: String, reflect: true }, showElapsed: { type: Boolean, attribute: "show-elapsed" }, showStop: { type: Boolean, attribute: "show-stop" }, }; declare size: LoaderSize; declare showElapsed: boolean; declare showStop: boolean; private _startTime: number = 0; private _elapsedMs: number = 0; private _animationFrame: number | null = null; constructor() { super(); this.size = "md"; this.showElapsed = false; this.showStop = false; } override connectedCallback(): void { super.connectedCallback(); this._startTime = Date.now(); if (this.showElapsed) { this._startTimer(); } } override disconnectedCallback(): void { super.disconnectedCallback(); this._stopTimer(); } override updated(changedProperties: Map): void { if (changedProperties.has("showElapsed")) { if (this.showElapsed) { this._startTimer(); } else { this._stopTimer(); } } } private _startTimer(): void { if (this._animationFrame !== null) return; const tick = () => { this._elapsedMs = Date.now() - this._startTime; this.requestUpdate(); this._animationFrame = requestAnimationFrame(tick); }; this._animationFrame = requestAnimationFrame(tick); } private _stopTimer(): void { if (this._animationFrame !== null) { cancelAnimationFrame(this._animationFrame); this._animationFrame = null; } } private _formatElapsed(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) { return `${seconds}s`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } private _handleStop(event: Event): void { event.preventDefault(); event.stopPropagation(); this.emit("ct-stop", {}); } override render() { return html` ${this.showElapsed ? html` ${this._formatElapsed( this._elapsedMs, )} ` : null} ${this.showStop ? html` ` : null} Loading `; } /** Reset the elapsed timer */ resetTimer(): void { this._startTime = Date.now(); this._elapsedMs = 0; } } globalThis.customElements.define("ct-loader", CTLoader);