From 798272e974604024e884e4a5da3d39b0c3d2dd49 Mon Sep 17 00:00:00 2001
From: Rohan <45748283+r100-stack@users.noreply.github.com>
Date: Wed, 9 Oct 2024 16:28:51 -0400
Subject: [PATCH] e2e tests for `useOverflow` logic (#2151)
---
testing/e2e/app/routes/Breadcrumbs/route.tsx | 25 ++
testing/e2e/app/routes/Breadcrumbs/spec.ts | 91 ++++++
testing/e2e/app/routes/ButtonGroup/route.tsx | 132 ++++++++-
testing/e2e/app/routes/ButtonGroup/spec.ts | 275 +++++++++++++++++-
testing/e2e/app/routes/ComboBox/route.tsx | 94 +++++-
testing/e2e/app/routes/ComboBox/spec.ts | 184 +++++++++++-
testing/e2e/app/routes/DropdownMenu/spec.ts | 2 +-
.../app/routes/MiddleTextTruncation/route.tsx | 31 ++
.../app/routes/MiddleTextTruncation/spec.ts | 86 ++++++
testing/e2e/app/routes/Select/route.tsx | 50 ++++
testing/e2e/app/routes/Select/spec.ts | 140 +++++++++
testing/e2e/app/routes/Table/route.tsx | 143 ++++++++-
testing/e2e/app/routes/Table/spec.ts | 148 +++++++++-
13 files changed, 1354 insertions(+), 47 deletions(-)
create mode 100644 testing/e2e/app/routes/Breadcrumbs/route.tsx
create mode 100644 testing/e2e/app/routes/Breadcrumbs/spec.ts
create mode 100644 testing/e2e/app/routes/MiddleTextTruncation/route.tsx
create mode 100644 testing/e2e/app/routes/MiddleTextTruncation/spec.ts
create mode 100644 testing/e2e/app/routes/Select/route.tsx
create mode 100644 testing/e2e/app/routes/Select/spec.ts
diff --git a/testing/e2e/app/routes/Breadcrumbs/route.tsx b/testing/e2e/app/routes/Breadcrumbs/route.tsx
new file mode 100644
index 00000000000..e5b0ac4ddd5
--- /dev/null
+++ b/testing/e2e/app/routes/Breadcrumbs/route.tsx
@@ -0,0 +1,25 @@
+import { Breadcrumbs, Button } from '@itwin/itwinui-react';
+
+export default function BreadcrumbsTest() {
+ const items = Array(5)
+ .fill(null)
+ .map((_, index) => (
+
+ Item {index}
+
+ ));
+
+ return (
+ <>
+
+ {
+ return ;
+ }}
+ >
+ {items}
+
+
+ >
+ );
+}
diff --git a/testing/e2e/app/routes/Breadcrumbs/spec.ts b/testing/e2e/app/routes/Breadcrumbs/spec.ts
new file mode 100644
index 00000000000..b3c54921b47
--- /dev/null
+++ b/testing/e2e/app/routes/Breadcrumbs/spec.ts
@@ -0,0 +1,91 @@
+import { test, expect, Page } from '@playwright/test';
+
+test.describe('Breadcrumbs', () => {
+ test(`should overflow whenever there is not enough space`, async ({
+ page,
+ }) => {
+ await page.goto(`/Breadcrumbs`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 5,
+ expectedOverflowButtonVisibleCount: undefined,
+ });
+
+ await setContainerSize(page, '200px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 2,
+ expectedOverflowButtonVisibleCount: 2,
+ });
+
+ // should restore hidden items when space is available again
+ await setContainerSize(page, undefined);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 5,
+ expectedOverflowButtonVisibleCount: undefined,
+ });
+ });
+
+ test(`should at minimum always show one overflow button and one item`, async ({
+ page,
+ }) => {
+ await page.goto(`/Breadcrumbs`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 5,
+ expectedOverflowButtonVisibleCount: undefined,
+ });
+
+ await setContainerSize(page, '10px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedOverflowButtonVisibleCount: 1,
+ });
+ });
+});
+
+// ----------------------------------------------------------------------------
+
+const setContainerSize = async (page: Page, value: string | undefined) => {
+ await page.getByTestId('container').evaluate(
+ (element, args) => {
+ if (args.value != null) {
+ element.style.setProperty('width', args.value);
+ } else {
+ element.style.removeProperty('width');
+ }
+ },
+ { value },
+ );
+ await page.waitForTimeout(200);
+};
+
+const expectOverflowState = async ({
+ page,
+ expectedItemLength,
+ expectedOverflowButtonVisibleCount,
+}: {
+ page: Page;
+ expectedItemLength: number;
+ expectedOverflowButtonVisibleCount: number | undefined;
+}) => {
+ const items = page.getByTestId('item');
+ expect(items).toHaveCount(expectedItemLength);
+
+ const overflowButton = page.locator('button');
+
+ if (expectedOverflowButtonVisibleCount != null) {
+ await expect(overflowButton).toHaveText(
+ `${expectedOverflowButtonVisibleCount}`,
+ );
+ } else {
+ await expect(overflowButton).not.toBeVisible();
+ }
+};
diff --git a/testing/e2e/app/routes/ButtonGroup/route.tsx b/testing/e2e/app/routes/ButtonGroup/route.tsx
index 1399e71b436..656487f3e59 100644
--- a/testing/e2e/app/routes/ButtonGroup/route.tsx
+++ b/testing/e2e/app/routes/ButtonGroup/route.tsx
@@ -1,23 +1,127 @@
-import { ButtonGroup, IconButton } from '@itwin/itwinui-react';
+import { Button, ButtonGroup, Flex, IconButton } from '@itwin/itwinui-react';
import { SvgPlaceholder } from '@itwin/itwinui-icons-react';
import { useSearchParams } from '@remix-run/react';
+import * as React from 'react';
export default function ButtonGroupTest() {
const [searchParams] = useSearchParams();
+ const config = getConfigFromSearchParams(searchParams);
+ const { exampleType } = config;
- const orientation = searchParams.get('orientation') || 'horizontal';
+ return exampleType === 'default' ? (
+
+ ) : (
+
+ );
+}
+
+const getConfigFromSearchParams = (searchParams: URLSearchParams) => {
+ const exampleType = (searchParams.get('exampleType') ?? 'default') as
+ | 'default'
+ | 'overflow';
+ const initialProvideOverflowButton =
+ searchParams.get('provideOverflowButton') !== 'false';
+ const orientation =
+ (searchParams.get('orientation') as 'horizontal' | 'vertical') ||
+ 'horizontal';
+ const overflowPlacement =
+ (searchParams.get('overflowPlacement') as 'start' | 'end') || undefined;
+ const showToggleProvideOverflowButton =
+ searchParams.get('showToggleProvideOverflowButton') === 'true';
+
+ return {
+ exampleType,
+ initialProvideOverflowButton,
+ orientation,
+ overflowPlacement,
+ showToggleProvideOverflowButton,
+ };
+};
+
+const Default = ({
+ config,
+}: {
+ config: ReturnType;
+}) => {
+ const {
+ initialProvideOverflowButton,
+ showToggleProvideOverflowButton,
+ orientation,
+ overflowPlacement,
+ } = config;
+
+ const [provideOverflowButton, setProvideOverflowButton] = React.useState(
+ initialProvideOverflowButton,
+ );
return (
-
-
-
-
-
-
-
-
-
-
-
+
+ {showToggleProvideOverflowButton && (
+
+ )}
+
+
+ {
+ return (
+
+ {firstOverflowingIndex}
+
+ );
+ }
+ : undefined
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
);
-}
+};
+
+const Overflow = ({
+ config,
+}: {
+ config: ReturnType;
+}) => {
+ const { overflowPlacement } = config;
+
+ const buttons = [...Array(10)].map((_, index) => (
+
+
+
+ ));
+
+ return (
+
+ {startIndex}}
+ overflowPlacement={overflowPlacement}
+ >
+ {buttons}
+
+
+ );
+};
diff --git a/testing/e2e/app/routes/ButtonGroup/spec.ts b/testing/e2e/app/routes/ButtonGroup/spec.ts
index 0ad18d62823..9d7f0250300 100644
--- a/testing/e2e/app/routes/ButtonGroup/spec.ts
+++ b/testing/e2e/app/routes/ButtonGroup/spec.ts
@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { test, expect, Page } from '@playwright/test';
-test.describe('ButtonGroup', () => {
+test.describe('ButtonGroup (toolbar)', () => {
test("should support keyboard navigation when role='toolbar'", async ({
page,
}) => {
@@ -61,3 +61,274 @@ test.describe('ButtonGroup', () => {
await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused();
});
});
+
+test.describe('ButtonGroup (overflow)', () => {
+ (
+ [
+ {
+ orientation: 'horizontal',
+ overflowPlacement: 'end',
+ },
+ {
+ orientation: 'horizontal',
+ overflowPlacement: 'start',
+ },
+ {
+ orientation: 'vertical',
+ overflowPlacement: 'end',
+ },
+ {
+ orientation: 'vertical',
+ overflowPlacement: 'start',
+ },
+ ] as const
+ ).forEach(({ orientation, overflowPlacement }) => {
+ test(`should overflow whenever there is not enough space (orientation=${orientation}, overflowPlacement=${overflowPlacement})`, async ({
+ page,
+ }) => {
+ await page.goto(
+ `/ButtonGroup?orientation=${orientation}&overflowPlacement=${overflowPlacement}`,
+ );
+
+ const setContainerSize = getSetContainerSize(page, orientation);
+ const expectOverflowState = getExpectOverflowState(
+ page,
+ overflowPlacement,
+ );
+
+ await expectOverflowState({
+ expectedButtonLength: 3,
+ expectedOverflowTagFirstOverflowingIndex: undefined,
+ });
+
+ await setContainerSize(2.5);
+
+ await expectOverflowState({
+ expectedButtonLength: 2,
+ expectedOverflowTagFirstOverflowingIndex: 1,
+ });
+
+ await setContainerSize(1.5);
+
+ await expectOverflowState({
+ expectedButtonLength: 1,
+ expectedOverflowTagFirstOverflowingIndex:
+ overflowPlacement === 'end' ? 0 : 2,
+ });
+
+ await setContainerSize(0.5);
+
+ // should return 1 overflowTag when item is bigger than the container
+ await expectOverflowState({
+ expectedButtonLength: 1,
+ expectedOverflowTagFirstOverflowingIndex:
+ overflowPlacement === 'end' ? 0 : 2,
+ });
+
+ // should restore hidden items when space is available again
+ await setContainerSize(1.5);
+
+ await expectOverflowState({
+ expectedButtonLength: 1,
+ expectedOverflowTagFirstOverflowingIndex:
+ overflowPlacement === 'end' ? 0 : 2,
+ });
+
+ await setContainerSize(2.5);
+
+ await expectOverflowState({
+ expectedButtonLength: 2,
+ expectedOverflowTagFirstOverflowingIndex: 1,
+ });
+
+ await setContainerSize(10);
+
+ await expectOverflowState({
+ expectedButtonLength: 3,
+ expectedOverflowTagFirstOverflowingIndex: undefined,
+ });
+ });
+ });
+
+ test(`should handle overflow only whenever overflowButton is passed`, async ({
+ page,
+ }) => {
+ await page.goto(
+ `/ButtonGroup?provideOverflowButton=false&showToggleProvideOverflowButton=true`,
+ );
+
+ const setContainerSize = getSetContainerSize(page, 'horizontal');
+ const expectOverflowState = getExpectOverflowState(page, 'end');
+
+ await expectOverflowState({
+ expectedButtonLength: 3,
+ expectedOverflowTagFirstOverflowingIndex: undefined,
+ });
+
+ await setContainerSize(2.5);
+
+ await expectOverflowState({
+ expectedButtonLength: 3,
+ expectedOverflowTagFirstOverflowingIndex: undefined,
+ });
+
+ const toggleProviderOverflowContainerButton = page.getByTestId(
+ 'toggle-provide-overflow-container',
+ );
+
+ await toggleProviderOverflowContainerButton.click();
+ await expectOverflowState({
+ expectedButtonLength: 2,
+ expectedOverflowTagFirstOverflowingIndex: 1,
+ });
+
+ await toggleProviderOverflowContainerButton.click();
+ await expectOverflowState({
+ expectedButtonLength: 3,
+ expectedOverflowTagFirstOverflowingIndex: undefined,
+ });
+ });
+
+ (
+ [
+ {
+ visibleCount: 9,
+ overflowStart: 1,
+ overflowPlacement: 'start',
+ },
+ {
+ visibleCount: 8,
+ overflowStart: 2,
+ overflowPlacement: 'start',
+ },
+ {
+ visibleCount: 4,
+ overflowStart: 6,
+ overflowPlacement: 'start',
+ },
+ {
+ visibleCount: 3,
+ overflowStart: 7,
+ overflowPlacement: 'start',
+ },
+ {
+ visibleCount: 1,
+ overflowStart: 9,
+ overflowPlacement: 'start',
+ },
+ {
+ visibleCount: 9,
+ overflowStart: 8,
+ overflowPlacement: 'end',
+ },
+ {
+ visibleCount: 8,
+ overflowStart: 7,
+ overflowPlacement: 'end',
+ },
+ {
+ visibleCount: 4,
+ overflowStart: 3,
+ overflowPlacement: 'end',
+ },
+ {
+ visibleCount: 3,
+ overflowStart: 2,
+ overflowPlacement: 'end',
+ },
+ {
+ visibleCount: 1,
+ overflowStart: 0,
+ overflowPlacement: 'end',
+ },
+ ] as const
+ ).forEach(({ visibleCount, overflowStart, overflowPlacement }) => {
+ test(`should calculate correct values when overflowPlacement=${overflowPlacement} and visibleCount=${visibleCount}`, async ({
+ page,
+ }) => {
+ await page.goto(
+ `/ButtonGroup?exampleType=overflow&overflowPlacement=${overflowPlacement}`,
+ );
+
+ const setContainerSize = getSetContainerSize(page, 'horizontal');
+ await setContainerSize(visibleCount + 0.5);
+
+ const allItems = await page.locator('button').all();
+ const overflowButton =
+ allItems[overflowPlacement === 'end' ? allItems.length - 1 : 0];
+ const buttonGroupButtons = allItems.slice(
+ overflowPlacement === 'end' ? 0 : 1,
+ overflowPlacement === 'end' ? -1 : undefined,
+ );
+
+ await expect(overflowButton).toHaveText(`${overflowStart}`);
+ expect(buttonGroupButtons).toHaveLength(visibleCount - 1);
+ });
+ });
+});
+
+// ----------------------------------------------------------------------------
+
+const getSetContainerSize = (
+ page: Page,
+ orientation: 'horizontal' | 'vertical',
+) => {
+ return async (
+ /**
+ * Set container size relative to the item size.
+ */
+ multiplier: number | undefined,
+ ) => {
+ await page.locator('#container').evaluate(
+ (element, args) => {
+ if (args.multiplier != null) {
+ const overlappingBorderOvercount = args.multiplier - 1;
+
+ if (args.orientation === 'horizontal') {
+ element.style.setProperty(
+ 'width',
+ `${50 * args.multiplier - overlappingBorderOvercount - 1}px`, // - 1 to force the overflow
+ );
+ } else {
+ element.style.setProperty(
+ 'height',
+ `${36 * args.multiplier - overlappingBorderOvercount - 1}px`,
+ );
+ }
+ } else {
+ if (args.orientation === 'horizontal') {
+ element.style.removeProperty('width');
+ } else {
+ element.style.removeProperty('height');
+ }
+ }
+ },
+ { orientation, multiplier },
+ );
+ await page.waitForTimeout(300);
+ };
+};
+
+const getExpectOverflowState = (
+ page: Page,
+ overflowPlacement: 'start' | 'end',
+) => {
+ return async ({
+ expectedButtonLength,
+ expectedOverflowTagFirstOverflowingIndex,
+ }: {
+ expectedButtonLength: number;
+ expectedOverflowTagFirstOverflowingIndex: number | undefined;
+ }) => {
+ const buttons = await page.locator('#container button').all();
+ expect(buttons).toHaveLength(expectedButtonLength);
+
+ if (expectedOverflowTagFirstOverflowingIndex != null) {
+ await expect(
+ buttons[overflowPlacement === 'end' ? buttons.length - 1 : 0],
+ ).toHaveText(`${expectedOverflowTagFirstOverflowingIndex}`);
+ } else {
+ await expect(page.getByTestId('overflow-button')).toHaveCount(0);
+ }
+ };
+};
diff --git a/testing/e2e/app/routes/ComboBox/route.tsx b/testing/e2e/app/routes/ComboBox/route.tsx
index 49147d58184..89d348c86e6 100644
--- a/testing/e2e/app/routes/ComboBox/route.tsx
+++ b/testing/e2e/app/routes/ComboBox/route.tsx
@@ -1,24 +1,86 @@
-import { ComboBox } from '@itwin/itwinui-react';
+import { Button, ComboBox } from '@itwin/itwinui-react';
import { useSearchParams } from '@remix-run/react';
+import * as React from 'react';
export default function ComboBoxTest() {
- const [searchParams] = useSearchParams();
+ const config = getConfigFromSearchParams();
- const virtualization = searchParams.get('virtualization') === 'true';
+ return ;
+}
+
+const Default = ({
+ config,
+}: {
+ config: ReturnType;
+}) => {
+ const {
+ initialValue,
+ multiple,
+ options,
+ showChangeValueButton,
+ virtualization,
+ } = config;
+ const [value, setValue] = React.useState(initialValue);
return (
-
+
+ {showChangeValueButton && (
+
+ )}
+
+
+
);
+};
+
+// ----------------------------------------------------------------------------
+
+function getConfigFromSearchParams() {
+ const [searchParams] = useSearchParams();
+
+ const exampleType = searchParams.get('exampleType') as 'default' | undefined;
+ const virtualization = searchParams.get('virtualization') === 'true';
+ const multiple = searchParams.get('multiple') === 'true';
+ const showChangeValueButton =
+ searchParams.get('showChangeValueButton') === 'true';
+
+ const options = [
+ { label: 'Item 0', value: 0 },
+ { label: 'Item 1', value: 1, subLabel: 'sub label' },
+ { label: 'Item 2', value: 2 },
+ { label: 'Item 3', value: 3 },
+ { label: 'Item 4', value: 4 },
+ { label: 'Item 10', value: 10 },
+ { label: 'Item 11', value: 11 },
+ ];
+
+ const initialValueSearchParam = searchParams.get('initialValue') as
+ | ('all' & string & {})
+ | null;
+ const initialValue =
+ initialValueSearchParam != null
+ ? initialValueSearchParam === 'all'
+ ? options.map((option) => option.value)
+ : (JSON.parse(initialValueSearchParam) as number | number[])
+ : undefined;
+
+ return {
+ exampleType,
+ virtualization,
+ multiple,
+ showChangeValueButton,
+ options,
+ initialValue,
+ };
}
diff --git a/testing/e2e/app/routes/ComboBox/spec.ts b/testing/e2e/app/routes/ComboBox/spec.ts
index 08a90999574..8efe52e8987 100644
--- a/testing/e2e/app/routes/ComboBox/spec.ts
+++ b/testing/e2e/app/routes/ComboBox/spec.ts
@@ -1,6 +1,80 @@
-import { test, expect } from '@playwright/test';
+import { test, expect, Page } from '@playwright/test';
-test.describe('ComboBox', () => {
+const defaultOptions = [
+ { label: 'Item 0', value: 0 },
+ { label: 'Item 1', value: 1, subLabel: 'sub label' },
+ { label: 'Item 2', value: 2 },
+ { label: 'Item 3', value: 3 },
+ { label: 'Item 4', value: 4 },
+ { label: 'Item 10', value: 10 },
+ { label: 'Item 11', value: 11 },
+];
+
+test('should select multiple options', async ({ page }) => {
+ await page.goto('/ComboBox?multiple=true');
+
+ await page.keyboard.press('Tab');
+
+ const options = await page.locator('[role="option"]').all();
+ for (const option of options) {
+ await option.click();
+ }
+
+ const tags = getSelectTagContainerTags(page);
+ await expect(tags).toHaveCount(options.length);
+
+ for (let i = 0; i < (await tags.count()); i++) {
+ await expect(tags.nth(i)).toHaveText(
+ (await options[i].textContent()) ?? '',
+ );
+ }
+});
+
+[true, false].forEach((multiple) => {
+ test(`should respect the value prop (${multiple})`, async ({ page }) => {
+ await page.goto(
+ `/ComboBox?multiple=${multiple}&initialValue=${
+ multiple ? 'all' : 11
+ }&showChangeValueButton=true`,
+ );
+
+ await page.waitForTimeout(200);
+
+ let tags = getSelectTagContainerTags(page);
+
+ // Should change internal state when the value prop changes
+ if (multiple) {
+ await expect(tags).toHaveCount(defaultOptions.length);
+
+ for (let i = 0; i < (await tags.count()); i++) {
+ await expect(tags.nth(i)).toHaveText(defaultOptions[i].label);
+ }
+
+ await page.getByTestId('change-value-to-first-option-button').click();
+
+ await expect(tags).toHaveCount(1);
+ await expect(tags.first()).toHaveText(defaultOptions[0].label);
+ } else {
+ await expect(page.locator('input')).toHaveValue('Item 11');
+ await page.getByTestId('change-value-to-first-option-button').click();
+ await expect(page.locator('input')).toHaveValue('Item 0');
+ }
+
+ // Should not allow to select other options
+ await page.keyboard.press('Tab');
+
+ await page.getByRole('option').nth(3).click();
+
+ if (multiple) {
+ await expect(tags).toHaveCount(1);
+ await expect(tags.first()).toHaveText(defaultOptions[0].label);
+ } else {
+ await expect(page.locator('input')).toHaveValue('Item 0');
+ }
+ });
+});
+
+test.describe('ComboBox (virtualization)', () => {
test('should support keyboard navigation when virtualization is enabled', async ({
page,
}) => {
@@ -76,7 +150,7 @@ test.describe('ComboBox', () => {
//Filter and focus first
await comboBoxInput.fill('1');
- expect((await items.all()).length).toBe(3);
+ expect(items).toHaveCount(3);
await page.keyboard.press('ArrowDown');
await expect(comboBoxInput).toHaveAttribute(
'aria-activedescendant',
@@ -178,4 +252,108 @@ test.describe('ComboBox', () => {
totalItemsHeight,
);
});
+
+ test.describe('ComboBox (overflow)', () => {
+ test(`should overflow whenever there is not enough space`, async ({
+ page,
+ }) => {
+ await page.goto(`/ComboBox?multiple=true&initialValue=all`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 7,
+ expectedLastTagTextContent: 'Item 11',
+ });
+
+ await setContainerSize(page, '500px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 4,
+ expectedLastTagTextContent: '+4 item(s)',
+ });
+ });
+
+ test(`should at minimum always show one overflow tag`, async ({ page }) => {
+ await page.goto(`/ComboBox?multiple=true&initialValue=all`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 7,
+ expectedLastTagTextContent: 'Item 11',
+ });
+
+ await setContainerSize(page, '10px');
+ await page.waitForTimeout(200);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: '+7 item(s)',
+ });
+ });
+
+ test('should always show the selected tag and no overflow tag when only one item is selected', async ({
+ page,
+ }) => {
+ await page.goto(`/ComboBox?multiple=true&initialValue=[11]`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: 'Item 11',
+ });
+
+ await setContainerSize(page, '50px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: 'Item 11',
+ });
+ });
+ });
});
+
+// ----------------------------------------------------------------------------
+
+const setContainerSize = async (page: Page, value: string | undefined) => {
+ await page.getByTestId('container').evaluate(
+ (element, args) => {
+ if (args.value != null) {
+ element.style.setProperty('width', args.value);
+ } else {
+ element.style.removeProperty('width');
+ }
+ },
+ { value },
+ );
+ await page.waitForTimeout(200);
+};
+
+const expectOverflowState = async ({
+ page,
+ expectedItemLength,
+ expectedLastTagTextContent,
+}: {
+ page: Page;
+ expectedItemLength: number;
+ expectedLastTagTextContent: string | undefined;
+}) => {
+ const tags = getSelectTagContainerTags(page);
+ await expect(tags).toHaveCount(expectedItemLength);
+
+ const lastTag = tags.last();
+
+ if (expectedLastTagTextContent != null) {
+ await expect(lastTag).toHaveText(expectedLastTagTextContent);
+ } else {
+ await expect(tags).toHaveCount(0);
+ }
+};
+
+const getSelectTagContainerTags = (page: Page) => {
+ // TODO: Remove this implementation detail of DOM hierarchy when we can customize the tag container.
+ // See: https://github.com/iTwin/iTwinUI/pull/2151#discussion_r1684394649
+ return page.getByRole('combobox').locator('+ div > span');
+};
diff --git a/testing/e2e/app/routes/DropdownMenu/spec.ts b/testing/e2e/app/routes/DropdownMenu/spec.ts
index 9ee0974679c..35437928266 100644
--- a/testing/e2e/app/routes/DropdownMenu/spec.ts
+++ b/testing/e2e/app/routes/DropdownMenu/spec.ts
@@ -309,7 +309,7 @@ test.describe('DropdownMenu', () => {
await page.locator('button').first().click();
await page.locator('button').nth(10).scrollIntoViewIfNeeded();
- await expect(page.getByRole('menu')).toBeVisible();
+ await expect(page.getByRole('menu')).toHaveCount(1);
});
});
diff --git a/testing/e2e/app/routes/MiddleTextTruncation/route.tsx b/testing/e2e/app/routes/MiddleTextTruncation/route.tsx
new file mode 100644
index 00000000000..4ff48dc1247
--- /dev/null
+++ b/testing/e2e/app/routes/MiddleTextTruncation/route.tsx
@@ -0,0 +1,31 @@
+import { MiddleTextTruncation } from '@itwin/itwinui-react';
+import { useSearchParams } from '@remix-run/react';
+
+const longText =
+ 'MyFileWithAReallyLongNameThatWillBeTruncatedBecauseItIsReallyThatLongSoHardToBelieve_FinalVersion_V2.html';
+
+export default function MiddleTextTruncationTest() {
+ const [searchParams] = useSearchParams();
+
+ const shouldUseCustomRenderer =
+ searchParams.get('shouldUseCustomRenderer') === 'true';
+
+ return (
+
+ (
+
+ {truncatedText} - some additional text
+
+ )
+ : undefined
+ }
+ />
+
+ );
+}
diff --git a/testing/e2e/app/routes/MiddleTextTruncation/spec.ts b/testing/e2e/app/routes/MiddleTextTruncation/spec.ts
new file mode 100644
index 00000000000..98bebeb9b94
--- /dev/null
+++ b/testing/e2e/app/routes/MiddleTextTruncation/spec.ts
@@ -0,0 +1,86 @@
+import { test, expect, Page } from '@playwright/test';
+
+test.describe('MiddleTextTruncation', () => {
+ const longItem =
+ 'MyFileWithAReallyLongNameThatWillBeTruncatedBecauseItIsReallyThatLongSoHardToBelieve_FinalVersion_V2.html';
+
+ test(`should overflow whenever there is not enough space`, async ({
+ page,
+ }) => {
+ await page.goto(`/MiddleTextTruncation`);
+
+ const middleTextTruncation = page.getByTestId('middleTextTruncation');
+ await expect(middleTextTruncation.first()).toHaveText(longItem);
+
+ await setContainerSize(page, '200px');
+
+ const truncatedText = await middleTextTruncation.first().textContent();
+
+ // There should be at least some truncation
+ expect(truncatedText).toMatch(new RegExp('.+…2.html')); // should have ellipsis
+ expect(truncatedText).not.toMatch(
+ new RegExp(`${longItem.slice(0, longItem.length - '2.html'.length)}.+`),
+ ); // should not have full text before the ellipsis
+
+ await setContainerSize(page, undefined);
+
+ // should restore hidden items when space is available again
+ await expect(middleTextTruncation.first()).toHaveText(longItem);
+ });
+
+ test(`should at minimum always show ellipses and endCharsCount number of characters`, async ({
+ page,
+ }) => {
+ await page.goto(`/MiddleTextTruncation`);
+
+ const endCharsCount = 6;
+
+ const middleTextTruncation = page.getByTestId('container');
+ await expect(middleTextTruncation.first()).toHaveText(longItem);
+
+ await setContainerSize(page, '20px');
+
+ await expect(middleTextTruncation.first()).toHaveText(
+ `…${longItem.slice(-endCharsCount)}`,
+ );
+
+ await setContainerSize(page, undefined);
+
+ // should restore hidden items when space is available again
+ await expect(middleTextTruncation.first()).toHaveText(longItem);
+ });
+
+ test('should render custom text', async ({ page }) => {
+ await page.goto(`/MiddleTextTruncation?shouldUseCustomRenderer=true`);
+
+ await setContainerSize(page, '500px');
+
+ const truncatedText = await page.getByTestId('custom-text').textContent();
+
+ // There should be at least some truncation
+ expect(truncatedText).toMatch(
+ new RegExp('.+…2.html - some additional text'),
+ ); // should have ellipsis
+ expect(truncatedText).not.toMatch(
+ new RegExp(`${longItem.slice(0, longItem.length - '2.html'.length)}.+`),
+ ); // should not have full text before the ellipsis
+
+ await page.waitForTimeout(200);
+ });
+});
+
+// ----------------------------------------------------------------------------
+
+const setContainerSize = async (page: Page, value: string | undefined) => {
+ await page.getByTestId('container').evaluate(
+ (element, args) => {
+ if (args.value != null) {
+ element.style.setProperty('width', args.value);
+ } else {
+ element.style.removeProperty('width');
+ }
+ },
+ { value },
+ );
+ await page.waitForTimeout(200);
+};
diff --git a/testing/e2e/app/routes/Select/route.tsx b/testing/e2e/app/routes/Select/route.tsx
new file mode 100644
index 00000000000..8013e477021
--- /dev/null
+++ b/testing/e2e/app/routes/Select/route.tsx
@@ -0,0 +1,50 @@
+import { Select } from '@itwin/itwinui-react';
+import { useSearchParams } from '@remix-run/react';
+
+export default function SelectTest() {
+ const [searchParams] = useSearchParams();
+
+ const options = [
+ ...Array(9)
+ .fill(null)
+ .map((_, index) => {
+ return {
+ label: `option ${index}`,
+ value: index,
+ };
+ }),
+ {
+ label: 'Very long option',
+ value: 9,
+ },
+ ];
+
+ /**
+ * `value`/`defaultValue` can be a:
+ * - `"all"`
+ * - a single value (when multiple=false)
+ * - an array of values (when multiple=false)
+ */
+ const searchParamValue = searchParams.get('value') as
+ | ('all' & string & {})
+ | null;
+ const value = (() => {
+ if (searchParamValue == null) {
+ return undefined;
+ }
+
+ if (searchParamValue === 'all') {
+ return options.map((option) => option.value);
+ }
+ return JSON.parse(searchParamValue) as number | number[];
+ })();
+ const multiple = searchParams.get('multiple') === 'true';
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/testing/e2e/app/routes/Select/spec.ts b/testing/e2e/app/routes/Select/spec.ts
new file mode 100644
index 00000000000..d9f00ca587f
--- /dev/null
+++ b/testing/e2e/app/routes/Select/spec.ts
@@ -0,0 +1,140 @@
+import { test, expect, Page } from '@playwright/test';
+
+test.describe('Select (general)', () => {
+ [true, false].forEach((multiple) => {
+ test(`should work in uncontrolled mode (multiple=${multiple})`, async ({
+ page,
+ }) => {
+ await page.goto(`/Select?multiple=${multiple}`);
+
+ await page.waitForTimeout(200);
+
+ await page.keyboard.press('Tab');
+ await page.keyboard.press('Space');
+
+ await page.getByRole('option').first().click();
+ expect(page.getByRole('combobox')).toHaveText('option 0');
+
+ if (!multiple) {
+ await page.getByRole('combobox').click();
+ }
+
+ await page.getByRole('option').nth(1).click();
+
+ if (multiple) {
+ await page.getByRole('option').first().click();
+ }
+
+ await expect(page.getByRole('combobox')).toHaveText('option 1');
+ });
+ });
+});
+
+test.describe('Select (overflow)', () => {
+ test(`should overflow whenever there is not enough space`, async ({
+ page,
+ }) => {
+ await page.goto(`/Select?multiple=true&value=all`);
+
+ await page.waitForTimeout(200);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 10,
+ expectedLastTagTextContent: 'Very long option',
+ });
+
+ await setContainerSize(page, '600px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 6,
+ expectedLastTagTextContent: '+5 item(s)',
+ });
+ });
+
+ test(`should at minimum always show one overflow tag`, async ({ page }) => {
+ await page.goto(`/Select?multiple=true&value=all`);
+
+ await page.waitForTimeout(200);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 10,
+ expectedLastTagTextContent: 'Very long option',
+ });
+
+ await setContainerSize(page, '10px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: '+10 item(s)',
+ });
+ });
+
+ test(`should always show the selected tag and no overflow tag when only one item is selected`, async ({
+ page,
+ }) => {
+ await page.goto(`/Select?multiple=true&value=[9]`);
+
+ await page.waitForTimeout(200);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: 'Very long option',
+ });
+
+ await setContainerSize(page, '160px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedLastTagTextContent: 'Very long option',
+ });
+ });
+});
+
+// ----------------------------------------------------------------------------
+
+const setContainerSize = async (page: Page, value: string | undefined) => {
+ await page.locator('#container').evaluate(
+ (element, args) => {
+ if (args.value != null) {
+ element.style.setProperty('width', args.value);
+ } else {
+ element.style.removeProperty('width');
+ }
+ },
+ { value },
+ );
+ await page.waitForTimeout(200);
+};
+
+const expectOverflowState = async ({
+ page,
+ expectedItemLength,
+ expectedLastTagTextContent,
+}: {
+ page: Page;
+ expectedItemLength: number;
+ expectedLastTagTextContent: string | undefined;
+}) => {
+ const tags = await getSelectTagContainerTags(page);
+ expect(tags).toHaveLength(expectedItemLength);
+
+ const lastTag = tags[tags.length - 1];
+
+ if (expectedLastTagTextContent != null) {
+ await expect(lastTag).toHaveText(expectedLastTagTextContent);
+ } else {
+ expect(tags).toHaveLength(0);
+ }
+};
+
+const getSelectTagContainerTags = async (page: Page) => {
+ // TODO: Remove this implementation detail of class name when we can customize the tag container.
+ // See: https://github.com/iTwin/iTwinUI/pull/2151#discussion_r1684394649
+ return await page.locator('span[class$="-select-tag"]').all();
+};
diff --git a/testing/e2e/app/routes/Table/route.tsx b/testing/e2e/app/routes/Table/route.tsx
index 6678c491104..09c967ca4ff 100644
--- a/testing/e2e/app/routes/Table/route.tsx
+++ b/testing/e2e/app/routes/Table/route.tsx
@@ -1,4 +1,9 @@
-import { Table, tableFilters } from '@itwin/itwinui-react';
+import {
+ Table,
+ tableFilters,
+ TablePaginator,
+ TablePaginatorRendererProps,
+} from '@itwin/itwinui-react';
import type { CellProps } from '@itwin/itwinui-react/react-table';
import { useSearchParams } from '@remix-run/react';
import React from 'react';
@@ -6,11 +11,17 @@ import React from 'react';
export default function Page() {
const [searchParams] = useSearchParams();
- if (searchParams.get('allFilters')) {
- return ;
- }
+ const config = getConfigFromSearchParams(searchParams);
+ const { exampleType } = config;
- return ;
+ switch (exampleType) {
+ case 'withTablePaginator':
+ return ;
+ case 'allFilters':
+ return ;
+ default:
+ return ;
+ }
}
// ----------------------------------------------------------------------------
@@ -53,13 +64,17 @@ function FiltersTest() {
// ----------------------------------------------------------------------------
-function EverythingElse() {
- const [searchParams] = useSearchParams();
-
+const getConfigFromSearchParams = (searchParams: URLSearchParams) => {
+ const exampleType = searchParams.get('exampleType') || 'default';
const disableResizing = searchParams.get('disableResizing') === 'true';
const columnResizeMode = searchParams.get('columnResizeMode') || 'fit';
const maxWidths = searchParams.getAll('maxWidth');
const minWidths = searchParams.getAll('minWidth');
+ const density = (searchParams.get('density') || undefined) as
+ | 'default'
+ | 'condensed'
+ | 'extra-condensed'
+ | undefined;
const isSelectable = searchParams.get('isSelectable') === 'true';
const rows = searchParams.get('rows');
const filter = searchParams.get('filter') === 'true';
@@ -72,11 +87,57 @@ function EverythingElse() {
const scrollRow = Number(searchParams.get('scrollRow'));
const hasSubComponent = searchParams.get('hasSubComponent') === 'true';
+ return {
+ exampleType,
+ disableResizing,
+ columnResizeMode,
+ maxWidths,
+ minWidths,
+ density,
+ isSelectable,
+ rows,
+ filter,
+ selectSubRows,
+ enableVirtualization,
+ empty,
+ scroll,
+ oneRow,
+ stateReducer,
+ scrollRow,
+ hasSubComponent,
+ };
+};
+
+const Default = ({
+ config,
+}: {
+ config: ReturnType;
+}) => {
+ const {
+ oneRow,
+ empty,
+ columnResizeMode,
+ density,
+ disableResizing,
+ enableVirtualization,
+ filter,
+ isSelectable,
+ maxWidths,
+ minWidths,
+ scroll,
+ scrollRow,
+ selectSubRows,
+ stateReducer,
+ rows,
+ hasSubComponent,
+ } = config;
+
const virtualizedData = React.useMemo(() => {
- const size = oneRow ? 1 : 100000;
if (empty) {
return [];
}
+
+ const size = oneRow ? 1 : 100000;
const arr = new Array(size);
for (let i = 0; i < size; ++i) {
arr[i] = {
@@ -143,6 +204,7 @@ function EverythingElse() {
isRowDisabled={isRowDisabled}
isSelectable={isSelectable}
isSortable
+ density={density}
columnResizeMode={columnResizeMode as 'fit' | 'expand' | undefined}
selectSubRows={selectSubRows}
enableVirtualization={enableVirtualization}
@@ -173,7 +235,68 @@ function EverythingElse() {
/>
>
);
-}
+};
+
+const WithTablePaginator = ({
+ config,
+}: {
+ config: ReturnType;
+}) => {
+ const { density } = config;
+
+ const columns = React.useMemo(
+ () => [
+ {
+ id: 'name',
+ Header: 'Name',
+ accessor: 'name',
+ Filter: tableFilters.TextFilter(),
+ },
+ {
+ id: 'description',
+ Header: 'Description',
+ accessor: 'description',
+ maxWidth: 200,
+ Filter: tableFilters.TextFilter(),
+ },
+ ],
+ [],
+ );
+
+ const data = React.useMemo(
+ () =>
+ Array(505)
+ .fill(null)
+ .map((_, index) => ({
+ name: `Name ${index}`,
+ description: `Description ${index}`,
+ })),
+ [],
+ );
+
+ const paginator = React.useCallback(
+ (props: TablePaginatorRendererProps) => (
+
+ ),
+ [],
+ );
+
+ return (
+ <>
+
+ >
+ );
+};
// ----------------------------------------------------------------------------
diff --git a/testing/e2e/app/routes/Table/spec.ts b/testing/e2e/app/routes/Table/spec.ts
index 5071b33f14c..9cc5355ac26 100644
--- a/testing/e2e/app/routes/Table/spec.ts
+++ b/testing/e2e/app/routes/Table/spec.ts
@@ -444,6 +444,152 @@ test.describe('Table row selection', () => {
//#endregion
});
+test.describe('Table Paginator', () => {
+ test(`should render data in pages`, async ({ page }) => {
+ await page.goto(`/Table?exampleType=withTablePaginator`);
+
+ await expect(page.locator(`[role="cell"]`).first()).toHaveText('Name 0');
+ await expect(page.locator(`[role="cell"]`).last()).toHaveText(
+ 'Description 49',
+ );
+
+ // Go to the 6th page
+ await page.locator('button').last().click({ clickCount: 5 });
+
+ await expect(page.locator(`[role="cell"]`).first()).toHaveText('Name 250');
+ await expect(page.locator(`[role="cell"]`).last()).toHaveText(
+ 'Description 299',
+ );
+ });
+
+ test('should render truncated pages list', async ({ page }) => {
+ await page.goto(`/Table?exampleType=withTablePaginator`);
+
+ await setContainerSize(page, '800px');
+
+ // Go to the 6th page
+ await page.locator('button').last().click({ clickCount: 5 });
+
+ const paginatorButtons = page.locator('#paginator button', {
+ hasText: /[0-9]+/,
+ });
+ await expect(paginatorButtons).toHaveText(['1', '5', '6', '7', '11']);
+ await expect(paginatorButtons.nth(2)).toHaveAttribute(
+ 'data-iui-active',
+ 'true',
+ );
+
+ await expect(page.getByText('…')).toHaveCount(2);
+ });
+
+ test(`should overflow whenever there is not enough space`, async ({
+ page,
+ }) => {
+ await page.goto(`/Table?exampleType=withTablePaginator`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 11,
+ expectedOverflowingEllipsisVisibleCount: 0,
+ });
+
+ await setContainerSize(page, '750px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 6,
+ expectedOverflowingEllipsisVisibleCount: 1,
+ });
+
+ // should restore hidden items when space is available again
+ await setContainerSize(page, undefined);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 11,
+ expectedOverflowingEllipsisVisibleCount: 0,
+ });
+ });
+
+ test(`should at minimum always show one page`, async ({ page }) => {
+ await page.goto(`/Table?exampleType=withTablePaginator`);
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 11,
+ expectedOverflowingEllipsisVisibleCount: 0,
+ });
+
+ await setContainerSize(page, '100px');
+
+ await expectOverflowState({
+ page,
+ expectedItemLength: 1,
+ expectedOverflowingEllipsisVisibleCount: 0,
+ });
+
+ await expect(
+ page.locator('#paginator button', { hasText: /1/ }),
+ ).toHaveAttribute('data-iui-active', 'true');
+ });
+
+ test(`should render elements in small size`, async ({ page }) => {
+ await page.goto(`/Table?exampleType=withTablePaginator&density=condensed`);
+
+ await setContainerSize(page, '500px');
+
+ (await page.locator('#paginator button').all()).forEach(async (button) => {
+ await expect(button).toHaveAttribute('data-iui-size', 'small');
+ });
+
+ await expect(page.getByText('…')).toHaveClass(
+ /_iui[0-9]+-table-paginator-ellipsis-small/,
+ );
+
+ await page.waitForTimeout(300);
+ });
+
+ //#region Helpers for table paginator tests
+ const setContainerSize = async (page: Page, value: string | undefined) => {
+ await page.locator('#container').evaluate(
+ (element, args) => {
+ if (args.value != null) {
+ element.style.setProperty('width', args.value ? args.value : `999px`);
+ } else {
+ element.style.removeProperty('width');
+ }
+ },
+ {
+ value,
+ },
+ );
+ await page.waitForTimeout(200);
+ };
+
+ const expectOverflowState = async ({
+ page,
+ expectedItemLength,
+ expectedOverflowingEllipsisVisibleCount,
+ }: {
+ page: Page;
+ expectedItemLength: number;
+ expectedOverflowingEllipsisVisibleCount: number;
+ }) => {
+ const allItems = await page.locator('#paginator button').all();
+ const items =
+ allItems.length >= 2
+ ? allItems.slice(1, allItems.length - 1) // since the first and last button and to toggle pages
+ : [];
+ expect(items).toHaveLength(expectedItemLength);
+
+ const overflowingEllipsis = page.getByText('…');
+ await expect(overflowingEllipsis).toHaveCount(
+ expectedOverflowingEllipsisVisibleCount,
+ );
+ };
+ //#endregion
+});
+
test.describe('Virtual Scroll Tests', () => {
test('should render only a few elements out of a big data set', async ({
page,
@@ -558,7 +704,7 @@ test.describe('Virtual Scroll Tests', () => {
test.describe('Table filters', () => {
test('DateRangeFilter should show DatePicker', async ({ page }) => {
- await page.goto('/Table?allFilters=true');
+ await page.goto('/Table?exampleType=allFilters');
// open Date filter
const dateHeader = page.locator('[role="columnheader"]', {