import { expect, fixture, html, oneEvent, aTimeout } from '@open-wc/testing'; import './nile-minimap'; import type { NileMinimap } from './nile-minimap'; /** * Tests for nile-minimap. Written from kb/specs/nile-minimap.md ahead of (or alongside) * the implementation. Every acceptance criterion maps to one or more numbered `it` blocks. * * Harness notes: * - The component batches DOM updates with requestAnimationFrame plus Resize/Mutation * observers, so after any change we await `el.updateComplete` and a couple of frames. * - Scroll assertions use `smooth = false` so the container jumps synchronously (modulo rAF). * - Drag is simulated with PointerEvent dispatch on the viewport part. `setPointerCapture` * can throw for synthetic pointer ids in some browsers; the component is expected to guard * that call, so these tests dispatch real-ish PointerEvents and tolerate capture no-ops. */ /** Wait for the component's batched rAF/observer work to settle. */ async function settle(el: NileMinimap): Promise { await el.updateComplete; await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); } /** Build a horizontal scroller: 300px viewport, flex row of N fixed-width children. */ function scrollerHtml(id: string, count = 6, childWidth = 200, childHeight = 60) { const kids = []; for (let i = 0; i < count; i++) { kids.push(html`
${i}
`); } return html`
${kids}
`; } /** A host that mounts a scroller + minimap inside the same shadow root. */ async function shadowHostWithMinimap(forId: string): Promise<{ host: HTMLElement; minimap: NileMinimap; scroller: HTMLElement; }> { const host = document.createElement('div'); const root = host.attachShadow({ mode: 'open' }); document.body.appendChild(host); const scroller = document.createElement('div'); scroller.id = forId; scroller.style.cssText = 'width:300px;height:80px;overflow:auto;display:flex;flex-direction:row'; for (let i = 0; i < 6; i++) { const kid = document.createElement('div'); kid.style.cssText = 'flex:0 0 200px;width:200px;height:60px'; kid.textContent = String(i); scroller.appendChild(kid); } root.appendChild(scroller); const minimap = document.createElement('nile-minimap') as NileMinimap; minimap.setAttribute('for', forId); root.appendChild(minimap); await (minimap as unknown as { updateComplete: Promise }).updateComplete; await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); return { host, minimap, scroller }; } /** Mount a scroller + minimap wired by `for` in the document, returning both. */ async function docFixture( forId: string, opts: { count?: number; childWidth?: number } = {} ): Promise<{ wrapper: HTMLElement; minimap: NileMinimap; scroller: HTMLElement }> { const { count = 6, childWidth = 200 } = opts; const wrapper = await fixture(html`
${scrollerHtml(forId, count, childWidth)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; const scroller = wrapper.querySelector(`#${forId}`) as HTMLElement; await settle(minimap); return { wrapper, minimap, scroller }; } const part = (el: NileMinimap, name: string) => el.shadowRoot!.querySelector(`[part~="${name}"]`); /** The viewport outline is drawn 3px outside the thumb on every side (by design); return the thumb box. */ const OUTSET = 3; const thumbRect = (el: NileMinimap) => { const r = part(el, 'viewport')!.getBoundingClientRect(); return { left: r.left + OUTSET, right: r.right - OUTSET, width: r.width - OUTSET * 2, }; }; describe('NileMinimap', () => { // === RENDERING === it('1. renders without errors', async () => { const { minimap } = await docFixture('s1'); expect(minimap).to.exist; }); it('2. has a shadow root', async () => { const { minimap } = await docFixture('s2'); expect(minimap.shadowRoot).to.not.be.null; }); it('3. has the correct tag name', async () => { const { minimap } = await docFixture('s3'); expect(minimap.tagName.toLowerCase()).to.equal('nile-minimap'); }); // === DEFAULT PROPERTIES === it('4. defaults orientation to auto', async () => { const el = await fixture(html``); expect(el.orientation).to.equal('auto'); }); it('5. defaults mode to segments', async () => { const el = await fixture(html``); expect(el.mode).to.equal('segments'); }); it('6. defaults for to empty string', async () => { const el = await fixture(html``); expect(el.for).to.equal(''); }); it('7. defaults itemSelector to :scope > *', async () => { const el = await fixture(html``); expect(el.itemSelector).to.equal(':scope > *'); }); it('8. defaults hideWhenFits to true', async () => { const el = await fixture(html``); expect(el.hideWhenFits).to.be.true; }); it('9. defaults smooth to true', async () => { const el = await fixture(html``); expect(el.smooth).to.be.true; }); it('10. defaults disabled to false', async () => { const el = await fixture(html``); expect(el.disabled).to.be.false; }); it('11. defaults label to "Scroll position"', async () => { const el = await fixture(html``); expect(el.label).to.equal('Scroll position'); }); it('12. provides a default formatValueText function', async () => { const el = await fixture(html``); expect(el.formatValueText).to.be.a('function'); expect(el.formatValueText(3, 4, 8)).to.be.a('string'); }); // === ATTRIBUTE REFLECTION === it('13. reflects the for attribute', async () => { const { minimap } = await docFixture('s13'); expect(minimap.getAttribute('for')).to.equal('s13'); }); it('14. reflects orientation attribute', async () => { const el = await fixture(html``); expect(el.getAttribute('orientation')).to.equal('vertical'); }); it('15. reflects mode attribute', async () => { const el = await fixture(html``); expect(el.getAttribute('mode')).to.equal('track'); }); it('16. reflects hidewhenfits attribute (kebab/lowercase)', async () => { const el = await fixture(html``); el.hideWhenFits = true; await el.updateComplete; expect(el.hasAttribute('hidewhenfits')).to.be.true; }); it('17. reflects disabled attribute', async () => { const el = await fixture(html``); expect(el.hasAttribute('disabled')).to.be.true; expect(el.disabled).to.be.true; }); // === SHADOW PARTS === it('18. exposes a base part', async () => { const { minimap } = await docFixture('p18'); expect(part(minimap, 'base')).to.exist; }); it('19. exposes a track part', async () => { const { minimap } = await docFixture('p19'); expect(part(minimap, 'track')).to.exist; }); it('20. exposes a viewport part', async () => { const { minimap } = await docFixture('p20'); expect(part(minimap, 'viewport')).to.exist; }); it('21. renders one segment part per item in segments mode', async () => { const { minimap } = await docFixture('p21', { count: 6 }); const segments = minimap.shadowRoot!.querySelectorAll('[part~="segment"]'); expect(segments.length).to.equal(6); }); // === ATTACHING VIA for === it('22. attaches to a scroll container in the document by id', async () => { const { minimap } = await docFixture('a22'); expect(part(minimap, 'viewport')).to.exist; }); it('23. attaches to a scroll container inside the same shadow root', async () => { const { minimap, host } = await shadowHostWithMinimap('a23'); expect(part(minimap, 'viewport')).to.exist; host.remove(); }); it('24. logs one warning and renders nothing for an unknown id', async () => { const warn = console.warn; let calls = 0; console.warn = () => { calls += 1; }; try { const el = await fixture(html``); await settle(el); expect(calls).to.equal(1); // renders nothing: the host takes no space and isn't in the accessibility tree expect(getComputedStyle(el).display).to.equal('none'); } finally { console.warn = warn; } }); it('25. re-resolves when the for id changes to a new container', async () => { const wrapper = await fixture(html`
${scrollerHtml('a25a', 6)} ${scrollerHtml('a25b', 4)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(6); minimap.for = 'a25b'; await settle(minimap); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(4); }); // === GEOMETRY (viewport length & offset) === it('26. viewport length matches track*client/scroll within 1px', async () => { const { minimap, scroller } = await docFixture('g26'); const track = part(minimap, 'track')!.getBoundingClientRect(); const vp = thumbRect(minimap); const expected = Math.max(14, (track.width * scroller.clientWidth) / scroller.scrollWidth); expect(vp.width).to.be.closeTo(expected, 1.5); }); it('27. viewport offset is ~0 at scroll start', async () => { const { minimap, scroller } = await docFixture('g27'); scroller.scrollLeft = 0; await settle(minimap); const track = part(minimap, 'track')!.getBoundingClientRect(); const vp = thumbRect(minimap); expect(vp.left - track.left).to.be.closeTo(0, 1.5); }); it('28. viewport offset tracks a 50% scroll within 1px', async () => { const { minimap, scroller } = await docFixture('g28'); const max = scroller.scrollWidth - scroller.clientWidth; scroller.scrollLeft = max / 2; await settle(minimap); const track = part(minimap, 'track')!.getBoundingClientRect(); const vp = thumbRect(minimap); const expected = (scroller.scrollLeft / max) * (track.width - vp.width); expect(vp.left - track.left).to.be.closeTo(expected, 1.5); }); it('29. viewport sits at the far end at 100% scroll within 1px', async () => { const { minimap, scroller } = await docFixture('g29'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); const track = part(minimap, 'track')!.getBoundingClientRect(); const vp = thumbRect(minimap); expect(track.right - vp.right).to.be.closeTo(0, 1.5); }); // === SEGMENTS === it('30. segment count equals itemSelector matches', async () => { const { minimap } = await docFixture('seg30', { count: 5 }); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(5); }); it('31. segment positions increase with item order', async () => { const { minimap } = await docFixture('seg31', { count: 4 }); const segs = Array.from( minimap.shadowRoot!.querySelectorAll('[part~="segment"]') ); const lefts = segs.map((s) => s.getBoundingClientRect().left); for (let i = 1; i < lefts.length; i++) { expect(lefts[i]).to.be.greaterThan(lefts[i - 1]); } }); it('32. honours a custom itemSelector', async () => { const wrapper = await fixture(html`
a skip b
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(2); }); // === DYNAMIC ITEM CHANGES === it('33. adding an item updates segment count within a frame', async () => { const { minimap, scroller } = await docFixture('dyn33', { count: 4 }); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(4); const kid = document.createElement('div'); kid.style.cssText = 'flex:0 0 200px;width:200px;height:60px'; scroller.appendChild(kid); await settle(minimap); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(5); }); it('34. removing an item updates segment count within a frame', async () => { const { minimap, scroller } = await docFixture('dyn34', { count: 4 }); scroller.removeChild(scroller.lastElementChild!); await settle(minimap); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(3); }); // === FALLBACK === it('35. falls back to track mode with more than 48 items', async () => { const { minimap } = await docFixture('fb35', { count: 60, childWidth: 20 }); // In track fallback there is a single rail, not 60 discrete segment parts. const segments = minimap.shadowRoot!.querySelectorAll('[part~="segment"]'); expect(segments.length).to.be.lessThan(60); }); it('36. falls back to track when a segment would be under 3px', async () => { // Uniform items: segment ≈ track / count. A 100px minimap leaves an ~82px track, // so 40 items (under the 48-item cap) give ≈2px segments. const { minimap } = await docFixture('fb36', { count: 40, childWidth: 10 }); minimap.style.setProperty('--nile-minimap-width', '100px'); await settle(minimap); const segments = minimap.shadowRoot!.querySelectorAll('[part~="segment"]'); expect(segments.length).to.be.lessThan(40); }); // === NAVIGATION: CLICK === it('37. clicking the track fires cancelable nile-navigate with source "click"', async () => { const { minimap } = await docFixture('nav37'); const track = part(minimap, 'track')!; const rect = track.getBoundingClientRect(); setTimeout(() => track.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, composed: true, clientX: rect.left + rect.width * 0.75, clientY: rect.top + rect.height / 2, }) ) ); const ev = await oneEvent(minimap, 'nile-navigate'); expect(ev.detail.source).to.equal('click'); expect(ev.cancelable).to.be.true; }); it('38. clicking the track scrolls the container toward the click point', async () => { const { minimap, scroller } = await docFixture('nav38'); scroller.scrollLeft = 0; await settle(minimap); const track = part(minimap, 'track')!; const rect = track.getBoundingClientRect(); track.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, composed: true, clientX: rect.left + rect.width * 0.9, clientY: rect.top + rect.height / 2, }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.greaterThan(0); }); it('39. preventDefault on nile-navigate stops the scroll', async () => { const { minimap, scroller } = await docFixture('nav39'); scroller.scrollLeft = 0; await settle(minimap); minimap.addEventListener('nile-navigate', (e) => e.preventDefault(), { once: true }); const track = part(minimap, 'track')!; const rect = track.getBoundingClientRect(); track.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, composed: true, clientX: rect.left + rect.width * 0.9, clientY: rect.top + rect.height / 2, }) ); await settle(minimap); expect(scroller.scrollLeft).to.equal(0); }); it('40. direct container scrolling never fires nile-navigate', async () => { const { minimap, scroller } = await docFixture('nav40'); let fired = false; minimap.addEventListener('nile-navigate', () => (fired = true)); scroller.scrollLeft = 100; scroller.dispatchEvent(new Event('scroll')); await settle(minimap); expect(fired).to.be.false; }); // === NAVIGATION: DRAG === it('41. dragging the viewport fires nile-navigate with source "drag" and scrolls', async () => { const { minimap, scroller } = await docFixture('nav41'); scroller.scrollLeft = 0; await settle(minimap); const vp = part(minimap, 'viewport')!; const rect = vp.getBoundingClientRect(); let dragSource: string | undefined; minimap.addEventListener('nile-navigate', (e) => { const ce = e as CustomEvent; if (ce.detail.source === 'drag') dragSource = ce.detail.source; }); vp.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 1, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }) ); vp.dispatchEvent( new PointerEvent('pointermove', { bubbles: true, composed: true, pointerId: 1, clientX: rect.left + rect.width / 2 + 40, clientY: rect.top + rect.height / 2, }) ); vp.dispatchEvent( new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 1, clientX: rect.left + rect.width / 2 + 40, clientY: rect.top + rect.height / 2, }) ); await settle(minimap); expect(dragSource).to.equal('drag'); expect(scroller.scrollLeft).to.be.greaterThan(0); }); // === NAVIGATION: WHEEL === it('42. wheel over the minimap scrolls the container', async () => { const { minimap, scroller } = await docFixture('nav42'); scroller.scrollLeft = 0; await settle(minimap); const base = part(minimap, 'base')!; base.dispatchEvent( new WheelEvent('wheel', { bubbles: true, composed: true, deltaY: 120, cancelable: true }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.greaterThan(0); }); it('43. wheel at the end is not preventDefault-ed (passes through)', async () => { const { minimap, scroller } = await docFixture('nav43'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); const base = part(minimap, 'base')!; const ev = new WheelEvent('wheel', { bubbles: true, composed: true, deltaY: 120, cancelable: true, }); base.dispatchEvent(ev); await settle(minimap); expect(ev.defaultPrevented).to.be.false; }); // === KEYBOARD === it('44. ArrowRight steps to the next item start and scrolls forward', async () => { const { minimap, scroller } = await docFixture('kb44'); scroller.scrollLeft = 0; await settle(minimap); const vp = part(minimap, 'viewport')!; vp.dispatchEvent( new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'ArrowRight' }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.greaterThan(0); }); it('45. ArrowLeft steps back toward the start', async () => { const { minimap, scroller } = await docFixture('kb45'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); const before = scroller.scrollLeft; const vp = part(minimap, 'viewport')!; vp.dispatchEvent( new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'ArrowLeft' }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.lessThan(before); }); it('46. keyboard navigation fires nile-navigate with source "keyboard"', async () => { const { minimap } = await docFixture('kb46'); const vp = part(minimap, 'viewport')!; setTimeout(() => vp.dispatchEvent( new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'ArrowRight' }) ) ); const ev = await oneEvent(minimap, 'nile-navigate'); expect(ev.detail.source).to.equal('keyboard'); }); it('47. End jumps to the end of the content', async () => { const { minimap, scroller } = await docFixture('kb47'); scroller.scrollLeft = 0; await settle(minimap); const vp = part(minimap, 'viewport')!; vp.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'End' })); await settle(minimap); expect(scroller.scrollLeft).to.be.closeTo( scroller.scrollWidth - scroller.clientWidth, 1.5 ); }); it('48. Home jumps to the start of the content', async () => { const { minimap, scroller } = await docFixture('kb48'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); const vp = part(minimap, 'viewport')!; vp.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'Home' })); await settle(minimap); expect(scroller.scrollLeft).to.be.closeTo(0, 1.5); }); it('49. PageDown moves roughly one viewport forward', async () => { const { minimap, scroller } = await docFixture('kb49'); scroller.scrollLeft = 0; await settle(minimap); const vp = part(minimap, 'viewport')!; vp.dispatchEvent( new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'PageDown' }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.closeTo(scroller.clientWidth, scroller.clientWidth * 0.5 + 2); }); it('50. PageUp moves back one viewport', async () => { const { minimap, scroller } = await docFixture('kb50'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); const before = scroller.scrollLeft; const vp = part(minimap, 'viewport')!; vp.dispatchEvent( new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'PageUp' }) ); await settle(minimap); expect(scroller.scrollLeft).to.be.lessThan(before); }); // === OVERFLOW CHANGE === it('51. fires nile-overflow-change on first measure with overflowing true', async () => { const wrapper = await fixture(html`
${scrollerHtml('of51', 6)}
`); const minimap = document.createElement('nile-minimap') as NileMinimap; minimap.smooth = false; const got = oneEvent(minimap, 'nile-overflow-change'); minimap.setAttribute('for', 'of51'); wrapper.appendChild(minimap); const ev = await got; expect(ev.detail.overflowing).to.be.true; }); it('52. hideWhenFits hides the host when nothing overflows', async () => { const wrapper = await fixture(html`
only
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); // host collapsed / hidden when content fits const visible = minimap.offsetWidth > 0 && minimap.offsetHeight > 0; expect(visible).to.be.false; }); it('53. does not hide when hideWhenFits is false and content fits', async () => { const wrapper = await fixture(html`
only
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(minimap.offsetWidth).to.be.greaterThan(0); }); // === ORIENTATION === it('54. auto picks the horizontal axis for a horizontally overflowing container', async () => { const { minimap } = await docFixture('or54'); const vp = part(minimap, 'viewport')!; expect(vp.getAttribute('aria-orientation')).to.equal('horizontal'); }); it('55. auto picks the vertical axis for a vertically overflowing container', async () => { const wrapper = await fixture(html`
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); const vp = part(minimap, 'viewport')!; expect(vp.getAttribute('aria-orientation')).to.equal('vertical'); }); // === MOTION / smooth === it('56. smooth=false scrolls instantly (no animation frames needed)', async () => { const { minimap, scroller } = await docFixture('mo56'); scroller.scrollLeft = 0; await settle(minimap); const vp = part(minimap, 'viewport')!; vp.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, composed: true, key: 'End' })); await minimap.updateComplete; await aTimeout(0); expect(scroller.scrollLeft).to.be.closeTo( scroller.scrollWidth - scroller.clientWidth, 2 ); }); // === ARIA === it('57. viewport has role="scrollbar"', async () => { const { minimap } = await docFixture('ar57'); expect(part(minimap, 'viewport')!.getAttribute('role')).to.equal('scrollbar'); }); it('58. viewport aria-controls equals the for id', async () => { const { minimap } = await docFixture('ar58'); expect(part(minimap, 'viewport')!.getAttribute('aria-controls')).to.equal('ar58'); }); it('59. viewport exposes aria-valuemin/max of 0 and 100', async () => { const { minimap } = await docFixture('ar59'); const vp = part(minimap, 'viewport')!; expect(vp.getAttribute('aria-valuemin')).to.equal('0'); expect(vp.getAttribute('aria-valuemax')).to.equal('100'); }); it('60. aria-valuenow is 0 at the start of scroll', async () => { const { minimap, scroller } = await docFixture('ar60'); scroller.scrollLeft = 0; await settle(minimap); expect(Number(part(minimap, 'viewport')!.getAttribute('aria-valuenow'))).to.equal(0); }); it('61. aria-valuenow reaches ~100 at the end of scroll', async () => { const { minimap, scroller } = await docFixture('ar61'); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; await settle(minimap); expect(Number(part(minimap, 'viewport')!.getAttribute('aria-valuenow'))).to.be.closeTo(100, 1); }); it('62. aria-valuetext reads "Items a–b of n" in segments mode', async () => { const { minimap } = await docFixture('ar62', { count: 6 }); const text = part(minimap, 'viewport')!.getAttribute('aria-valuetext') ?? ''; expect(text).to.match(/Items\s+\d+.?\d*.*of\s+6/i); }); it('63. aria-valuetext honours a custom formatValueText', async () => { const wrapper = await fixture(html`
${scrollerHtml('ar63', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; minimap.formatValueText = (a, b, n) => `custom ${a}/${b}/${n}`; await settle(minimap); expect(part(minimap, 'viewport')!.getAttribute('aria-valuetext')).to.contain('custom'); }); it('64. viewport uses the label for its aria-label', async () => { const wrapper = await fixture(html`
${scrollerHtml('ar64', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(part(minimap, 'viewport')!.getAttribute('aria-label')).to.equal('Board overview'); }); it('65. viewport default aria-label is "Scroll position"', async () => { const { minimap } = await docFixture('ar65'); expect(part(minimap, 'viewport')!.getAttribute('aria-label')).to.equal('Scroll position'); }); // === DISABLED === it('66. disabled sets aria-disabled="true" on the viewport', async () => { const wrapper = await fixture(html`
${scrollerHtml('dis66', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(part(minimap, 'viewport')!.getAttribute('aria-disabled')).to.equal('true'); }); it('67. disabled makes the viewport non-focusable (tabindex -1)', async () => { const wrapper = await fixture(html`
${scrollerHtml('dis67', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; await settle(minimap); expect(part(minimap, 'viewport')!.getAttribute('tabindex')).to.equal('-1'); }); it('68. disabled still mirrors container scrolling', async () => { const wrapper = await fixture(html`
${scrollerHtml('dis68', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; const scroller = wrapper.querySelector('#dis68') as HTMLElement; await settle(minimap); scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; scroller.dispatchEvent(new Event('scroll')); await settle(minimap); expect(Number(part(minimap, 'viewport')!.getAttribute('aria-valuenow'))).to.be.greaterThan(0); }); it('69. disabled viewport does not scroll the container on click', async () => { const wrapper = await fixture(html`
${scrollerHtml('dis69', 6)}
`); const minimap = wrapper.querySelector('nile-minimap') as NileMinimap; const scroller = wrapper.querySelector('#dis69') as HTMLElement; await settle(minimap); scroller.scrollLeft = 0; await settle(minimap); const track = part(minimap, 'track')!; const rect = track.getBoundingClientRect(); track.dispatchEvent( new MouseEvent('click', { bubbles: true, composed: true, clientX: rect.left + rect.width * 0.9, clientY: rect.top + rect.height / 2, }) ); await settle(minimap); expect(scroller.scrollLeft).to.equal(0); }); // === DISCONNECT === it('70. releases listeners on disconnect (scroll after removal does not update)', async () => { const { minimap, scroller, wrapper } = await docFixture('dc70'); await settle(minimap); const vpBefore = part(minimap, 'viewport')!.getAttribute('aria-valuenow'); minimap.remove(); expect(() => { scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; scroller.dispatchEvent(new Event('scroll')); }).to.not.throw(); await aTimeout(16); // detached minimap's reported value should not have moved expect(part(minimap, 'viewport')!.getAttribute('aria-valuenow')).to.equal(vpBefore); wrapper.remove(); }); it('71. disconnecting and reconnecting does not throw', async () => { const { minimap, wrapper } = await docFixture('dc71'); const parent = minimap.parentElement!; minimap.remove(); parent.appendChild(minimap); await settle(minimap); expect(minimap.shadowRoot).to.exist; wrapper.remove(); }); // === DYNAMIC PROPERTY UPDATES === it('72. switching mode to track updates rendering', async () => { const { minimap } = await docFixture('dp72', { count: 6 }); expect(minimap.shadowRoot!.querySelectorAll('[part~="segment"]').length).to.equal(6); minimap.mode = 'track'; await settle(minimap); expect(minimap.mode).to.equal('track'); }); it('73. is an instance of NileMinimap with the right constructor', async () => { const { minimap } = await docFixture('dp73'); expect(minimap.constructor.name).to.contain('Minimap'); }); it('74. has static styles defined', async () => { const ctor = customElements.get('nile-minimap') as unknown as { styles?: unknown }; expect(ctor.styles).to.exist; }); // === AUTO-HIDE === const isIdle = (el: NileMinimap) => el.hasAttribute('data-idle'); const baseOpacity = (el: NileMinimap) => getComputedStyle(part(el, 'base')!).opacity; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function autoHideFixture(id: string, hideDelay = 60) { const f = await docFixture(id); f.minimap.hideDelay = hideDelay; f.minimap.autoHide = true; await settle(f.minimap); return f; } it('75. autoHide defaults to false and hideDelay to 1200', async () => { const { minimap } = await docFixture('ah75'); expect(minimap.autoHide).to.be.false; expect(minimap.hideDelay).to.equal(1200); expect(isIdle(minimap)).to.be.false; }); it('76. autoHide reflects and starts hidden (opacity 0, no pointer events) but keeps its size', async () => { const { minimap } = await autoHideFixture('ah76'); expect(minimap.hasAttribute('autohide')).to.be.true; expect(isIdle(minimap)).to.be.true; await wait(200); // let the fade transition finish expect(baseOpacity(minimap)).to.equal('0'); expect(getComputedStyle(part(minimap, 'base')!).pointerEvents).to.equal('none'); expect(minimap.getBoundingClientRect().width).to.be.greaterThan(0); }); it('77. scrolling the container reveals it, then it hides after hideDelay', async () => { const { minimap, scroller } = await autoHideFixture('ah77'); scroller.scrollLeft = 120; scroller.dispatchEvent(new Event('scroll')); expect(isIdle(minimap)).to.be.false; await wait(20); expect(isIdle(minimap)).to.be.false; await wait(120); expect(isIdle(minimap)).to.be.true; }); it('78. each scroll restarts the countdown', async () => { const { minimap, scroller } = await autoHideFixture('ah78', 100); scroller.dispatchEvent(new Event('scroll')); await wait(70); scroller.dispatchEvent(new Event('scroll')); await wait(70); expect(isIdle(minimap)).to.be.false; await wait(80); expect(isIdle(minimap)).to.be.true; }); it('79. stays visible while hovered and hides after the pointer leaves', async () => { const { minimap, scroller } = await autoHideFixture('ah79'); scroller.dispatchEvent(new Event('scroll')); minimap.dispatchEvent(new PointerEvent('pointerenter')); await wait(150); expect(isIdle(minimap)).to.be.false; minimap.dispatchEvent(new PointerEvent('pointerleave')); await wait(150); expect(isIdle(minimap)).to.be.true; }); it('80. keyboard focus reveals it and keeps it visible until blur', async () => { const { minimap } = await autoHideFixture('ah80'); expect(isIdle(minimap)).to.be.true; part(minimap, 'viewport')!.focus(); expect(isIdle(minimap)).to.be.false; await wait(150); expect(isIdle(minimap)).to.be.false; part(minimap, 'viewport')!.blur(); await wait(150); expect(isIdle(minimap)).to.be.true; }); it('81. turning autoHide off shows it immediately and stops hiding', async () => { const { minimap, scroller } = await autoHideFixture('ah81'); expect(isIdle(minimap)).to.be.true; minimap.autoHide = false; await settle(minimap); expect(isIdle(minimap)).to.be.false; scroller.dispatchEvent(new Event('scroll')); await wait(150); expect(isIdle(minimap)).to.be.false; }); });