/**
* 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;
}
}