import { expect, fixture, html } from '@open-wc/testing';
import './nile-segmented-progress-bar';
import type {
NileProgressSegment,
NileSegmentedProgressBar,
} from './nile-segmented-progress-bar';
/** The reference "Run outcomes" data — sum 98365, with two sub-1% slivers. */
const RUN_OUTCOMES: NileProgressSegment[] = [
{ label: 'Success', value: 45623 },
{ label: 'Failure', value: 50342 },
{ label: 'Errors', value: 682 },
{ label: 'Running', value: 166 },
{ label: 'Other', value: 1552 },
];
const SUM = 98365;
interface SegmentClickDetail {
segment: NileProgressSegment;
index: number;
percentage: number;
}
const segmentsOf = (el: NileSegmentedProgressBar): HTMLElement[] =>
Array.from(el.shadowRoot!.querySelectorAll('[part="segment"]'));
const trackOf = (el: NileSegmentedProgressBar): HTMLElement =>
el.shadowRoot!.querySelector('[part~="track"]')!;
const legendItemsOf = (el: NileSegmentedProgressBar): HTMLElement[] =>
Array.from(
el.shadowRoot!.querySelectorAll('[part="legend-item"]')
);
const tooltipsOf = (el: NileSegmentedProgressBar): HTMLElement[] =>
Array.from(el.shadowRoot!.querySelectorAll('nile-lite-tooltip'));
/** Mounts a bar inside a fixed-width wrapper so layout assertions are stable. */
async function mount(
width: number,
segments: NileProgressSegment[] = RUN_OUTCOMES,
minSegmentWidth = 2
): Promise {
const wrapper = await fixture(html`
`);
const el = wrapper.querySelector(
'nile-segmented-progress-bar'
) as NileSegmentedProgressBar;
await el.updateComplete;
return el;
}
/** Runs `fn` with `console.error` captured, then restores it. */
async function captureErrors(fn: () => Promise): Promise {
const errors: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.map(arg => String(arg)).join(' '));
};
try {
await fn();
} finally {
console.error = original;
}
return errors;
}
describe('NileSegmentedProgressBar', () => {
/* #region Boilerplate */
it('1. renders', async () => {
const el = await fixture(
html``
);
expect(el).to.exist;
});
it('2. shadow root', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot).to.not.be.null;
});
it('3. tag name', async () => {
const el = await fixture(
html``
);
expect(el.tagName.toLowerCase()).to.equal('nile-segmented-progress-bar');
});
it('4. is defined', async () => {
expect(customElements.get('nile-segmented-progress-bar')).to.exist;
});
it('5. shadow mode is open', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.mode).to.equal('open');
});
/* #endregion */
/* #region Property defaults */
it('6. segments defaults to an empty array', async () => {
const el = await fixture(
html``
);
expect(el.segments).to.deep.equal([]);
});
it('7. label defaults empty', async () => {
const el = await fixture(
html``
);
expect(el.label).to.equal('');
});
it('8. showLegend defaults true', async () => {
const el = await fixture(
html``
);
expect(el.showLegend).to.be.true;
});
it('9. showTooltip defaults true', async () => {
const el = await fixture(
html``
);
expect(el.showTooltip).to.be.true;
});
it('10. showValues defaults true', async () => {
const el = await fixture(
html``
);
expect(el.showValues).to.be.true;
});
it('11. size defaults medium', async () => {
const el = await fixture(
html``
);
expect(el.size).to.equal('medium');
});
it('12. total defaults undefined', async () => {
const el = await fixture(
html``
);
expect(el.total).to.be.undefined;
});
it('13. rounded defaults true', async () => {
const el = await fixture(
html``
);
expect(el.rounded).to.be.true;
});
it('14. minSegmentWidth defaults 2', async () => {
const el = await fixture(
html``
);
expect(el.minSegmentWidth).to.equal(2);
});
it('15. tooltipPlacement defaults top', async () => {
const el = await fixture(
html``
);
expect(el.tooltipPlacement).to.equal('top');
});
/* #endregion */
/* #region Attributes */
it('16. label reflects to the label attribute', async () => {
const el = await fixture(
html``
);
el.label = 'Run outcomes';
await el.updateComplete;
expect(el.getAttribute('label')).to.equal('Run outcomes');
});
it('17. size reflects to the size attribute', async () => {
const el = await fixture(
html``
);
el.size = 'large';
await el.updateComplete;
expect(el.getAttribute('size')).to.equal('large');
});
it('18. size attribute sets the property', async () => {
const el = await fixture(
html``
);
expect(el.size).to.equal('small');
});
it('19. show-legend attribute maps to showLegend', async () => {
const el = await fixture(
html``
);
expect(el.showLegend).to.be.true;
});
it('20. show-tooltip attribute maps to showTooltip', async () => {
const el = await fixture(
html``
);
expect(el.showTooltip).to.be.true;
});
it('21. show-values attribute maps to showValues', async () => {
const el = await fixture(
html``
);
expect(el.showValues).to.be.true;
});
it('22. min-segment-width attribute maps to minSegmentWidth', async () => {
const el = await fixture(
html``
);
expect(el.minSegmentWidth).to.equal(6);
});
it('23. tooltip-placement attribute maps to tooltipPlacement', async () => {
const el = await fixture(
html``
);
expect(el.tooltipPlacement).to.equal('bottom');
});
it('24. total attribute maps to total as a number', async () => {
const el = await fixture(
html``
);
expect(el.total).to.equal(200);
});
/* #endregion */
/* #region Parts */
it('25. container part', async () => {
const el = await mount(800);
expect(el.shadowRoot!.querySelector('[part="container"]')).to.exist;
});
it('26. label part renders when label is set', async () => {
const el = await mount(800);
const label = el.shadowRoot!.querySelector('[part="label"]');
expect(label!.textContent!.trim()).to.equal('Run outcomes');
});
it('27. label part absent when label is empty', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('[part="label"]')).to.be.null;
});
it('28. bar and track are the same element', async () => {
const el = await mount(800);
const bar = el.shadowRoot!.querySelector('[part~="bar"]');
expect(bar).to.equal(trackOf(el));
});
it('29. segment parts', async () => {
const el = await mount(800);
expect(segmentsOf(el).length).to.equal(5);
});
it('30. legend part', async () => {
const el = await mount(800);
expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist;
});
it('31. legend-item parts', async () => {
const el = await mount(800);
expect(legendItemsOf(el).length).to.equal(5);
});
it('32. legend-swatch part', async () => {
const el = await mount(800);
expect(
el.shadowRoot!.querySelectorAll('[part="legend-swatch"]').length
).to.equal(5);
});
it('33. legend-label part text', async () => {
const el = await mount(800);
const labels = Array.from(
el.shadowRoot!.querySelectorAll('[part="legend-label"]')
).map(node => node.textContent!.trim());
expect(labels).to.deep.equal([
'Success',
'Failure',
'Errors',
'Running',
'Other',
]);
});
it('34. legend-value part text', async () => {
const el = await mount(800);
const values = Array.from(
el.shadowRoot!.querySelectorAll('[part="legend-value"]')
).map(node => node.textContent!.trim());
expect(values).to.deep.equal([
'45623',
'50342',
'682',
'166',
'1552',
]);
});
/* #endregion */
/* #region Classes and sizes */
it('35. small size class', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--small')).to
.exist;
});
it('36. medium size class by default', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--medium')).to
.exist;
});
it('37. large size class', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to
.exist;
});
it('38. rounded class present by default', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to
.exist;
});
it('39. rounded class removed when rounded is false', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to
.be.null;
});
it('40. palette class rotates by index', async () => {
const el = await mount(800);
const classes = segmentsOf(el).map(segment =>
Array.from(segment.classList).find(name => name.includes('palette'))
);
expect(classes).to.deep.equal([
'segmented-progress-bar__palette-0',
'segmented-progress-bar__palette-1',
'segmented-progress-bar__palette-2',
'segmented-progress-bar__palette-3',
'segmented-progress-bar__palette-4',
]);
});
it('41. palette wraps after 10 segments', async () => {
const many = Array.from({ length: 11 }, (_, index) => ({
label: `S${index}`,
value: 10,
}));
const el = await mount(800, many);
expect(segmentsOf(el)[10].classList.contains(
'segmented-progress-bar__palette-0'
)).to.be.true;
});
it('42. custom color suppresses the palette class', async () => {
const el = await mount(800, [
{ label: 'A', value: 1, color: 'rgb(255, 0, 0)' },
]);
const segment = segmentsOf(el)[0];
expect(segment.className).to.not.contain('palette');
expect(segment.style.backgroundColor).to.equal('rgb(255, 0, 0)');
});
it('43. no custom color leaves inline background unset', async () => {
const el = await mount(800);
expect(segmentsOf(el)[0].style.backgroundColor).to.equal('');
});
it('44. custom color is applied to the legend swatch', async () => {
const el = await mount(800, [
{ label: 'A', value: 1, color: 'rgb(0, 128, 0)' },
]);
const swatch = el.shadowRoot!.querySelector(
'[part="legend-swatch"]'
)!;
expect(swatch.style.backgroundColor).to.equal('rgb(0, 128, 0)');
});
/* #endregion */
/* #region Proportional math */
it('45. reference data flex-basis percentages', async () => {
const el = await mount(800);
const bases = segmentsOf(el).map(segment =>
parseFloat(segment.style.flexBasis)
);
RUN_OUTCOMES.forEach((segment, index) => {
expect(bases[index]).to.be.closeTo((segment.value / SUM) * 100, 0.01);
});
});
it('46. reference data flex-basis sums to 100%', async () => {
const el = await mount(800);
const total = segmentsOf(el).reduce(
(sum, segment) => sum + parseFloat(segment.style.flexBasis),
0
);
expect(total).to.be.closeTo(100, 0.01);
});
it('47. omitted total means the segments fill 100%', async () => {
const el = await mount(800, [
{ label: 'A', value: 30 },
{ label: 'B', value: 70 },
]);
const bases = segmentsOf(el).map(segment =>
parseFloat(segment.style.flexBasis)
);
expect(bases).to.deep.equal([30, 70]);
});
it('48. total larger than the sum leaves an unfilled remainder', async () => {
const el = await fixture(
html``
);
const bases = segmentsOf(el).map(segment =>
parseFloat(segment.style.flexBasis)
);
expect(bases).to.deep.equal([15, 35]);
});
it('49. total smaller than the sum falls back to the sum', async () => {
let el!: NileSegmentedProgressBar;
const errors = await captureErrors(async () => {
el = await fixture(
html``
);
});
const bases = segmentsOf(el).map(segment =>
parseFloat(segment.style.flexBasis)
);
expect(bases).to.deep.equal([30, 70]);
expect(errors.join('\n')).to.contain('Invalid total (50)');
});
it('50. total equal to the sum fills 100%', async () => {
const el = await fixture(
html``
);
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100);
});
it('51. only the largest segment may shrink', async () => {
const el = await mount(800);
const shrinks = segmentsOf(el).map(segment => segment.style.flexShrink);
expect(shrinks).to.deep.equal(['0', '1', '0', '0', '0']);
});
it('52. the largest segment has no min-width floor', async () => {
const el = await mount(800);
const minWidths = segmentsOf(el).map(segment => segment.style.minWidth);
expect(minWidths).to.deep.equal(['2px', '0px', '2px', '2px', '2px']);
});
it('53. minSegmentWidth is honoured on non-largest segments', async () => {
const el = await mount(800, RUN_OUTCOMES, 8);
const minWidths = segmentsOf(el).map(segment => segment.style.minWidth);
expect(minWidths).to.deep.equal(['8px', '0px', '8px', '8px', '8px']);
});
it('54. minSegmentWidth of 0 removes the floor', async () => {
const el = await mount(800, RUN_OUTCOMES, 0);
const minWidths = segmentsOf(el).map(segment => segment.style.minWidth);
expect(minWidths).to.deep.equal(['0px', '0px', '0px', '0px', '0px']);
});
it('55. negative minSegmentWidth is clamped to 0', async () => {
const el = await mount(800, RUN_OUTCOMES, -10);
expect(segmentsOf(el)[0].style.minWidth).to.equal('0px');
});
it('56. a single segment fills the whole bar', async () => {
const el = await mount(800, [{ label: 'Only', value: 5 }]);
const segments = segmentsOf(el);
expect(segments.length).to.equal(1);
expect(parseFloat(segments[0].style.flexBasis)).to.equal(100);
expect(segments[0].style.flexShrink).to.equal('1');
});
/* #endregion */
/* #region Layout — no overflow, no wrap */
[800, 360, 240].forEach((width, offset) => {
it(`${57 + offset}. track does not overflow at ${width}px`, async () => {
const el = await mount(width);
const track = trackOf(el);
expect(track.scrollWidth).to.equal(track.clientWidth);
});
});
it('60. tiny segments still add up to the full track width', async () => {
const el = await mount(360);
const track = trackOf(el);
const rendered = segmentsOf(el).reduce(
(sum, segment) => sum + segment.getBoundingClientRect().width,
0
);
expect(rendered).to.be.closeTo(track.getBoundingClientRect().width, 0.5);
});
it('61. segments never wrap onto a second row', async () => {
const el = await mount(240);
const tops = segmentsOf(el).map(
segment => Math.round(segment.getBoundingClientRect().top * 10) / 10
);
expect(new Set(tops).size).to.equal(1);
});
it('62. the first segment starts at the track edge and the last ends at it', async () => {
const el = await mount(800);
const track = trackOf(el).getBoundingClientRect();
const segments = segmentsOf(el);
const first = segments[0].getBoundingClientRect();
const last = segments[segments.length - 1].getBoundingClientRect();
expect(first.left).to.be.closeTo(track.left, 0.5);
expect(last.right).to.be.closeTo(track.right, 0.5);
});
it('63. sub-1% segments stay at least minSegmentWidth wide', async () => {
const el = await mount(240);
const running = segmentsOf(el)[3].getBoundingClientRect().width;
expect(running).to.be.at.least(2);
});
it('64. a large minSegmentWidth still does not overflow', async () => {
const el = await mount(240, RUN_OUTCOMES, 12);
const track = trackOf(el);
expect(track.scrollWidth).to.equal(track.clientWidth);
});
it('65. many tiny segments against one dominant one do not overflow', async () => {
const el = await mount(240, [
{ label: 'Dominant', value: 99000 },
{ label: 'A', value: 40 },
{ label: 'B', value: 25 },
{ label: 'C', value: 12 },
{ label: 'D', value: 8 },
{ label: 'E', value: 3 },
{ label: 'F', value: 1 },
]);
const track = trackOf(el);
expect(track.scrollWidth).to.equal(track.clientWidth);
});
/* #endregion */
/* #region Zero, empty and invalid data */
it('66. zero-value segments render no bar segment', async () => {
const el = await mount(800, [
{ label: 'Success', value: 512 },
{ label: 'Failure', value: 0 },
{ label: 'Errors', value: 0 },
{ label: 'Running', value: 64 },
]);
expect(segmentsOf(el).length).to.equal(2);
});
it('67. zero-value segments still appear in the legend', async () => {
const el = await mount(800, [
{ label: 'Success', value: 512 },
{ label: 'Failure', value: 0 },
{ label: 'Errors', value: 0 },
{ label: 'Running', value: 64 },
]);
expect(legendItemsOf(el).length).to.equal(4);
});
it('68. an empty segments array renders without throwing', async () => {
const el = await mount(800, []);
expect(segmentsOf(el).length).to.equal(0);
expect(legendItemsOf(el).length).to.equal(0);
expect(trackOf(el)).to.exist;
});
it('69. an all-zero array renders an empty track and a full legend', async () => {
const el = await mount(800, [
{ label: 'A', value: 0 },
{ label: 'B', value: 0 },
{ label: 'C', value: 0 },
]);
expect(segmentsOf(el).length).to.equal(0);
expect(legendItemsOf(el).length).to.equal(3);
});
it('70. negative values are clamped to 0', async () => {
let el!: NileSegmentedProgressBar;
const errors = await captureErrors(async () => {
el = await mount(800, [
{ label: 'A', value: -5 },
{ label: 'B', value: 10 },
]);
});
expect(segmentsOf(el).length).to.equal(1);
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100);
expect(errors.join('\n')).to.contain(
'Invalid value (-5) detected at segment index 0'
);
});
it('71. NaN values are guarded to 0', async () => {
let el!: NileSegmentedProgressBar;
const errors = await captureErrors(async () => {
el = await mount(800, [
{ label: 'A', value: Number.NaN },
{ label: 'B', value: 10 },
]);
});
expect(segmentsOf(el).length).to.equal(1);
expect(errors.join('\n')).to.contain(
'Invalid value (NaN) detected at segment index 0'
);
});
it('72. clamped values still render as 0 in the legend', async () => {
let el!: NileSegmentedProgressBar;
await captureErrors(async () => {
el = await mount(800, [
{ label: 'A', value: -5 },
{ label: 'B', value: 10 },
]);
});
const values = Array.from(
el.shadowRoot!.querySelectorAll('[part="legend-value"]')
).map(node => node.textContent!.trim());
expect(values).to.deep.equal(['0', '10']);
});
it('73. Infinity is guarded to 0', async () => {
let el!: NileSegmentedProgressBar;
const errors = await captureErrors(async () => {
el = await mount(800, [
{ label: 'A', value: Number.POSITIVE_INFINITY },
{ label: 'B', value: 10 },
]);
});
expect(segmentsOf(el).length).to.equal(1);
expect(errors.join('\n')).to.contain('Invalid value (Infinity)');
});
it('74. a negative total is reported and ignored', async () => {
let el!: NileSegmentedProgressBar;
const errors = await captureErrors(async () => {
el = await fixture(
html``
);
});
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100);
expect(errors.join('\n')).to.contain('Invalid total (-1)');
});
it('75. a valid config logs nothing', async () => {
const errors = await captureErrors(async () => {
await mount(800);
});
expect(errors).to.deep.equal([]);
});
/* #endregion */
/* #region Percentage formatting */
it('76. percentages render to one decimal', async () => {
const el = await mount(800);
expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal(
'Success: 45623 (46.4%)'
);
});
it('77. sub-1% percentages keep their decimal', async () => {
const el = await mount(800);
expect(segmentsOf(el)[3].getAttribute('aria-label')).to.equal(
'Running: 166 (0.2%)'
);
});
it('78. whole percentages are trimmed', async () => {
const el = await mount(800, [
{ label: 'A', value: 1 },
{ label: 'B', value: 1 },
]);
expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal('A: 1 (50%)');
});
it('79. a 100% segment is trimmed to 100%', async () => {
const el = await mount(800, [{ label: 'A', value: 7 }]);
expect(segmentsOf(el)[0].getAttribute('aria-label')).to.equal('A: 7 (100%)');
});
it('80. zero segments describe themselves as 0%', async () => {
const el = await mount(800, [
{ label: 'A', value: 0 },
{ label: 'B', value: 10 },
]);
expect(legendItemsOf(el)[0].getAttribute('aria-label')).to.equal(
'A: 0 (0%)'
);
});
/* #endregion */
/* #region Accessibility */
it('81. the track is a group, not an img', async () => {
const el = await mount(800);
expect(trackOf(el).getAttribute('role')).to.equal('group');
});
it('82. the track carries the whole distribution as its accessible name', async () => {
const el = await mount(800);
expect(trackOf(el).getAttribute('aria-label')).to.equal(
'Run outcomes: Success: 45623 (46.4%), Failure: 50342 (51.2%), Errors: 682 (0.7%), Running: 166 (0.2%), Other: 1552 (1.6%)'
);
});
it('83. the track summary omits the prefix when there is no label', async () => {
const el = await fixture(
html``
);
expect(trackOf(el).getAttribute('aria-label')).to.equal('A: 1 (100%)');
});
it('84. the track summary reads "No data" when empty', async () => {
const el = await mount(800, []);
expect(trackOf(el).getAttribute('aria-label')).to.equal(
'Run outcomes: No data'
);
});
it('85. segments are focusable buttons', async () => {
const el = await mount(800);
segmentsOf(el).forEach(segment => {
expect(segment.getAttribute('role')).to.equal('button');
expect(segment.getAttribute('tabindex')).to.equal('0');
});
});
it('86. legend items are focusable buttons', async () => {
const el = await mount(800);
legendItemsOf(el).forEach(item => {
expect(item.getAttribute('role')).to.equal('button');
expect(item.getAttribute('tabindex')).to.equal('0');
});
});
it('87. legend swatches are hidden from assistive tech', async () => {
const el = await mount(800);
const swatches = el.shadowRoot!.querySelectorAll('[part="legend-swatch"]');
Array.from(swatches).forEach(swatch => {
expect(swatch.getAttribute('aria-hidden')).to.equal('true');
});
});
it('88. segments carry their config index', async () => {
const el = await mount(800);
expect(
segmentsOf(el).map(segment => segment.getAttribute('data-index'))
).to.deep.equal(['0', '1', '2', '3', '4']);
});
it('89. no native buttons are rendered', async () => {
const el = await mount(800);
expect(el.shadowRoot!.querySelectorAll('button').length).to.equal(0);
});
/* #endregion */
/* #region Tooltips */
it('90. every rendered segment is wrapped in a tooltip', async () => {
const el = await mount(800);
expect(tooltipsOf(el).length).to.equal(5);
});
it('91. tooltip content defaults to the segment description', async () => {
const el = await mount(800);
expect(tooltipsOf(el)[0].getAttribute('content')).to.equal(
'Success: 45623 (46.4%)'
);
});
it('92. a per-segment tooltip overrides the default', async () => {
const el = await mount(800, [
{ label: 'Documents', value: 4200, tooltip: '4.2 GB of documents' },
{ label: 'Media', value: 8800 },
]);
const tooltips = tooltipsOf(el);
expect(tooltips[0].getAttribute('content')).to.equal(
'4.2 GB of documents'
);
expect(tooltips[1].getAttribute('content')).to.equal(
'Media: 8800 (67.7%)'
);
});
it('93. an empty tooltip override falls back to the default', async () => {
const el = await mount(800, [{ label: 'A', value: 1, tooltip: '' }]);
expect(tooltipsOf(el)[0].getAttribute('content')).to.equal('A: 1 (100%)');
});
it('94. tooltipPlacement is forwarded', async () => {
const el = await fixture(
html``
);
expect(tooltipsOf(el)[0].getAttribute('placement')).to.equal('bottom');
});
it('95. showTooltip false creates no tooltip wrappers at all', async () => {
const el = await fixture(
html``
);
expect(tooltipsOf(el).length).to.equal(0);
expect(segmentsOf(el).length).to.equal(5);
});
it('96. tooltips are display:contents so they never become flex items', async () => {
const el = await mount(800);
const tooltip = tooltipsOf(el)[0];
expect(getComputedStyle(tooltip).display).to.equal('contents');
});
it('97. the tooltip wrapper is the segment parent inside the track', async () => {
const el = await mount(800);
const segment = segmentsOf(el)[0];
expect(segment.parentElement!.tagName.toLowerCase()).to.equal(
'nile-lite-tooltip'
);
expect(segment.parentElement!.parentElement).to.equal(trackOf(el));
});
it('98. the tooltip layer does not break the 100% layout', async () => {
const el = await mount(360);
const track = trackOf(el);
expect(track.scrollWidth).to.equal(track.clientWidth);
const rendered = segmentsOf(el).reduce(
(sum, segment) => sum + segment.getBoundingClientRect().width,
0
);
expect(rendered).to.be.closeTo(track.getBoundingClientRect().width, 0.5);
});
/* #endregion */
/* #region Events */
it('99. clicking a segment emits nile-segment-click once', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
segmentsOf(el)[2].click();
expect(events.length).to.equal(1);
});
it('100. the click payload carries segment, index and raw percentage', async () => {
const el = await mount(800);
const details: SegmentClickDetail[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
details.push((event as CustomEvent).detail as SegmentClickDetail);
});
segmentsOf(el)[0].click();
expect(details[0].segment).to.equal(el.segments[0]);
expect(details[0].index).to.equal(0);
expect(details[0].percentage).to.equal((45623 / SUM) * 100);
});
it('101. the percentage in the payload is unrounded', async () => {
const el = await mount(800);
let percentage = 0;
el.addEventListener('nile-segment-click', (event: Event) => {
percentage = (event as CustomEvent).detail.percentage;
});
segmentsOf(el)[3].click();
expect(percentage).to.equal((166 / SUM) * 100);
expect(percentage).to.not.equal(0.2);
});
it('102. clicking a legend item emits the same payload', async () => {
const el = await mount(800);
const details: SegmentClickDetail[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
details.push((event as CustomEvent).detail as SegmentClickDetail);
});
legendItemsOf(el)[4].click();
expect(details.length).to.equal(1);
expect(details[0].index).to.equal(4);
expect(details[0].percentage).to.equal((1552 / SUM) * 100);
});
it('103. clicking a zero-value legend item still emits', async () => {
const el = await mount(800, [
{ label: 'A', value: 0 },
{ label: 'B', value: 10 },
]);
const details: SegmentClickDetail[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
details.push((event as CustomEvent).detail as SegmentClickDetail);
});
legendItemsOf(el)[0].click();
expect(details[0].index).to.equal(0);
expect(details[0].percentage).to.equal(0);
});
it('104. Enter on a segment emits exactly once', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
segmentsOf(el)[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(events.length).to.equal(1);
expect(events[0].detail.index).to.equal(1);
});
it('105. Space on a segment emits exactly once', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
segmentsOf(el)[1].dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true })
);
expect(events.length).to.equal(1);
});
it('106. legacy Spacebar key also activates', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
segmentsOf(el)[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Spacebar', bubbles: true })
);
expect(events.length).to.equal(1);
});
it('107. Enter on a legend item emits once', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
legendItemsOf(el)[2].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(events.length).to.equal(1);
expect(events[0].detail.index).to.equal(2);
});
it('108. Space is prevented so the page does not scroll', async () => {
const el = await mount(800);
const event = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
segmentsOf(el)[0].dispatchEvent(event);
expect(event.defaultPrevented).to.be.true;
});
it('109. other keys do not activate a segment', async () => {
const el = await mount(800);
const events: CustomEvent[] = [];
el.addEventListener('nile-segment-click', (event: Event) => {
events.push(event as CustomEvent);
});
['a', 'Tab', 'Escape', 'ArrowRight'].forEach(key => {
segmentsOf(el)[0].dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
);
});
expect(events.length).to.equal(0);
});
it('110. the event bubbles and is composed', async () => {
const el = await mount(800);
const received: Event[] = [];
const listener = (event: Event) => received.push(event);
document.addEventListener('nile-segment-click', listener);
try {
segmentsOf(el)[0].click();
} finally {
document.removeEventListener('nile-segment-click', listener);
}
expect(received.length).to.equal(1);
expect(received[0].bubbles).to.be.true;
expect(received[0].composed).to.be.true;
});
/* #endregion */
/* #region Toggles */
it('111. showLegend false removes the legend', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('[part="legend"]')).to.be.null;
expect(segmentsOf(el).length).to.equal(5);
});
it('112. showValues false removes only the legend values', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelectorAll('[part="legend-value"]').length).to
.equal(0);
expect(el.shadowRoot!.querySelectorAll('[part="legend-label"]').length).to
.equal(5);
expect(legendItemsOf(el).length).to.equal(5);
});
/* #endregion */
/* #region Dynamic updates */
it('113. updating segments re-renders the bar', async () => {
const el = await mount(800, [{ label: 'A', value: 1 }]);
expect(segmentsOf(el).length).to.equal(1);
el.segments = RUN_OUTCOMES;
await el.updateComplete;
expect(segmentsOf(el).length).to.equal(5);
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.be.closeTo(
46.381,
0.01
);
});
it('114. updating total re-renders the proportions', async () => {
const el = await mount(800, [{ label: 'A', value: 50 }]);
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(100);
el.total = 200;
await el.updateComplete;
expect(parseFloat(segmentsOf(el)[0].style.flexBasis)).to.equal(25);
});
it('115. updating size swaps the size class', async () => {
const el = await mount(800);
el.size = 'large';
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to
.exist;
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--medium')).to
.be.null;
});
it('116. updating label adds the label part', async () => {
const el = await fixture(
html``
);
expect(el.shadowRoot!.querySelector('[part="label"]')).to.be.null;
el.label = 'Later';
await el.updateComplete;
expect(
el.shadowRoot!.querySelector('[part="label"]')!.textContent!.trim()
).to.equal('Later');
});
it('117. toggling showLegend at runtime adds and removes the legend', async () => {
const el = await mount(800);
expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist;
el.showLegend = false;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('[part="legend"]')).to.be.null;
el.showLegend = true;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('[part="legend"]')).to.exist;
});
it('118. toggling showTooltip at runtime adds and removes the wrappers', async () => {
const el = await mount(800);
expect(tooltipsOf(el).length).to.equal(5);
el.showTooltip = false;
await el.updateComplete;
expect(tooltipsOf(el).length).to.equal(0);
el.showTooltip = true;
await el.updateComplete;
expect(tooltipsOf(el).length).to.equal(5);
});
it('119. toggling rounded at runtime swaps the class', async () => {
const el = await mount(800);
el.rounded = false;
await el.updateComplete;
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--rounded')).to
.be.null;
});
it('120. updating minSegmentWidth re-applies the floors', async () => {
const el = await mount(800);
el.minSegmentWidth = 10;
await el.updateComplete;
expect(segmentsOf(el)[2].style.minWidth).to.equal('10px');
const track = trackOf(el);
expect(track.scrollWidth).to.equal(track.clientWidth);
});
it('121. the largest index follows the data', async () => {
const el = await mount(800, [
{ label: 'A', value: 10 },
{ label: 'B', value: 90 },
]);
expect(segmentsOf(el).map(segment => segment.style.flexShrink)).to.deep.equal([
'0',
'1',
]);
el.segments = [
{ label: 'A', value: 90 },
{ label: 'B', value: 10 },
];
await el.updateComplete;
expect(segmentsOf(el).map(segment => segment.style.flexShrink)).to.deep.equal([
'1',
'0',
]);
});
it('122. going from data to an empty array clears the track', async () => {
const el = await mount(800);
el.segments = [];
await el.updateComplete;
expect(segmentsOf(el).length).to.equal(0);
expect(trackOf(el)).to.exist;
});
it('123. a non-array segments value is tolerated', async () => {
const el = await mount(800);
(el as unknown as { segments: unknown }).segments = null;
await el.updateComplete;
expect(segmentsOf(el).length).to.equal(0);
expect(trackOf(el)).to.exist;
});
/* #endregion */
/* #region Integration */
it('124. a non-array, non-nullish segments value is tolerated', async () => {
const el = await mount(800);
(el as unknown as { segments: unknown }).segments = {};
await el.updateComplete;
expect(segmentsOf(el).length).to.equal(0);
expect(trackOf(el).getAttribute('aria-label')).to.equal(
'Run outcomes: No data'
);
});
it('125. multiple instances are independent', async () => {
const wrapper = await fixture(html`
`);
const bars = Array.from(
wrapper.querySelectorAll(
'nile-segmented-progress-bar'
)
);
await Promise.all(bars.map(bar => bar.updateComplete));
expect(segmentsOf(bars[0]).length).to.equal(1);
expect(segmentsOf(bars[1]).length).to.equal(5);
});
it('126. full reference configuration', async () => {
const el = await fixture(html`
`);
expect(el.getAttribute('label')).to.equal('Run outcomes');
expect(el.getAttribute('size')).to.equal('large');
expect(segmentsOf(el).length).to.equal(5);
expect(legendItemsOf(el).length).to.equal(5);
expect(tooltipsOf(el).length).to.equal(5);
expect(el.shadowRoot!.querySelector('.segmented-progress-bar--large')).to
.exist;
expect(trackOf(el).getAttribute('role')).to.equal('group');
});
/* #endregion */
});