Skip to content

Commit bd231a0

Browse files
Merge branch 'main' into jmundhra/disable-auto-flip
2 parents a72efa6 + 6e191b9 commit bd231a0

2 files changed

Lines changed: 221 additions & 17 deletions

File tree

src/components/Table/Internal/Body/Scroller.tsx

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import styles from '../octable.module.scss';
2525
const BUTTON_HEIGHT: number = 36;
2626
const BUTTON_PADDING: number = 2;
2727
const DEFAULT_SCROLL_WIDTH: number = 100;
28+
const SCROLL_TOLERANCE: number = 1;
2829

2930
export const Scroller = React.forwardRef(
3031
<RecordType,>(
@@ -138,14 +139,14 @@ export const Scroller = React.forwardRef(
138139
.reverse()
139140
.find(
140141
(leftOffset: number) =>
141-
leftOffset > scrollBodyRef.current.scrollLeft
142+
leftOffset > scrollBodyRef.current.scrollLeft + SCROLL_TOLERANCE
142143
);
143144
} else {
144145
scrollLeft = scrollOffsets
145146
.map((offset) => -offset)
146147
.find(
147148
(leftOffset: number) =>
148-
leftOffset < scrollBodyRef.current.scrollLeft
149+
leftOffset < scrollBodyRef.current.scrollLeft - SCROLL_TOLERANCE
149150
);
150151
}
151152
} else {
@@ -155,12 +156,12 @@ export const Scroller = React.forwardRef(
155156
.reverse()
156157
.find(
157158
(leftOffset: number) =>
158-
leftOffset < scrollBodyRef.current.scrollLeft
159+
leftOffset < scrollBodyRef.current.scrollLeft - SCROLL_TOLERANCE
159160
);
160161
} else {
161162
scrollLeft = scrollOffsets.find(
162163
(leftOffset: number) =>
163-
leftOffset > scrollBodyRef.current.scrollLeft
164+
leftOffset > scrollBodyRef.current.scrollLeft + SCROLL_TOLERANCE
164165
);
165166
}
166167
}
@@ -181,20 +182,11 @@ export const Scroller = React.forwardRef(
181182
const bodyScrollLeft: number = scrollBodyRef.current?.scrollLeft || 0;
182183
const bodyScrollWidth: number = scrollBodyRef.current?.scrollWidth || 0;
183184
const bodyWidth: number = scrollBodyRef.current?.clientWidth || 0;
184-
const offset: 1 | -1 = direction === 'rtl' ? -1 : 1;
185-
const threshold: number = offset * (bodyScrollWidth - bodyWidth);
185+
const scrolled: number = Math.abs(bodyScrollLeft);
186+
const maxScroll: number = bodyScrollWidth - bodyWidth;
186187

187-
if (bodyScrollLeft === 0) {
188-
setStartButtonVisible(false);
189-
setEndButtonVisible(true);
190-
} else {
191-
setStartButtonVisible(true);
192-
if (bodyScrollLeft === threshold) {
193-
setEndButtonVisible(false);
194-
} else {
195-
setEndButtonVisible(true);
196-
}
197-
}
188+
setStartButtonVisible(scrolled > SCROLL_TOLERANCE);
189+
setEndButtonVisible(scrolled < maxScroll - SCROLL_TOLERANCE);
198190
};
199191

200192
useImperativeHandle(ref, () => ({
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import React from 'react';
2+
import Enzyme, { mount, ReactWrapper } from 'enzyme';
3+
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
4+
import { act } from 'react-dom/test-utils';
5+
import MatchMediaMock from 'jest-matchmedia-mock';
6+
import { Scroller } from '../Body/Scroller';
7+
import { Button } from '../../../Button';
8+
import { ColumnType, ScrollerRef, StickyOffsets } from '../OcTable.types';
9+
10+
Enzyme.configure({ adapter: new Adapter() });
11+
12+
/** Minimal stand-in for the scroll body element the Scroller reads/writes. */
13+
interface MockScrollBody {
14+
scrollLeft: number;
15+
clientWidth: number;
16+
scrollWidth: number;
17+
scrollTo: jest.Mock;
18+
getBoundingClientRect: () => Pick<DOMRect, 'top' | 'height'>;
19+
addEventListener: jest.Mock;
20+
removeEventListener: jest.Mock;
21+
/** Captured listeners so tests can trigger mouseenter/mouseleave. */
22+
listeners: Record<string, () => void>;
23+
}
24+
25+
let matchMedia: MatchMediaMock;
26+
27+
describe('Table.Scroller arrow navigation', () => {
28+
beforeAll(() => {
29+
matchMedia = new MatchMediaMock();
30+
});
31+
32+
afterEach(() => {
33+
matchMedia.clear();
34+
});
35+
36+
// Three unfixed 128px columns => the Scroller builds stop-points
37+
// scrollOffsets = [0, 128, 256, 384].
38+
const columns: ColumnType<unknown>[] = [
39+
{ title: 'C1', dataIndex: 'c1', key: 'c1', width: 128 },
40+
{ title: 'C2', dataIndex: 'c2', key: 'c2', width: 128 },
41+
{ title: 'C3', dataIndex: 'c3', key: 'c3', width: 128 },
42+
];
43+
44+
const makeScrollBody = (scrollLeft: number): MockScrollBody => {
45+
const listeners: Record<string, () => void> = {};
46+
return {
47+
scrollLeft,
48+
clientWidth: 100,
49+
scrollWidth: 384,
50+
scrollTo: jest.fn(),
51+
getBoundingClientRect: () => ({ top: 0, height: 0 }),
52+
addEventListener: jest.fn((event: string, cb: () => void) => {
53+
listeners[event] = cb;
54+
}),
55+
removeEventListener: jest.fn(),
56+
listeners,
57+
};
58+
};
59+
60+
const createScroller = (
61+
scrollBody: MockScrollBody,
62+
direction: string = 'ltr',
63+
ref?: React.Ref<ScrollerRef>
64+
): ReactWrapper => {
65+
const scrollBodyRef: React.RefObject<HTMLDivElement> = {
66+
current: scrollBody as unknown as HTMLDivElement,
67+
};
68+
const stickyOffsets: StickyOffsets = {
69+
left: [0, 0, 0, 0],
70+
right: [0, 0, 0, 0],
71+
};
72+
const titleRef: React.RefObject<HTMLDivElement> = { current: null };
73+
return mount(
74+
<Scroller
75+
ref={ref}
76+
columns={columns}
77+
flattenColumns={columns}
78+
scrollBodyRef={scrollBodyRef}
79+
stickyOffsets={stickyOffsets}
80+
direction={direction}
81+
titleRef={titleRef}
82+
scrollLeftAriaLabelText="Scroll left"
83+
scrollRightAriaLabelText="Scroll right"
84+
/>
85+
);
86+
};
87+
88+
const clickArrow = (wrapper: ReactWrapper, ariaLabel: string): void => {
89+
wrapper.find(`button[aria-label="${ariaLabel}"]`).first().simulate('click');
90+
};
91+
92+
/** Inline style the Scroller computes for the arrow with the given label. */
93+
const arrowStyle = (
94+
wrapper: ReactWrapper,
95+
ariaLabel: string
96+
): React.CSSProperties =>
97+
wrapper
98+
.find(Button)
99+
.filterWhere((node) => node.prop('ariaLabel') === ariaLabel)
100+
.first()
101+
.prop('style') as React.CSSProperties;
102+
103+
it('right arrow advances to the next column from the start', () => {
104+
const scrollBody = makeScrollBody(0);
105+
const wrapper = createScroller(scrollBody);
106+
107+
clickArrow(wrapper, 'Scroll right');
108+
109+
expect(scrollBody.scrollTo).toHaveBeenCalledWith({
110+
left: 128,
111+
behavior: 'smooth',
112+
});
113+
});
114+
115+
it('right arrow advances past a fractionally-snapped scroll position (IMPL-203300)', () => {
116+
// At <100% browser zoom the browser snaps a declared 128px column to a
117+
// slightly smaller rendered value (measured ~127.78px), so the body settles
118+
// at 127.78 after a click that targeted 128. Without the 1px tolerance,
119+
// find(offset > 127.78) returns 128 again -> scrolling snaps right back and
120+
// the arrow is stuck at the first column. With the tolerance it must skip
121+
// to the next stop-point (256).
122+
const scrollBody = makeScrollBody(127.78);
123+
const wrapper = createScroller(scrollBody);
124+
125+
clickArrow(wrapper, 'Scroll right');
126+
127+
expect(scrollBody.scrollTo).toHaveBeenCalledWith({
128+
left: 256,
129+
behavior: 'smooth',
130+
});
131+
});
132+
133+
it('left arrow returns to the previous column from a fractionally-snapped position', () => {
134+
const scrollBody = makeScrollBody(256.22);
135+
const wrapper = createScroller(scrollBody);
136+
137+
clickArrow(wrapper, 'Scroll left');
138+
139+
expect(scrollBody.scrollTo).toHaveBeenCalledWith({
140+
left: 128,
141+
behavior: 'smooth',
142+
});
143+
});
144+
145+
it('applies the same tolerance in RTL (negative stop-points)', () => {
146+
// In RTL the stop-points are negated: [0, -128, -256, -384]. The right
147+
// arrow moves toward 0, the left arrow toward -384.
148+
const scrollBody = makeScrollBody(-127.78);
149+
const wrapper = createScroller(scrollBody, 'rtl');
150+
151+
// RTL "left" arrow => scroll further negative, must skip -128 (already
152+
// snapped) and reach -256.
153+
clickArrow(wrapper, 'Scroll left');
154+
155+
expect(scrollBody.scrollTo).toHaveBeenCalledWith({
156+
left: -256,
157+
behavior: 'smooth',
158+
});
159+
});
160+
161+
describe('arrow visibility at scroll extents', () => {
162+
// Reveal the arrows (they are only shown while the body is hovered).
163+
const reveal = (scrollBody: MockScrollBody): void => {
164+
act(() => scrollBody.listeners.mouseenter?.());
165+
};
166+
167+
// maxScroll = scrollWidth (384) - clientWidth (100) = 284.
168+
it('hides the end arrow within tolerance of the scroll end (IMPL-203300)', () => {
169+
const ref = React.createRef<ScrollerRef>();
170+
const scrollBody = makeScrollBody(0);
171+
const wrapper = createScroller(scrollBody, 'ltr', ref);
172+
reveal(scrollBody);
173+
174+
// The browser snaps the max scroll position to a subpixel value, so the
175+
// body settles just short of 284. An exact === check would keep the end
176+
// arrow visible; the tolerance must hide it.
177+
scrollBody.scrollLeft = 283.8;
178+
act(() => ref.current?.onBodyScroll());
179+
wrapper.update();
180+
181+
const style = arrowStyle(wrapper, 'Scroll right');
182+
expect(style.visibility).toBe('hidden');
183+
expect(style.opacity).toBe(0);
184+
});
185+
186+
it('keeps the end arrow visible mid-scroll', () => {
187+
const ref = React.createRef<ScrollerRef>();
188+
const scrollBody = makeScrollBody(0);
189+
const wrapper = createScroller(scrollBody, 'ltr', ref);
190+
reveal(scrollBody);
191+
192+
scrollBody.scrollLeft = 128;
193+
act(() => ref.current?.onBodyScroll());
194+
wrapper.update();
195+
196+
expect(arrowStyle(wrapper, 'Scroll right').visibility).toBe('visible');
197+
});
198+
199+
it('hides the start arrow within tolerance of the scroll start', () => {
200+
const ref = React.createRef<ScrollerRef>();
201+
const scrollBody = makeScrollBody(128);
202+
const wrapper = createScroller(scrollBody, 'ltr', ref);
203+
reveal(scrollBody);
204+
205+
scrollBody.scrollLeft = 0.2;
206+
act(() => ref.current?.onBodyScroll());
207+
wrapper.update();
208+
209+
expect(arrowStyle(wrapper, 'Scroll left').visibility).toBe('hidden');
210+
});
211+
});
212+
});

0 commit comments

Comments
 (0)