Skip to content

Commit 66f9a8f

Browse files
committed
test: Add OptionList unit and integration tests
1 parent 41aa9e2 commit 66f9a8f

15 files changed

Lines changed: 1501 additions & 1 deletion
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { act, render } from '@testing-library/react';
5+
6+
import PlainList from '../../../../../lib/components/internal/components/listbox/parts/plain-list';
7+
import { flattenOptions } from '../../../../../lib/components/internal/components/option/utils/flatten-options';
8+
import { HighlightType } from '../../../../../lib/components/internal/components/options-list/utils/use-highlight-option';
9+
import type { GetOptionProps, ScrollToIndexRef } from '../interfaces';
10+
import type { ItemProps } from '../parts/item';
11+
import type { ListProps } from '../parts/plain-list';
12+
13+
const { flatOptions } = flattenOptions([
14+
{ value: 'a', label: 'A' },
15+
{ value: 'b', label: 'B' },
16+
]);
17+
18+
const getOptionProps: GetOptionProps = (option, index) => ({ option, index, 'data-mouse-target': index }) as ItemProps;
19+
20+
function renderPlainList(scrollStrategy?: ListProps['scrollStrategy']) {
21+
const scrollToIndex = React.createRef<ScrollToIndexRef>();
22+
const listRef = React.createRef<HTMLDivElement>();
23+
render(
24+
<PlainList
25+
ref={scrollToIndex}
26+
listProps={{ ref: listRef, open: true, embedded: true }}
27+
getOptionProps={getOptionProps}
28+
filteredOptions={flatOptions}
29+
filteringValue=""
30+
highlightType={new HighlightType('keyboard')}
31+
scrollStrategy={scrollStrategy}
32+
/>
33+
);
34+
const list = listRef.current!;
35+
const item = list.querySelector<HTMLElement>('[data-mouse-target="1"]')!;
36+
return { scrollTo: (index: number) => act(() => scrollToIndex.current!(index)), list, item };
37+
}
38+
39+
describe('PlainList scrollStrategy', () => {
40+
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
41+
let scrollIntoView: jest.Mock;
42+
43+
beforeEach(() => {
44+
scrollIntoView = HTMLElement.prototype.scrollIntoView = jest.fn();
45+
});
46+
afterEach(() => {
47+
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
48+
});
49+
50+
test('ancestors: scrolls the option into view through all scrollable ancestors', () => {
51+
const { scrollTo, list, item } = renderPlainList('ancestors');
52+
list.scrollBy = jest.fn();
53+
scrollTo(1);
54+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
55+
expect(scrollIntoView.mock.instances[0]).toBe(item);
56+
expect(list.scrollBy).not.toHaveBeenCalled();
57+
});
58+
59+
test('scrolls only the list by default', () => {
60+
const { scrollTo, list, item } = renderPlainList();
61+
// jsdom has no layout, so give the list a height and place the option below its visible area.
62+
Object.defineProperty(list, 'clientHeight', { value: 100 });
63+
list.getBoundingClientRect = () => ({ top: 0, bottom: 100, height: 100 }) as DOMRect;
64+
item.getBoundingClientRect = () => ({ top: 200, bottom: 250, height: 50 }) as DOMRect;
65+
list.scrollBy = jest.fn();
66+
scrollTo(1);
67+
expect(list.scrollBy).toHaveBeenCalledWith({ top: 150 });
68+
expect(scrollIntoView).not.toHaveBeenCalled();
69+
});
70+
});

‎src/internal/components/listbox/__tests__/scroll-to-index.test.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import scrollToIndex from '../parts/scroll-to-index';
3+
import scrollToIndex, { scrollOptionIntoView } from '../parts/scroll-to-index';
44

