Tab group tests (#1128)

* remove duplicate test

* Add tests for sl-tab-group -- initial round of tests

* use individual fixtures for each test

* extract mocks + utility functions in external files

* remove unnecessary internals of intersection observer from the mock

* added first test on scroll buttons

* add scrolling tests

* remove resize observer mock

Resize observer is triggered but waiting for element
to be updated is not enough. You need to free the main thread
with the test for some time

* Also removed intersection observer mock

By waiting long enough for the things to happen automatically

* Fix problems with resize observer

These problems appeared after npm ci but (according
to the sources linked in the comments) unproblematic

* Handle merge request comments

* replace custom wait function with corresponding function
 from openwc/testing
* Extracted waitForScrollingToEnd and isElementVisibleFromScrolling into
dedicated files to be reused
* Improve queryByTestId --> make it usable for more complex values
* Add js docs

* run lint fix

* Added tests for selecting a tab by click

* added further tests for tab group selection

* use Promise<void> instead of Promise<any>

to avoid eslint errors

---------

Co-authored-by: Dominikus Hellgartner <dominikus.hellgartner@gmail.com>
pull/1170/head
dhellgartner 2023-01-27 15:55:28 +01:00 zatwierdzone przez GitHub
rodzic e8fe783fb4
commit 38a9e98d9b
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
5 zmienionych plików z 516 dodań i 8 usunięć

Wyświetl plik

@ -0,0 +1,449 @@
import { aTimeout, elementUpdated, expect, fixture, oneEvent, waitUntil } from '@open-wc/testing';
import { clickOnElement } from '../../internal/test';
import { html } from 'lit';
import { isElementVisibleFromOverflow } from '../../internal/test/element-visible-overflow';
import { queryByTestId } from '../../internal/test/data-testid-helpers';
import { sendKeys } from '@web/test-runner-commands';
import { waitForScrollingToEnd } from '../../internal/test/wait-for-scrolling';
import type { HTMLTemplateResult } from 'lit';
import type SlTab from '../tab/tab';
import type SlTabGroup from './tab-group';
import type SlTabPanel from '../tab-panel/tab-panel';
interface ClientRectangles {
body?: DOMRect;
navigation?: DOMRect;
}
interface CustomEventPayload {
name: string;
}
const waitForScrollButtonsToBeRendered = async (tabGroup: SlTabGroup): Promise<void> => {
await waitUntil(() => {
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
return scrollButtons?.length === 2;
});
};
const getClientRectangles = (tabGroup: SlTabGroup): ClientRectangles => {
const shadowRoot = tabGroup.shadowRoot;
if (shadowRoot) {
const nav = shadowRoot.querySelector<HTMLElement>('[part=nav]');
const body = shadowRoot.querySelector<HTMLElement>('[part=body]');
return {
body: body?.getBoundingClientRect(),
navigation: nav?.getBoundingClientRect()
};
}
return {};
};
const expectHeaderToBeVisible = (container: HTMLElement, dataTestId: string): void => {
const generalHeader = queryByTestId<SlTab>(container, dataTestId);
expect(generalHeader).not.to.be.null;
expect(generalHeader).to.be.visible;
};
const expectOnlyOneTabPanelToBeActive = async (container: HTMLElement, dataTestIdOfActiveTab: string) => {
await waitUntil(() => {
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
return activeTabPanels.length === 1;
});
const tabPanels = Array.from(container.getElementsByTagName('sl-tab-panel'));
const activeTabPanels = tabPanels.filter((element: SlTabPanel) => element.hasAttribute('active'));
expect(activeTabPanels).to.have.lengthOf(1);
expect(activeTabPanels[0]).to.have.attribute('data-testid', dataTestIdOfActiveTab);
};
const expectPromiseToHaveName = async (showEventPromise: Promise<CustomEvent>, expectedName: string) => {
const showEvent = await showEventPromise;
expect((showEvent.detail as CustomEventPayload).name).to.equal(expectedName);
};
const waitForHeaderToBeActive = async (container: HTMLElement, headerTestId: string): Promise<SlTab> => {
const generalHeader = queryByTestId<SlTab>(container, headerTestId);
await waitUntil(() => {
return generalHeader?.hasAttribute('active');
});
if (generalHeader) {
return generalHeader;
} else {
throw new Error(`did not find error with testid=${headerTestId}`);
}
};
describe('<sl-tab-group>', () => {
it('renders', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
expect(tabGroup).to.be.visible;
});
it('is accessible', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await expect(tabGroup).to.be.accessible();
});
it('displays all tabs', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-tab-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" disabled data-testid="disabled-tab-header">Disabled</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel>
</sl-tab-group>
`);
expectHeaderToBeVisible(tabGroup, 'general-tab-header');
expectHeaderToBeVisible(tabGroup, 'disabled-tab-header');
});
it('shows the first tab to be active by default', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
});
describe('proper positioning', () => {
it('shows the header above the tabs by default', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.top).to.be.greaterThanOrEqual(clientRectangles.navigation?.bottom || -Infinity);
});
it('shows the header below the tabs by setting placement to bottom', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'bottom';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.bottom).to.be.lessThanOrEqual(clientRectangles.navigation?.top || +Infinity);
});
it('shows the header left of the tabs by setting placement to start', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'start';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.left).to.be.greaterThanOrEqual(clientRectangles.navigation?.right || -Infinity);
});
it('shows the header right of the tabs by setting placement to end', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general">General</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.placement = 'end';
await aTimeout(0);
const clientRectangles = getClientRectangles(tabGroup);
expect(clientRectangles.body?.right).to.be.lessThanOrEqual(clientRectangles.navigation?.left || -Infinity);
});
});
describe('scrolling behavior', () => {
const generateTabs = (n: number): HTMLTemplateResult[] => {
const result: HTMLTemplateResult[] = [];
for (let i = 0; i < n; i++) {
result.push(html`<sl-tab slot="nav" panel="tab-${i}">Tab ${i}</sl-tab>
<sl-tab-panel name="tab-${i}">Content of tab ${i}0</sl-tab-panel> `);
}
return result;
};
before(() => {
// disabling failing on resize observer ... unfortunately on webkit this is not really specific
// https://github.com/WICG/resize-observer/issues/38#issuecomment-422126006
// https://stackoverflow.com/a/64197640
const errorHandler = window.onerror;
window.onerror = (
event: string | Event,
source?: string | undefined,
lineno?: number | undefined,
colno?: number | undefined,
error?: Error | undefined
) => {
if ((event as string).includes('ResizeObserver') || event === 'Script error.') {
return true;
} else if (errorHandler) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return errorHandler(event, source, lineno, colno, error);
} else {
return true;
}
};
});
it('shows scroll buttons on too many tabs', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons, 'Both scroll buttons should be shown').to.have.length(2);
tabGroup.disconnectedCallback();
});
it('does not show scroll buttons on too many tabs if deactivated', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(30)} </sl-tab-group>`);
tabGroup.noScrollControls = true;
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if all tabs fit on the screen', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(2)} </sl-tab-group>`);
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if placement is start', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
tabGroup.placement = 'start';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does not show scroll buttons if placement is end', async () => {
const tabGroup = await fixture<SlTabGroup>(html`<sl-tab-group> ${generateTabs(50)} </sl-tab-group>`);
tabGroup.placement = 'end';
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(0);
});
it('does scroll on scroll button click', async () => {
const numberOfElements = 15;
const tabGroup = await fixture<SlTabGroup>(
html`<sl-tab-group> ${generateTabs(numberOfElements)} </sl-tab-group>`
);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('sl-icon-button');
expect(scrollButtons).to.have.length(2);
const firstTab = tabGroup.querySelector('[panel="tab-0"]');
expect(firstTab).not.to.be.null;
const lastTab = tabGroup.querySelector(`[panel="tab-${numberOfElements - 1}"]`);
expect(lastTab).not.to.be.null;
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.true;
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.false;
const scrollToRightButton = tabGroup.shadowRoot?.querySelector('sl-icon-button[part*="scroll-button--end"]');
expect(scrollToRightButton).not.to.be.null;
await clickOnElement(scrollToRightButton!);
await elementUpdated(tabGroup);
await waitForScrollingToEnd(firstTab!);
await waitForScrollingToEnd(lastTab!);
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.false;
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.true;
});
});
describe('tab selection', () => {
const expectCustomTabToBeActiveAfter = async (tabGroup: SlTabGroup, action: () => Promise<void>): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
await action();
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
};
const expectGeneralTabToBeStillActiveAfter = async (
tabGroup: SlTabGroup,
action: () => Promise<void>
): Promise<void> => {
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
let showEventFired = false;
let hideEventFired = false;
oneEvent(tabGroup, 'sl-tab-show').then(() => (showEventFired = true));
oneEvent(tabGroup, 'sl-tab-hide').then(() => (hideEventFired = true));
await action();
expect(generalHeader).to.have.attribute('active');
expect(showEventFired).to.be.false;
expect(hideEventFired).to.be.false;
return expectOnlyOneTabPanelToBeActive(tabGroup, 'general-tab-content');
};
it('selects a tab by clicking on it', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
return expectCustomTabToBeActiveAfter(tabGroup, () => clickOnElement(customHeader!));
});
it('does not change if the active tab is reselected', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom">Custom</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const generalHeader = queryByTestId(tabGroup, 'general-header');
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(generalHeader!));
});
it('does not change if a disabled tab is clicked', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" data-testid="disabled-header" disabled>disabled</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is the disabled tab panel.</sl-tab-panel>
</sl-tab-group>
`);
const disabledHeader = queryByTestId(tabGroup, 'disabled-header');
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => clickOnElement(disabledHeader!));
});
it('selects a tab by using the arrow keys', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the arrow keys and enter if activation is set to manual', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
tabGroup.activation = 'manual';
const generalHeader = await waitForHeaderToBeActive(tabGroup, 'general-header');
generalHeader.focus();
const customHeader = queryByTestId<SlTab>(tabGroup, 'custom-header');
expect(customHeader).not.to.have.attribute('active');
const showEventPromise = oneEvent(tabGroup, 'sl-tab-show') as Promise<CustomEvent>;
await sendKeys({ press: 'ArrowRight' });
await aTimeout(0);
expect(generalHeader).to.have.attribute('active');
await sendKeys({ press: 'Enter' });
expect(customHeader).to.have.attribute('active');
await expectPromiseToHaveName(showEventPromise, 'custom');
return expectOnlyOneTabPanelToBeActive(tabGroup, 'custom-tab-content');
});
it('does not allow selection of disabled tabs with arrow keys', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="disabled" disabled>Disabled</sl-tab>
<sl-tab-panel name="general" data-testid="general-tab-content">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectGeneralTabToBeStillActiveAfter(tabGroup, () => sendKeys({ press: 'ArrowRight' }));
});
it('selects a tab by using the show function', async () => {
const tabGroup = await fixture<SlTabGroup>(html`
<sl-tab-group>
<sl-tab slot="nav" panel="general" data-testid="general-header">General</sl-tab>
<sl-tab slot="nav" panel="custom" data-testid="custom-header">Custom</sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom" data-testid="custom-tab-content">This is the custom tab panel.</sl-tab-panel>
</sl-tab-group>
`);
return expectCustomTabToBeActiveAfter(tabGroup, () => {
tabGroup.show('custom');
return aTimeout(0);
});
});
});
});

Wyświetl plik

@ -35,14 +35,6 @@ describe('<sl-tab-panel>', () => {
expect(el.getAttribute('aria-hidden')).to.equal('false');
});
it('changing active should always update aria-hidden role', async () => {
const el = await fixture<SlTabPanel>(html` <sl-tab-panel>Test</sl-tab-panel> `);
el.active = true;
await aTimeout(100);
expect(el.getAttribute('aria-hidden')).to.equal('false');
});
it('passed id should be used', async () => {
const el = await fixture<SlTabPanel>(html` <sl-tab-panel id="test-id">Test</sl-tab-panel> `);

Wyświetl plik

@ -0,0 +1,14 @@
/**
* Allows you to find a DOM element based on the value of its `data-testid` attribute.
* This attribute can be used to decouple identifying dom elements for testing from
* styling (which is typically done via class selectors) or other ids which serve
* different purposes.
* See also https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change
* Inspired by https://testing-library.com/docs/queries/bytestid/
* @param {HTMLElement} container - A parent element of the DOM element to find
* @param {string} testId - The value of the `data-testid` attribute of the component to find.
* @returns The found element or null if there was no such element
*/
export const queryByTestId = <T extends Element>(container: HTMLElement, testId: string): T | null => {
return container.querySelector<T>(`[data-testid="${testId}"]`);
};

Wyświetl plik

@ -0,0 +1,20 @@
/**
* Given a parent element featuring `overflow: hidden` and a child element inside the parent, this
* function determines whether the child will be visible taking only the overflow of the parent into account
* Id does NOT check whether it is hidden or overlapped by another element
* It basically checks whether the bounding rects of the parent and the child overlap
*
* @param {HTMLElement} outerElement - The parent element
* @param {HTMLElement} innerElement - the child element
* @returns {Boolean} whether the two elements overlap
*/
export const isElementVisibleFromOverflow = (outerElement: Element, innerElement: Element): boolean => {
const outerRect = outerElement.getBoundingClientRect();
const innerRect = innerElement.getBoundingClientRect();
return (
outerRect.top <= innerRect.bottom &&
innerRect.top <= outerRect.bottom &&
outerRect.left <= innerRect.right &&
innerRect.left <= outerRect.right
);
};

Wyświetl plik

@ -0,0 +1,33 @@
/**
* Wait until an element has stopped scrolling
* This considers the element to have stopped scrolling, as soon as it did not change its
* scroll position for 20 successive animation frames
* @param {HTMLElement} element - The element which is scrolled
* @param {numeric} timeoutInMs - A timeout in ms. If the timeout has elapsed, the promise rejects
* @returns A promise which resolves after the scrolling has stopped
*/
export const waitForScrollingToEnd = (element: Element, timeoutInMs = 500): Promise<void> => {
let lastLeft = element.scrollLeft;
let lastTop = element.scrollTop;
let framesWithoutChange = 0;
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
reject(new Error('Waiting for scroll end timed out'));
}, timeoutInMs);
function checkScrollingChanged() {
if (element.scrollLeft !== lastLeft || element.scrollTop !== lastTop) {
framesWithoutChange = 0;
lastLeft = window.scrollX;
lastTop = window.scrollY;
} else {
framesWithoutChange++;
if (framesWithoutChange >= 20) {
clearTimeout(timeout);
resolve();
}
}
window.requestAnimationFrame(checkScrollingChanged);
}
checkScrollingChanged();
});
};