|
| 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