55
describe('scroll-to-index', () => {
66
function runWith({
@@ -168,3 +168,44 @@ describe('scroll-to-index', () => {
168168
});
169169
});
170170
});
171+
172+
describe('scrollOptionIntoView', () => {
173+
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
174+
let scrollIntoView: jest.Mock;
175+
176+
beforeEach(() => {
177+
scrollIntoView = HTMLElement.prototype.scrollIntoView = jest.fn();
178+
});
179+
afterEach(() => {
180+
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
181+
});
182+
183+
function createMenu(mouseTargets: number[]) {
184+
const menuEl = document.createElement('div');
185+
for (const mouseTarget of mouseTargets) {
186+
const item = document.createElement('div');
187+
item.dataset.mouseTarget = `${mouseTarget}`;
188+
menuEl.appendChild(item);
189+
}
190+
return menuEl;
191+
}
192+
193+
it('scrolls the option at the index into view through its scrollable ancestors', () => {
194+
const menuEl = createMenu([0, 1, 2]);
195+
scrollOptionIntoView({ index: 2, menuEl });
196+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
197+
expect(scrollIntoView.mock.instances[0]).toBe(menuEl.children[2]);
198+
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
199+
});
200+
201+
it('does not scroll when nothing is highlighted, even if a non-interactive row uses index -1', () => {
202+
// Non-interactive rows such as single-selection group headers render with data-mouse-target="-1".
203+
scrollOptionIntoView({ index: -1, menuEl: createMenu([-1, 0, 1]) });
204+
expect(scrollIntoView).not.toHaveBeenCalled();
205+
});
206+
207+
it('does not scroll when no option has the index', () => {
208+
scrollOptionIntoView({ index: 5, menuEl: createMenu([0, 1]) });
209+
expect(scrollIntoView).not.toHaveBeenCalled();
210+
});
211+
});

‎src/internal/components/options-list/__tests__/options-list.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,13 @@ test('onLoadMore is called when dropdown is open and status type changes to "pen
119119

120120
expect(onLoadMore).toHaveBeenCalledTimes(2);
121121
});
122+
123+
test('onLoadMore does not fire on open when statusType is omitted', () => {
124+
const onLoadMore = jest.fn();
125+
render(
126+
<OptionsList open={true} onLoadMore={onLoadMore}>
127+
<div>Option</div>
128+
</OptionsList>
129+
);
130+
expect(onLoadMore).not.toHaveBeenCalled();
131+
});

‎src/internal/components/options-list/__tests__/use-keyboard.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@ describe('useMenuKeyboard', () => {
105105
expect(menuKeys.escape.preventDefault).not.toHaveBeenCalled();
106106
});
107107

