import * as colors from '../../00colors.js'; import { demoFunc } from './dees-progressbar.demo.js'; import { customElement, html, DeesElement, property, cssManager, css, state, } from '@design.estate/dees-element'; import { themeDefaultStyles } from '../../00theme.js'; declare global { interface HTMLElementTagNameMap { 'dees-progressbar': DeesProgressbar; } } @customElement('dees-progressbar') export class DeesProgressbar extends DeesElement { public static demo = demoFunc; public static demoGroups = ['Feedback']; @property({ type: Number, }) accessor percentage = 0; // `value` and `progress` keep existing readme/internal usages working. @property({ type: Number, }) accessor value: number | null = null; @property({ type: Number, }) accessor progress: number | null = null; @property({ type: String, }) accessor label = ''; @property({ type: String, }) accessor statusText = ''; @property({ type: Array, }) accessor terminalLines: string[] = []; @property({ type: Number, }) accessor statusRows = 3; @property({ type: Boolean, }) accessor indeterminate = false; @property({ type: Boolean, }) accessor showPercentage = true; @state() accessor activeSpinnerFrame = 0; private spinnerIntervalId: number | null = null; private readonly spinnerFrames = ['|', '/', '-', '\\']; public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` :host { display: block; color: ${cssManager.bdTheme(colors.bright.text, colors.dark.text)}; } .progressBarContainer { min-width: 200px; padding: 8px; box-sizing: border-box; } .progressHeader { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 8px; } .progressLabel { font-size: 14px; font-weight: 500; line-height: 1.3; } .progressValue { font-size: 12px; line-height: 1.3; color: ${cssManager.bdTheme('hsl(215 15% 40%)', 'hsl(215 15% 70%)')}; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; } .progressBar { position: relative; overflow: hidden; width: 100%; height: 8px; border-radius: 999px; background: ${cssManager.bdTheme('#eeeeeb', '#444')}; border-top: 0.5px solid ${cssManager.bdTheme('transparent', '#555')}; } .progressBarFill { height: 100%; border-radius: inherit; background: ${cssManager.bdTheme(colors.dark.blueActive, colors.bright.blueActive)}; transition: width 0.2s ease; } .progressBarFill.indeterminate { width: 34%; transition: none; animation: indeterminateSlide 1.2s ease-in-out infinite; } .statusPanel { margin-top: 10px; height: calc(var(--status-rows, 3) * 1.35em + 16px); min-height: calc(var(--status-rows, 3) * 1.35em + 16px); padding: 8px 10px; box-sizing: border-box; border-radius: 8px; border: 1px solid ${cssManager.bdTheme('hsl(210 20% 86%)', 'hsl(210 10% 26%)')}; background: ${cssManager.bdTheme('hsl(210 33% 98%)', 'hsl(220 20% 10%)')}; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; font-size: 12px; line-height: 1.35; overflow: hidden; } .statusTextRow, .terminalLine { display: flex; align-items: flex-start; gap: 8px; min-height: 1.35em; } .terminalScroller { height: 100%; overflow: auto; } .terminalScroller::-webkit-scrollbar { width: 6px; } .terminalScroller::-webkit-scrollbar-thumb { background: ${cssManager.bdTheme('hsl(215 18% 78%)', 'hsl(215 10% 34%)')}; border-radius: 999px; } .terminalScroller::-webkit-scrollbar-track { background: transparent; } .linePrefix { width: 1ch; flex: 0 0 1ch; color: ${cssManager.bdTheme(colors.dark.blueActive, colors.bright.blueActive)}; text-align: center; } .lineText { flex: 1; min-width: 0; color: ${cssManager.bdTheme('hsl(220 15% 25%)', 'hsl(210 15% 86%)')}; white-space: pre-wrap; word-break: break-word; } .terminalLine:not(.current) .lineText { color: ${cssManager.bdTheme('hsl(215 12% 42%)', 'hsl(215 12% 63%)')}; } @keyframes indeterminateSlide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } } `, ]; public async connectedCallback(): Promise { await super.connectedCallback(); this.syncSpinnerState(); } public async disconnectedCallback(): Promise { this.stopSpinner(); await super.disconnectedCallback(); } public updated(changedProperties: Map): void { super.updated(changedProperties); this.syncSpinnerState(); if (changedProperties.has('terminalLines') && this.terminalLines.length > 0) { this.scrollTerminalToBottom(); } } public render() { const effectivePercentage = this.getEffectivePercentage(); const showHeader = Boolean(this.label) || (this.showPercentage && !this.indeterminate); const hasTerminalLines = this.terminalLines.length > 0; const hasStatusContent = hasTerminalLines || this.statusText.trim().length > 0; const renderedRows = this.getRenderedStatusRows(); const spinnerFrame = this.spinnerFrames[this.activeSpinnerFrame] ?? this.spinnerFrames[0]; return html`
${showHeader ? html`
${this.label}
${this.showPercentage && !this.indeterminate ? html`
${this.formatPercentage(effectivePercentage)}%
` : ''}
` : ''}
${hasStatusContent ? html`
${hasTerminalLines ? html`
${this.terminalLines.map((line, index) => { const isCurrentLine = index === this.terminalLines.length - 1; const prefix = this.indeterminate && isCurrentLine ? spinnerFrame : '>'; return html`
${prefix} ${line}
`; })}
` : html`
${this.indeterminate ? spinnerFrame : '>'} ${this.statusText}
`}
` : ''}
`; } private getEffectivePercentage(): number { if (typeof this.value === 'number' && Number.isFinite(this.value)) { return this.clampPercentage(this.value); } if (typeof this.progress === 'number' && Number.isFinite(this.progress)) { const normalizedProgress = this.progress >= 0 && this.progress <= 1 ? this.progress * 100 : this.progress; return this.clampPercentage(normalizedProgress); } return this.clampPercentage(this.percentage); } private getRenderedStatusRows(): number { const rows = Number.isFinite(this.statusRows) ? Math.floor(this.statusRows) : 3; return Math.max(1, rows); } private clampPercentage(input: number): number { return Math.max(0, Math.min(100, input)); } private formatPercentage(input: number): string { return Number.isInteger(input) ? `${input}` : input.toFixed(1).replace(/\.0$/, ''); } private syncSpinnerState(): void { const shouldAnimate = this.indeterminate && (this.statusText.trim().length > 0 || this.terminalLines.length > 0); if (shouldAnimate && this.spinnerIntervalId === null) { this.spinnerIntervalId = window.setInterval(() => { this.activeSpinnerFrame = (this.activeSpinnerFrame + 1) % this.spinnerFrames.length; }, 120); return; } if (!shouldAnimate) { this.stopSpinner(); } } private stopSpinner(): void { if (this.spinnerIntervalId !== null) { window.clearInterval(this.spinnerIntervalId); this.spinnerIntervalId = null; } this.activeSpinnerFrame = 0; } private scrollTerminalToBottom(): void { const terminalScroller = this.shadowRoot?.querySelector('.terminalScroller') as HTMLElement | null; if (!terminalScroller) { return; } window.requestAnimationFrame(() => { terminalScroller.scrollTop = terminalScroller.scrollHeight; }); } }