diff --git a/src/__tests__/pinning.test.tsx b/src/__tests__/pinning.test.tsx index 0acd2324..79c53b85 100644 --- a/src/__tests__/pinning.test.tsx +++ b/src/__tests__/pinning.test.tsx @@ -101,6 +101,32 @@ describe('DataTable column pinning', () => { expect(offsets).toContain(0); expect(offsets.some(v => v > 0)).toBe(true); }); + + test('pinned scrollbar aria-controls still resolves after a column is unpinned and re-pinned', async () => { + const unpinnedCols: TableColumn[] = columns.map(c => ({ ...c, pinned: undefined })); + const { container, rerender } = render(); + + const wrapper = container.querySelector('.rdt_responsiveWrapper') as HTMLElement; + Object.defineProperty(wrapper, 'scrollWidth', { configurable: true, get: () => 1000 }); + Object.defineProperty(wrapper, 'clientWidth', { configurable: true, get: () => 400 }); + await act(async () => { + wrapper.dispatchEvent(new Event('scroll')); + }); + + // Unpinning every column drops hasPinnedColumns, which unmounts the + // scrollbar; re-pinning mounts a fresh one with a new useId, while the + // wrapper keeps the id the first mount stamped on it. + rerender(); + rerender(); + await act(async () => { + wrapper.dispatchEvent(new Event('scroll')); + }); + + const thumb = container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + const controls = thumb.getAttribute('aria-controls'); + expect(controls).toBe(wrapper.id); + expect(document.getElementById(controls!)).toBe(wrapper); + }); }); // ── PinnedScrollbar ────────────────────────────────────────────────────────── @@ -195,4 +221,185 @@ describe('PinnedScrollbar', () => { expect(ref.current!.scrollLeft).toBeGreaterThanOrEqual(0); } }); + + // Control: the container has no id of its own, so the effect stamps it with + // this mount's useId and aria-controls matches either way. Green before and + // after the fix — it pins the untouched offset-0 case so the fix can't be + // read as changing the ordinary first-mount path. + test('aria-controls resolves to the scroll container it labelled (control)', async () => { + const ref = makeScrollRef(1000, 400); + document.body.appendChild(ref.current!); + const { container, unmount } = renderWithTheme(); + + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + + const thumb = container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + const controls = thumb.getAttribute('aria-controls'); + expect(controls).toBe(ref.current!.id); + expect(document.getElementById(controls!)).toBe(ref.current); + + unmount(); + ref.current!.remove(); + }); + + // A dangling aria-controls is invisible in a rendered-DOM assertion — it only + // shows up when you resolve the id, which is what assistive tech does. + test('aria-controls points at a host-supplied container id instead of a fresh one', async () => { + const ref = makeScrollRef(1000, 400); + ref.current!.id = 'host-app-scroll-container'; + document.body.appendChild(ref.current!); + const { container, unmount } = renderWithTheme(); + + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + + const thumb = container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + // The container already had an id, so the effect leaves it alone — the thumb + // must follow it rather than pointing at its own unused useId value. + expect(ref.current!.id).toBe('host-app-scroll-container'); + expect(thumb.getAttribute('aria-controls')).toBe('host-app-scroll-container'); + expect(document.getElementById(thumb.getAttribute('aria-controls')!)).toBe(ref.current); + + unmount(); + ref.current!.remove(); + }); + + test('aria-controls survives a remount onto the same container', async () => { + const ref = makeScrollRef(1000, 400); + document.body.appendChild(ref.current!); + + // First mount stamps the container with its useId value. + const first = renderWithTheme(); + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + const stampedId = ref.current!.id; + first.unmount(); + + // Unmounting doesn't clear the id, and the remount gets a *different* useId. + // Unpinning then re-pinning a column does exactly this in DataTable. + const second = renderWithTheme(); + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + + const thumb = second.container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + expect(ref.current!.id).toBe(stampedId); + expect(thumb.getAttribute('aria-controls')).toBe(stampedId); + expect(document.getElementById(thumb.getAttribute('aria-controls')!)).toBe(ref.current); + + second.unmount(); + ref.current!.remove(); + }); + + // The effect keys on the *ref object*, so pointing the component at a + // different container mid-life re-runs it. The container swapped in already + // has an id, so nothing is stamped and aria-controls has to follow it. + test('aria-controls re-points when the scroll container is swapped for another one', async () => { + const first = makeScrollRef(1000, 400).current!; + const second = makeScrollRef(1000, 400).current!; + second.id = 'second-scroll-container'; + document.body.append(first, second); + + let swap!: (el: HTMLDivElement) => void; + function SwapHarness({ initial }: { initial: HTMLDivElement }): JSX.Element { + const [target, setTarget] = React.useState(initial); + swap = setTarget; + const ref = React.useMemo(() => ({ current: target }) as React.RefObject, [target]); + return ; + } + + const { container, unmount } = renderWithTheme(); + await act(async () => { + first.dispatchEvent(new Event('scroll')); + }); + await act(async () => { + swap(second); + }); + + const thumb = container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + const controls = thumb.getAttribute('aria-controls'); + expect(controls).toBe('second-scroll-container'); + expect(document.getElementById(controls!)).toBe(second); + + unmount(); + first.remove(); + second.remove(); + }); + + // Reverse of the host-supplied-id case: the stamp happens first and the host + // takes the id over afterwards, so the remount sees an id it did not write. + test('aria-controls follows an id the host assigns after the first mount stamped one', async () => { + const ref = makeScrollRef(1000, 400); + document.body.appendChild(ref.current!); + + const first = renderWithTheme(); + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + first.unmount(); + + ref.current!.id = 'host-took-over-later'; + const second = renderWithTheme(); + await act(async () => { + ref.current!.dispatchEvent(new Event('scroll')); + }); + + const thumb = second.container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + expect(thumb.getAttribute('aria-controls')).toBe('host-took-over-later'); + expect(document.getElementById(thumb.getAttribute('aria-controls')!)).toBe(ref.current); + + second.unmount(); + ref.current!.remove(); + }); + + // Control: two scrollbars on the page at once, each on its own container. + // Green before and after — the id each thumb points at is per-instance state, + // and this pins that the two never share one. + test('two concurrent scrollbars each control their own container (control)', async () => { + const a = makeScrollRef(1000, 400); + const b = makeScrollRef(1000, 400); + document.body.append(a.current!, b.current!); + + const firstRender = renderWithTheme(); + const secondRender = renderWithTheme(); + await act(async () => { + a.current!.dispatchEvent(new Event('scroll')); + b.current!.dispatchEvent(new Event('scroll')); + }); + + const thumbA = firstRender.container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + const thumbB = secondRender.container.querySelector('.rdt_pinnedScrollbarThumb') as HTMLElement; + const controlsA = thumbA.getAttribute('aria-controls'); + const controlsB = thumbB.getAttribute('aria-controls'); + expect(controlsA).not.toBe(controlsB); + expect(document.getElementById(controlsA!)).toBe(a.current); + expect(document.getElementById(controlsB!)).toBe(b.current); + + firstRender.unmount(); + secondRender.unmount(); + a.current!.remove(); + b.current!.remove(); + }); + + // Control: the effect bails at `if (!el) return` before it can stamp an id or + // call setControlsId, and `visible` never flips, so no thumb — and no + // aria-controls — is emitted at all. Green before and after the fix; it pins + // that the new state doesn't leak an attribute onto a scrollbar that the + // early return means was never rendered. + test('emits no thumb and no aria-controls when the scroll ref is empty (control)', async () => { + const ref = { current: null } as React.RefObject; + const { container, unmount } = renderWithTheme(); + + await act(async () => {}); + + expect(container.querySelector('.rdt_pinnedScrollbarTrack')).toBeNull(); + expect(container.querySelector('.rdt_pinnedScrollbarThumb')).toBeNull(); + expect(container.querySelector('[aria-controls]')).toBeNull(); + + unmount(); + }); }); diff --git a/src/components/PinnedScrollbar.tsx b/src/components/PinnedScrollbar.tsx index 6f61b393..b1d89c87 100644 --- a/src/components/PinnedScrollbar.tsx +++ b/src/components/PinnedScrollbar.tsx @@ -13,6 +13,9 @@ export default function PinnedScrollbar({ rightInset, }: PinnedScrollbarProps): JSX.Element | null { const scrollContainerId = React.useId(); + // The id the thumb points at. Only equals scrollContainerId when this + // component is the one that labelled the container — see the sync effect. + const [controlsId, setControlsId] = React.useState(scrollContainerId); const trackRef = React.useRef(null); const thumbRef = React.useRef(null); const [thumbWidth, setThumbWidth] = React.useState(0); @@ -62,6 +65,11 @@ export default function PinnedScrollbar({ if (!el.id) { el.id = scrollContainerId; } + // The container keeps whatever id it already had — a host-supplied one, or + // one left behind by a previous mount (useId hands out a fresh value each + // time). Point aria-controls at the id that is actually on the element, or + // it dangles and assistive tech can't resolve the scrollbar's target. + setControlsId(el.id); el.addEventListener('scroll', update, { passive: true }); const ro = new ResizeObserver(update); ro.observe(el); @@ -191,7 +199,7 @@ export default function PinnedScrollbar({ ref={thumbRef} role="scrollbar" tabIndex={0} - aria-controls={scrollContainerId} + aria-controls={controlsId} aria-orientation="horizontal" aria-valuenow={scrollPercent} aria-valuemin={0}