Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions src/__tests__/pinning.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Row>[] = columns.map(c => ({ ...c, pinned: undefined }));
const { container, rerender } = render(<DataTable columns={columns} data={data} responsive />);

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(<DataTable columns={unpinnedCols} data={data} responsive />);
rerender(<DataTable columns={columns} data={data} responsive />);
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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);

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(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);

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(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);
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(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);
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<HTMLDivElement>, [target]);
return <PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />;
}

const { container, unmount } = renderWithTheme(<SwapHarness initial={first} />);
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(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);
await act(async () => {
ref.current!.dispatchEvent(new Event('scroll'));
});
first.unmount();

ref.current!.id = 'host-took-over-later';
const second = renderWithTheme(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);
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(<PinnedScrollbar scrollRef={a} leftInset={0} rightInset={0} />);
const secondRender = renderWithTheme(<PinnedScrollbar scrollRef={b} leftInset={0} rightInset={0} />);
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<HTMLDivElement>;
const { container, unmount } = renderWithTheme(<PinnedScrollbar scrollRef={ref} leftInset={0} rightInset={0} />);

await act(async () => {});

expect(container.querySelector('.rdt_pinnedScrollbarTrack')).toBeNull();
expect(container.querySelector('.rdt_pinnedScrollbarThumb')).toBeNull();
expect(container.querySelector('[aria-controls]')).toBeNull();

unmount();
});
});
10 changes: 9 additions & 1 deletion src/components/PinnedScrollbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const thumbRef = React.useRef<HTMLDivElement>(null);
const [thumbWidth, setThumbWidth] = React.useState(0);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down