/** * Copyright Aquera Inc 2026 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { html, nothing } from 'lit'; import { styles } from './nile-segmented-progress-bar.css'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { customElement, property } from 'lit/decorators.js'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import '../nile-lite-tooltip/index'; /** A single slice of the distribution rendered by `nile-segmented-progress-bar`. */ export interface NileProgressSegment { /** Legend label, also used in the default tooltip text. */ label: string; /** Raw magnitude of this segment. */ value: number; /** Any CSS color; falls back to the palette rotation when omitted. */ color?: string; /** Overrides the default tooltip content for this segment. */ tooltip?: string; } /** Placement values forwarded to `nile-lite-tooltip`. */ export type NileSegmentTooltipPlacement = | 'top' | 'top-start' | 'top-end' | 'right' | 'right-start' | 'right-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'auto' | 'auto-start' | 'auto-end'; /** The number of palette classes declared in the stylesheet. */ const PALETTE_LENGTH = 10; /** * Nile segmented progress bar component. * * @tag nile-segmented-progress-bar * * @summary A config-driven, responsive multi-segment progress / distribution bar * with an optional wrapping legend — a single rounded track split into colored * proportional segments (for example Success / Failure / Errors / Running / Other). * @status experimental * * @event nile-segment-click - Emitted when a segment or its legend entry is activated by click, * `Enter` or `Space`. The `detail` contains `{ segment, index, percentage }`. * * @csspart container - The component's base wrapper. * @csspart label - The optional heading above the bar. * @csspart bar - The bar/track element (also exposed as `track`). * @csspart track - The bar/track element (also exposed as `bar`). * @csspart segment - An individual colored segment inside the track. * @csspart legend - The wrapping legend row. * @csspart legend-item - A single legend entry (swatch + label + value). * @csspart legend-swatch - The small rounded square in the segment color. * @csspart legend-label - The legend entry's label text. * @csspart legend-value - The legend entry's bold value. * * @example * */ @customElement('nile-segmented-progress-bar') export class NileSegmentedProgressBar extends NileElement { static styles: CSSResultGroup = styles; /** The segments to distribute across the bar. This is the component's config. */ @property({ type: Array }) segments: NileProgressSegment[] = []; /** Optional heading rendered above the bar. Nothing renders when empty. */ @property({ type: String, reflect: true }) label = ''; /** Renders the legend below the bar. */ @property({ type: Boolean, attribute: 'show-legend' }) showLegend = true; /** Wraps every segment in a `nile-lite-tooltip`. */ @property({ type: Boolean, attribute: 'show-tooltip' }) showTooltip = true; /** Renders the bold value alongside each legend label. */ @property({ type: Boolean, attribute: 'show-values' }) showValues = true; /** The thickness of the bar. */ @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; /** * Optional denominator. When omitted the denominator is the sum of the segment * values, so the segments always fill 100%. When larger than the sum, the * remainder renders as an unfilled track. */ @property({ type: Number }) total?: number; /** Rounds the bar's outer ends. */ @property({ type: Boolean }) rounded = true; /** * Minimum rendered width, in pixels, of a non-zero segment. Guarantees sub-1% * segments stay a discernible sliver. The largest segment absorbs whatever * overflow this introduces, so the row still totals exactly 100%. */ @property({ type: Number, attribute: 'min-segment-width' }) minSegmentWidth = 2; /** Passed straight through to `nile-lite-tooltip`'s `placement`. */ @property({ attribute: 'tooltip-placement' }) tooltipPlacement: NileSegmentTooltipPlacement = 'top'; /* #region Math */ /** Coerces a configured value into a usable, non-negative, finite number. */ private sanitizeValue(value: unknown): number { const numeric = Number(value); if (!Number.isFinite(numeric) || numeric < 0) return 0; return numeric; } /** The sanitized magnitude of every configured segment, in config order. */ private get values(): number[] { const list = Array.isArray(this.segments) ? this.segments : []; return list.map(segment => this.sanitizeValue(segment?.value)); } /** The sum of every sanitized segment value. */ private get sum(): number { return this.values.reduce((runningTotal, value) => runningTotal + value, 0); } /** The denominator the percentages are computed against. */ private get denominator(): number { const sum = this.sum; const total = Number(this.total); if (Number.isFinite(total) && total > sum) return total; return sum; } /** Each segment's share of the denominator, in config order. */ private get percentages(): number[] { const denominator = this.denominator; if (denominator <= 0) return this.values.map(() => 0); return this.values.map(value => (value / denominator) * 100); } /** * The index of the widest rendered segment. That segment is the one allowed to * shrink, so it absorbs the overflow introduced by `minSegmentWidth` on the * tiny ones and the row still adds up to exactly 100% of the track. */ private get largestIndex(): number { const values = this.values; let largest = -1; let largestValue = 0; values.forEach((value, index) => { if (value > largestValue) { largestValue = value; largest = index; } }); return largest; } /** Formats a percentage to one decimal, trimming a trailing `.0`. */ private formatPercentage(percentage: number): string { if (!Number.isFinite(percentage)) return '0'; const rounded = Math.round(percentage * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); } /* #endregion */ /* #region Text */ /** The default tooltip / aria text for a segment. */ private describeSegment(index: number): string { const segment = this.segments?.[index]; const value = this.values[index] ?? 0; const percentage = this.formatPercentage(this.percentages[index] ?? 0); return `${segment?.label ?? ''}: ${value} (${percentage}%)`; } /** The tooltip content for a segment, honouring a per-segment override. */ private tooltipFor(index: number): string { const override = this.segments?.[index]?.tooltip; return override && override.length > 0 ? override : this.describeSegment(index); } /** * A single sentence describing the whole distribution, used as the track's * accessible name so screen readers get the breakdown without tabbing it. */ private get accessibleSummary(): string { const list = Array.isArray(this.segments) ? this.segments : []; const parts = list.map((_, index) => this.describeSegment(index)); const body = parts.length > 0 ? parts.join(', ') : 'No data'; return this.label ? `${this.label}: ${body}` : body; } /* #endregion */ /* #region Validation */ override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('segments') || changedProperties.has('total')) { this.validateConfig(); } } /** Logs invalid config the way `nile-progress-bar` reports out-of-range values. */ private validateConfig(): void { const list = Array.isArray(this.segments) ? this.segments : []; list.forEach((segment, index) => { const numeric = Number(segment?.value); if (!Number.isFinite(numeric)) { console.error( `[NileSegmentedProgressBar]: Invalid value (${String( segment?.value )}) detected at segment index ${index}. Value must be a finite number. Falling back to 0.` ); return; } if (numeric < 0) { console.error( `[NileSegmentedProgressBar]: Invalid value (${numeric}) detected at segment index ${index}. Value must be greater than or equal to 0. Falling back to 0.` ); } }); const total = Number(this.total); if (this.total !== undefined && this.total !== null) { if (!Number.isFinite(total) || total < 0) { console.error( `[NileSegmentedProgressBar]: Invalid total (${String( this.total )}) detected. Total must be a finite number greater than or equal to 0. Falling back to the sum of the segment values.` ); } else if (total < this.sum) { console.error( `[NileSegmentedProgressBar]: Invalid total (${total}) detected. Total must be greater than or equal to the sum of the segment values (${this.sum}). Falling back to the sum.` ); } } } /* #endregion */ /* #region Interaction */ private activate(index: number): void { const segment = this.segments?.[index]; if (!segment) return; this.emit('nile-segment-click', { segment, index, percentage: this.percentages[index] ?? 0, }); } /** * `Enter` and `Space` activate a segment or legend entry. The affordances are * `role="button"` elements rather than native buttons, so activation is wired * up explicitly and a key press never double-fires alongside a synthetic click. */ private handleKeyDown(event: KeyboardEvent, index: number): void { if ( event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar' ) { return; } event.preventDefault(); this.activate(index); } /* #endregion */ /* #region Render */ private renderSegment(index: number): TemplateResult | typeof nothing { const segment = this.segments[index]; const percentage = this.percentages[index] ?? 0; if (percentage <= 0) return nothing; const isLargest = index === this.largestIndex; const minWidth = Math.max(0, Number(this.minSegmentWidth) || 0); const description = this.describeSegment(index); const bar = html`
this.activate(index)} @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event, index)} >
`; if (!this.showTooltip) return bar; return html` ${bar} `; } private renderLegendItem(index: number): TemplateResult { const segment = this.segments[index]; const value = this.values[index] ?? 0; return html`
this.activate(index)} @keydown=${(event: KeyboardEvent) => this.handleKeyDown(event, index)} > ${segment.label} ${this.showValues ? html`${value}` : nothing}
`; } render(): TemplateResult { const segments = Array.isArray(this.segments) ? this.segments : []; return html`
${this.label ? html`
${this.label}
` : nothing}
${segments.map((_, index) => this.renderSegment(index))}
${this.showLegend ? html`
${segments.map((_, index) => this.renderLegendItem(index))}
` : nothing}
`; } /* #endregion */ } export default NileSegmentedProgressBar; declare global { interface HTMLElementTagNameMap { 'nile-segmented-progress-bar': NileSegmentedProgressBar; } }