108+
test('does not stop propagation when onEscape is omitted', () => {
109+
const escape = { detail: { keyCode: KeyCode.escape }, preventDefault: jest.fn(), stopPropagation: jest.fn() };
110+
const hook = renderHook(useMenuKeyboard, {
111+
initialProps: { ...menuInitialProps, onEscape: undefined },
112+
});
113+
act(() => hook.result.current(escape as any));
114+
expect(escape.stopPropagation).not.toHaveBeenCalled();
115+
expect(escape.preventDefault).not.toHaveBeenCalled();
116+
});
117+
108118
test('should select option when pressing enter', () => {
109119
const selectOption = jest.fn();
110120
const hook = renderHook(useMenuKeyboard, {
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
4+
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
5+
6+
import createWrapper from '../../../lib/components/test-utils/selectors';
7+
8+
const list = createWrapper().findOptionList('[data-testid="option-list"]');
9+
const listbox = `${list.toSelector()} [role="listbox"]`;
10+
11+
class ScrollPage extends BasePageObject {
12+
/** Whether the highlighted option is inside the viewport and inside every scrolling ancestor. */
13+
isHighlightVisible() {
14+
return this.browser.execute(selector => {
15+
const option = document.querySelector(selector);
16+
if (!option) {
17+
return false;
18+
}
19+
const rect = option.getBoundingClientRect();
20+
const within = (top: number, bottom: number) => rect.top >= top - 1 && rect.bottom <= bottom + 1;
21+
for (let parent = option.parentElement; parent; parent = parent.parentElement) {
22+
const { overflowY } = getComputedStyle(parent);
23+
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) {
24+
const parentRect = parent.getBoundingClientRect();
25+
if (!within(parentRect.top + parent.clientTop, parentRect.top + parent.clientTop + parent.clientHeight)) {
26+
return false;
27+
}
28+
}
29+
}
30+
return within(0, window.innerHeight);
31+
}, list.findHighlightedOption().toSelector());
32+
}
33+
34+
getHighlightedText() {
35+
return this.getText(list.findHighlightedOption().findLabel().toSelector());
36+
}
37+
38+
/** Scrolls the list's nearest scroll container (or the page) back to the top, away from the highlight. */
39+
scrollToTop() {
40+
return this.browser.execute(selector => {
41+
for (let parent = document.querySelector(selector)!.parentElement; parent; parent = parent.parentElement) {
42+
const { overflowY } = getComputedStyle(parent);
43+
if ((overflowY === 'auto' || overflowY === 'scroll') && parent.scrollHeight > parent.clientHeight) {
44+
parent.scrollTop = 0;
45+
return;
46+
}
47+
}
48+
window.scrollTo(0, 0);
49+
}, listbox);
50+
}
51+
52+
/** Retries the visibility check for a short time, so it doesn't run before a scroll has settled. */
53+
async waitForHighlightVisible() {
54+
await this.waitForAssertion(() => expect(this.isHighlightVisible()).resolves.toBe(true));
55+
}
56+
57+
async expectHighlightVisibleAfter(keys: string[], label: string) {
58+
await this.keys(keys);
59+
await expect(this.getHighlightedText()).resolves.toBe(label);
60+
await this.waitForHighlightVisible();
61+
}
62+
63+
async assertKeyboardScrolling() {
64+
// Arrow keys call preventDefault, so the browser doesn't scroll on its own: every scroll here comes from the list.
65+
await this.expectHighlightVisibleAfter(new Array(30).fill('ArrowDown'), 'Option 31');
66+
await this.expectHighlightVisibleAfter(new Array(30).fill('ArrowUp'), 'Option 1');
67+
68+
// Wrapping around jumps between the two ends of the list.
69+
await this.expectHighlightVisibleAfter(['ArrowUp'], 'Option 100');
70+
await this.expectHighlightVisibleAfter(['ArrowDown'], 'Option 1');
71+
await this.expectHighlightVisibleAfter(['ArrowUp'], 'Option 100');
72+
73+
// Scroll away from the highlight, so the next move has to scroll back to it.
74+
await this.scrollToTop();
75+
await this.expectHighlightVisibleAfter(['ArrowUp'], 'Option 99');
76+
77+
// Home and End don't call preventDefault, so the browser also starts its native scroll. That scroll is animated
78+
// and keeps running after the key press, so it would move the page under any later check. Check them last, and
79+
// only check where they move the highlight.
80+
await this.keys(['End']);
81+
await expect(this.getHighlightedText()).resolves.toBe('Option 100');
82+
await this.keys(['Home']);
83+
await expect(this.getHighlightedText()).resolves.toBe('Option 1');
84+
}
85+
}
86+
87+
const setupTest = (url: string, testFn: (page: ScrollPage) => Promise<void>) =>
88+
useBrowser({ width: 1200, height: 800 }, async browser => {
89+
await browser.url(url);
90+
const page = new ScrollPage(browser);
91+
await page.waitForVisible('h1');
92+
await testFn(page);
93+
});
94+
95+
describe('OptionList keyboard scrolling', () => {
96+
describe.each(['wrapper', 'page'])('container=%s', container => {
97+
const focusList = async (page: ScrollPage) => {
98+
await page.click('#option-list-heading');
99+
await page.keys(['Tab']);
100+
await expect(page.isFocused(listbox)).resolves.toBe(true);
101+
};
102+
103+
test(
104+
'keeps the highlighted option visible',
105+
setupTest(`#/light/option-list/scroll?container=${container}`, async page => {
106+
await focusList(page);
107+
await page.assertKeyboardScrolling();
108+
})
109+
);
110+
111+
test(
112+
'focus entry highlights the selected option near the end and scrolls it into view',
113+
setupTest(`#/light/option-list/scroll?container=${container}&preselected=near-end`, async page => {
114+
await focusList(page);
115+
await expect(page.getHighlightedText()).resolves.toBe('Option 81');
116+
await page.waitForHighlightVisible();
117+
})
118+
);
119+
});
120+
121+
describe.each([false, true])('in a Dropdown, expandToViewport=%s', expandToViewport => {
122+
test(
123+
'keeps the highlighted option visible without scrolling the page on open',
124+
setupTest(`#/light/option-list/scroll?container=dropdown&expandToViewport=${expandToViewport}`, async page => {
125+
const scrollBefore = await page.getWindowScroll();
126+
await page.click('#dropdown-trigger');
127+
await page.waitForVisible(listbox);
128+
await expect(page.isFocused(listbox)).resolves.toBe(true);
129+
await expect(page.getWindowScroll()).resolves.toEqual(scrollBefore);
130+
await page.assertKeyboardScrolling();
131+
})
132+
);
133+
});
134+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
4+
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';
5+
6+
import createWrapper from '../../../lib/components/test-utils/selectors';
7+
8+
const wrapper = createWrapper();
9+
const singleList = wrapper.findOptionList('[data-testid="single"]');
10+
const multiList = wrapper.findOptionList('[data-testid="multi"]');
11+
const groupsList = wrapper.findOptionList('[data-testid="groups"]');
12+
const listbox = (list: typeof singleList) => `${list.toSelector()} [role="listbox"]`;
13+
14+
const setupTest = (testFn: (page: BasePageObject) => Promise<void>) =>
15+
useBrowser(async browser => {
16+
await browser.url('#/light/option-list/simple');
17+
const page = new BasePageObject(browser);
18+
await page.waitForVisible(singleList.toSelector());
19+
await testFn(page);
20+
});
21+
22+
describe('OptionList', () => {
23+
test(
24+
'selects an option with a click anywhere on the row',
25+
setupTest(async page => {
26+
await page.click(singleList.findOptionByValue('performance').findDescription().toSelector());
27+
await expect(page.getText('[data-testid="single-value"]')).resolves.toBe('Selected: performance');
28+
await expect(page.getElementsCount(singleList.findSelectedOptions().toSelector())).resolves.toBe(1);
29+
})
30+
);
31+
32+
test(
33+
'selects with the keyboard: Tab into the list, arrows move, Enter selects',
34+
setupTest(async page => {
35+
await page.click('#single-heading');
36+
await page.keys(['Tab']);
37+
await expect(page.isFocused(listbox(singleList))).resolves.toBe(true);
38+
await page.keys(['ArrowDown', 'ArrowDown', 'Enter']);
39+
await expect(page.getText('[data-testid="single-value"]')).resolves.toBe('Selected: reliability');
40+
})
41+
);
42+
43+
test(
44+
'Tab leaves the list after one stop',
45+
setupTest(async page => {
46+
await page.click('#single-heading');
47+
await page.keys(['Tab', 'Tab']);
48+
await expect(page.isFocused(listbox(singleList))).resolves.toBe(false);
49+
await expect(page.isFocused(listbox(multiList))).resolves.toBe(true);
50+
})
51+
);
52+
53+
test(
54+
'clicking a group header selects its enabled options',
55+
setupTest(async page => {
56+
await page.click(groupsList.findGroupHeaderByValue('storage').toSelector());
57+
await expect(page.getText('[data-testid="groups-value"]')).resolves.toBe('Selected: s3');
58+
})
59+
);
60+
});

0 commit comments

Comments
 (0)