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