From 71a36119713b093f6b18bcaf9041aa8f0d32fb6a Mon Sep 17 00:00:00 2001 From: Rohan <45748283+rohan-kadkol@users.noreply.github.com> Date: Thu, 18 Jul 2024 13:30:05 -0400 Subject: [PATCH 01/25] Brought back e2e tests from combined PR --- testing/e2e/app/routes/Breadcrumbs/route.tsx | 25 + testing/e2e/app/routes/Breadcrumbs/spec.ts | 94 ++++ testing/e2e/app/routes/ButtonGroup/route.tsx | 93 +++- testing/e2e/app/routes/ButtonGroup/spec.ts | 417 ++++++++++++-- testing/e2e/app/routes/ComboBox/route.tsx | 55 +- testing/e2e/app/routes/ComboBox/spec.ts | 515 ++++++++++++------ .../app/routes/MiddleTextTruncation/route.tsx | 31 ++ .../app/routes/MiddleTextTruncation/spec.ts | 90 +++ testing/e2e/app/routes/Select/route.tsx | 47 ++ testing/e2e/app/routes/Select/spec.ts | 143 +++++ testing/e2e/app/routes/Table/route.tsx | 321 ++++++----- testing/e2e/app/routes/Table/spec.ts | 163 ++++++ 12 files changed, 1646 insertions(+), 348 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..ba80a9973bf --- /dev/null +++ b/testing/e2e/app/routes/Breadcrumbs/spec.ts @@ -0,0 +1,94 @@ +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`); + + const setContainerSize = getSetContainerSize(page); + const expectOverflowState = getExpectOverflowState(page); + + await expectOverflowState({ + expectedItemLength: 5, + expectedOverflowButtonVisibleCount: undefined, + }); + + await setContainerSize('200px'); + + await expectOverflowState({ + expectedItemLength: 2, + expectedOverflowButtonVisibleCount: 2, + }); + + // should restore hidden items when space is available again + await setContainerSize(undefined); + + await expectOverflowState({ + expectedItemLength: 5, + expectedOverflowButtonVisibleCount: undefined, + }); + }); + + test(`should at minimum always show one overflow tag and one item`, async ({ + page, + }) => { + await page.goto(`/Breadcrumbs`); + + const setContainerSize = getSetContainerSize(page); + const expectOverflowState = getExpectOverflowState(page); + + await expectOverflowState({ + expectedItemLength: 5, + expectedOverflowButtonVisibleCount: undefined, + }); + + await setContainerSize('10px'); + + await expectOverflowState({ + expectedItemLength: 1, + expectedOverflowButtonVisibleCount: 1, + }); + }); +}); + +// ---------------------------------------------------------------------------- + +const getSetContainerSize = (page: Page) => { + return async (dimension: string | undefined) => { + await page.getByTestId('container').evaluate( + (element, args) => { + if (args.dimension != null) { + element.style.setProperty('width', args.dimension); + } else { + element.style.removeProperty('width'); + } + }, + { dimension }, + ); + await page.waitForTimeout(30); + }; +}; + +const getExpectOverflowState = (page: Page) => { + return async ({ + expectedItemLength, + expectedOverflowButtonVisibleCount, + }: { + expectedItemLength: number; + expectedOverflowButtonVisibleCount: number | undefined; + }) => { + const items = page.getByTestId('item'); + expect(items).toHaveCount(expectedItemLength); + + const overflowButton = page.locator('button'); + + if (expectedOverflowButtonVisibleCount != null) { + expect(await overflowButton.textContent()).toBe( + `${expectedOverflowButtonVisibleCount}`, + ); + } else { + expect(overflowButton).toHaveCount(0); + } + }; +}; diff --git a/testing/e2e/app/routes/ButtonGroup/route.tsx b/testing/e2e/app/routes/ButtonGroup/route.tsx index 1399e71b436..862e3f12b79 100644 --- a/testing/e2e/app/routes/ButtonGroup/route.tsx +++ b/testing/e2e/app/routes/ButtonGroup/route.tsx @@ -1,23 +1,90 @@ -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 React from 'react'; export default function ButtonGroupTest() { const [searchParams] = useSearchParams(); - const orientation = searchParams.get('orientation') || 'horizontal'; + 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; - return ( - - - - - - - - + const Default = () => { + const [provideOverflowButton, setProvideOverflowButton] = React.useState( + initialProvideOverflowButton, + ); + + return ( + + + +
+ { + return ( + + {firstOverflowingIndex} + + ); + } + : undefined + } + > + + + + + + + + + + +
+
+ ); + }; + + const Overflow = () => { + const buttons = [...Array(10)].map((_, index) => ( + -
- ); + )); + + return ( +
+ {startIndex}} + overflowPlacement={overflowPlacement} + > + {buttons} + +
+ ); + }; + + return exampleType === 'default' ? : ; } diff --git a/testing/e2e/app/routes/ButtonGroup/spec.ts b/testing/e2e/app/routes/ButtonGroup/spec.ts index 0ad18d62823..ca4cd1eaa14 100644 --- a/testing/e2e/app/routes/ButtonGroup/spec.ts +++ b/testing/e2e/app/routes/ButtonGroup/spec.ts @@ -1,63 +1,398 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, Page } from '@playwright/test'; test.describe('ButtonGroup', () => { - test("should support keyboard navigation when role='toolbar'", async ({ - page, - }) => { - await page.goto('/ButtonGroup'); + test.describe('Toolbar', () => { + test("should support keyboard navigation when role='toolbar'", async ({ + page, + }) => { + await page.goto('/ButtonGroup'); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect( + page.getByRole('button', { name: 'Button 2' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect( + page.getByRole('button', { name: 'Button 2' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowLeft'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); + }); - await page.keyboard.press('Tab'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + test("should support keyboard navigation when role='toolbar' and orientation='vertical'", async ({ + page, + }) => { + await page.goto('/ButtonGroup?orientation=vertical'); - await page.keyboard.press('ArrowRight'); - await expect(page.getByRole('button', { name: 'Button 2' })).toBeFocused(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); - await page.keyboard.press('ArrowRight'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); - await page.keyboard.press('ArrowRight'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect( + page.getByRole('button', { name: 'Button 2' }), + ).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(page.getByRole('button', { name: 'Button 2' })).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await expect( + page.getByRole('button', { name: 'Button 2' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowUp'); + await expect( + page.getByRole('button', { name: 'Button 1' }), + ).toBeFocused(); + + await page.keyboard.press('ArrowUp'); + await expect( + page.getByRole('button', { name: 'Button 3' }), + ).toBeFocused(); + }); }); - test("should support keyboard navigation when role='toolbar' and orientation='vertical'", async ({ - page, - }) => { - await page.goto('/ButtonGroup?orientation=vertical'); + test.describe('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 page.keyboard.press('Tab'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + await setContainerSize(0.5); - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('button', { name: 'Button 2' })).toBeFocused(); + // should return 1 overflowTag when item is bigger than the container + await expectOverflowState({ + expectedButtonLength: 1, + expectedOverflowTagFirstOverflowingIndex: + overflowPlacement === 'end' ? 0 : 2, + }); - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + // should restore hidden items when space is available again + await setContainerSize(1.5); - await page.keyboard.press('ArrowDown'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + await expectOverflowState({ + expectedButtonLength: 1, + expectedOverflowTagFirstOverflowingIndex: + overflowPlacement === 'end' ? 0 : 2, + }); - await page.keyboard.press('ArrowUp'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + await setContainerSize(2.5); - await page.keyboard.press('ArrowUp'); - await expect(page.getByRole('button', { name: 'Button 2' })).toBeFocused(); + await expectOverflowState({ + expectedButtonLength: 2, + expectedOverflowTagFirstOverflowingIndex: 1, + }); - await page.keyboard.press('ArrowUp'); - await expect(page.getByRole('button', { name: 'Button 1' })).toBeFocused(); + await setContainerSize(undefined); - await page.keyboard.press('ArrowUp'); - await expect(page.getByRole('button', { name: 'Button 3' })).toBeFocused(); + await expectOverflowState({ + expectedButtonLength: 3, + expectedOverflowTagFirstOverflowingIndex: undefined, + }); + }); + }); + + test(`should handle overflow only whenever overflowButton is passed`, async ({ + page, + }) => { + await page.goto(`/ButtonGroup?provideOverflowButton=false`); + + 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: 10, + containerSize: '488px', + overflowStart: 0, + overflowPlacement: 'start', + }, + { + visibleCount: 9, + containerSize: '481px', + overflowStart: 1, + overflowPlacement: 'start', + }, + { + visibleCount: 8, + containerSize: '429px', + overflowStart: 2, + overflowPlacement: 'start', + }, + { + visibleCount: 4, + containerSize: '220px', + overflowStart: 6, + overflowPlacement: 'start', + }, + { + visibleCount: 3, + containerSize: '183px', + overflowStart: 7, + overflowPlacement: 'start', + }, + { + visibleCount: 1, + containerSize: '77px', + overflowStart: 9, + overflowPlacement: 'start', + }, + { + visibleCount: 10, + containerSize: '487px', + overflowStart: 9, + overflowPlacement: 'end', + }, + { + visibleCount: 9, + containerSize: '475px', + overflowStart: 8, + overflowPlacement: 'end', + }, + { + visibleCount: 8, + containerSize: '429px', + overflowStart: 7, + overflowPlacement: 'end', + }, + { + visibleCount: 4, + containerSize: '221px', + overflowStart: 3, + overflowPlacement: 'end', + }, + { + visibleCount: 3, + containerSize: '183px', + overflowStart: 2, + overflowPlacement: 'end', + }, + { + visibleCount: 1, + containerSize: '78px', + overflowStart: 0, + overflowPlacement: 'end', + }, + ] as const + ).forEach( + ({ visibleCount, containerSize, overflowStart, overflowPlacement }) => { + test(`should calculate correct values when overflowPlacement=${overflowPlacement} and visibleCount=${visibleCount}`, async ({ + page, + }) => { + await page.goto( + `/ButtonGroup?exampleType=overflow&containerSize${containerSize}&overflowPlacement=${overflowPlacement}`, + ); + + const setContainerSize = getSetContainerSize(page, 'horizontal'); + await setContainerSize( + visibleCount === 10 ? visibleCount : 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(30); + }; +}; + +const getExpectOverflowState = ( + page: Page, + overflowPlacement: 'start' | 'end', +) => { + return async ({ + expectedButtonLength, + expectedOverflowTagFirstOverflowingIndex, + }: { + expectedButtonLength: number; + expectedOverflowTagFirstOverflowingIndex: number | undefined; + }) => { + const buttons = await page.locator('#container button').all(); + await expect(buttons.length).toBe(expectedButtonLength); + + if (expectedOverflowTagFirstOverflowingIndex != null) { + await expect( + await buttons[ + overflowPlacement === 'end' ? buttons.length - 1 : 0 + ].textContent(), + ).toBe(`${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..6e67ff36317 100644 --- a/testing/e2e/app/routes/ComboBox/route.tsx +++ b/testing/e2e/app/routes/ComboBox/route.tsx @@ -1,24 +1,51 @@ -import { ComboBox } from '@itwin/itwinui-react'; +import { Button, ComboBox } from '@itwin/itwinui-react'; import { useSearchParams } from '@remix-run/react'; +import React from 'react'; export default function ComboBoxTest() { const [searchParams] = useSearchParams(); const virtualization = searchParams.get('virtualization') === 'true'; + const multiple = searchParams.get('multiple') === '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; + + const [value, setValue] = React.useState(initialValue); return ( - +
+ + + +
); } diff --git a/testing/e2e/app/routes/ComboBox/spec.ts b/testing/e2e/app/routes/ComboBox/spec.ts index 08a90999574..d3f2d9fab09 100644 --- a/testing/e2e/app/routes/ComboBox/spec.ts +++ b/testing/e2e/app/routes/ComboBox/spec.ts @@ -1,181 +1,382 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, Page } from '@playwright/test'; + +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.describe('ComboBox', () => { - test('should support keyboard navigation when virtualization is enabled', async ({ - page, - }) => { - await page.goto('/ComboBox?virtualization=true'); - - await page.keyboard.press('Tab'); - const comboBoxInput = page.locator('#test-component').locator('input'); - const comboBoxMenu = page.getByRole('listbox'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).toBeVisible(); + test.describe('General', () => { + test('should select multiple options', async ({ page }) => { + await page.goto('/ComboBox?multiple=true'); - const items = page.getByRole('option'); + await page.keyboard.press('Tab'); - //focus first - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-0', - ); - await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + const options = await page.locator('[role="option"]').all(); + for (const option of options) { + await option.click(); + } - //stay on first - await page.keyboard.press('ArrowUp'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-0', - ); - await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + const tags = await page + .locator('#test-component-selected-live > span') + .all(); + expect(tags).toHaveLength(options.length); - //focus second - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-1', - ); - await expect(items.first()).not.toHaveAttribute('data-iui-focused', 'true'); - await expect(items.nth(1)).toHaveAttribute('data-iui-focused', 'true'); + for (let i = 0; i < tags.length; i++) { + await expect(tags[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 + }`, + ); + + await page.waitForTimeout(60); + + // Should change internal state when the value prop changes + if (multiple) { + let tags = await page + .locator('#test-component-selected-live > span') + .all(); + expect(tags).toHaveLength(defaultOptions.length); + + for (let i = 0; i < tags.length; i++) { + await expect(tags[i]).toHaveText(defaultOptions[i].label); + } + + await page.getByTestId('change-value-to-first-option-button').click(); + tags = await page + .locator('#test-component-selected-live > span') + .all(); + + expect(tags).toHaveLength(1); + await expect(tags[0]).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) { + const tags = await page + .locator('#test-component-selected-live > span') + .all(); + + expect(tags).toHaveLength(1); + await expect(tags[0]).toHaveText(defaultOptions[0].label); + } else { + await expect(page.locator('input')).toHaveValue('Item 0'); + } + }); + }); + }); + + test.describe('Virtualization', () => { + test('should support keyboard navigation when virtualization is enabled', async ({ + page, + }) => { + await page.goto('/ComboBox?virtualization=true'); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); - //focus last - for (let i = 0; i <= 6; ++i) { + const comboBoxInput = page.locator('#test-component').locator('input'); + const comboBoxMenu = page.getByRole('listbox'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).toBeVisible(); + + const items = page.getByRole('option'); + + //focus first await page.keyboard.press('ArrowDown'); - } - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-11', - ); - await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-0', + ); + await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); - //stay on last - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-11', - ); - await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + //stay on first + await page.keyboard.press('ArrowUp'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-0', + ); + await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); - //select last - await page.keyboard.press('Enter'); - await expect(comboBoxInput).not.toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).not.toBeVisible(); - await expect(comboBoxInput).toHaveAttribute('value', 'Item 11'); - - //reopen menu - await page.keyboard.press('Enter'); - await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); - await expect(items.last()).toHaveAttribute('data-iui-active', 'true'); - - //Filter and focus first - await comboBoxInput.fill('1'); - expect((await items.all()).length).toBe(3); - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-1', - ); - await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + //focus second + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-1', + ); + await expect(items.first()).not.toHaveAttribute( + 'data-iui-focused', + 'true', + ); + await expect(items.nth(1)).toHaveAttribute('data-iui-focused', 'true'); - //stay on first - await page.keyboard.press('ArrowUp'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-1', - ); - await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + //focus last + for (let i = 0; i <= 6; ++i) { + await page.keyboard.press('ArrowDown'); + } + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-11', + ); + await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); - //focus second - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-10', - ); - await expect(items.first()).not.toHaveAttribute('data-iui-focused', 'true'); - await expect(items.nth(1)).toHaveAttribute('data-iui-focused', 'true'); - - //focus third(last filtered) - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-11', - ); - await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + //stay on last + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-11', + ); + await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); - //stay on third(last filtered) - await page.keyboard.press('ArrowDown'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-activedescendant', - 'test-component-option-Item-11', - ); - await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + //select last + await page.keyboard.press('Enter'); + await expect(comboBoxInput).not.toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).not.toBeVisible(); + await expect(comboBoxInput).toHaveAttribute('value', 'Item 11'); - //select last - await page.keyboard.press('Enter'); - await expect(comboBoxInput).not.toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).not.toBeVisible(); - await expect(comboBoxInput).toHaveAttribute('value', 'Item 11'); - - //reopen menu - await page.keyboard.press('ArrowDown'); - await expect(items.nth(6)).toHaveAttribute('data-iui-focused', 'true'); - await expect(items.nth(6)).toHaveAttribute('data-iui-active', 'true'); - - //close menu - await page.keyboard.press('Escape'); - await expect(comboBoxInput).not.toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).not.toBeVisible(); + //reopen menu + await page.keyboard.press('Enter'); + await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + await expect(items.last()).toHaveAttribute('data-iui-active', 'true'); - //reopen and close menu - await page.keyboard.press('X'); - await expect(comboBoxInput).toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).toBeVisible(); - await page.keyboard.press('Tab'); - await expect(comboBoxInput).not.toHaveAttribute( - 'aria-controls', - 'test-component-list', - ); - await expect(comboBoxMenu).not.toBeVisible(); + //Filter and focus first + await comboBoxInput.fill('1'); + expect(items).toHaveCount(3); + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-1', + ); + await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + + //stay on first + await page.keyboard.press('ArrowUp'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-1', + ); + await expect(items.first()).toHaveAttribute('data-iui-focused', 'true'); + + //focus second + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-10', + ); + await expect(items.first()).not.toHaveAttribute( + 'data-iui-focused', + 'true', + ); + await expect(items.nth(1)).toHaveAttribute('data-iui-focused', 'true'); + + //focus third(last filtered) + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-11', + ); + await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + + //stay on third(last filtered) + await page.keyboard.press('ArrowDown'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-activedescendant', + 'test-component-option-Item-11', + ); + await expect(items.last()).toHaveAttribute('data-iui-focused', 'true'); + + //select last + await page.keyboard.press('Enter'); + await expect(comboBoxInput).not.toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).not.toBeVisible(); + await expect(comboBoxInput).toHaveAttribute('value', 'Item 11'); + + //reopen menu + await page.keyboard.press('ArrowDown'); + await expect(items.nth(6)).toHaveAttribute('data-iui-focused', 'true'); + await expect(items.nth(6)).toHaveAttribute('data-iui-active', 'true'); + + //close menu + await page.keyboard.press('Escape'); + await expect(comboBoxInput).not.toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).not.toBeVisible(); + + //reopen and close menu + await page.keyboard.press('X'); + await expect(comboBoxInput).toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(comboBoxInput).not.toHaveAttribute( + 'aria-controls', + 'test-component-list', + ); + await expect(comboBoxMenu).not.toBeVisible(); + }); + + test('virtualized ComboBox should support dynamic sizing', async ({ + page, + }) => { + await page.goto('/ComboBox?virtualization=true'); + + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + const comboBoxList = page.getByRole('listbox'); + const outerVirtualizedContainer = comboBoxList.locator('>div', { + has: page.locator('slot'), + }); + const items = page.getByRole('option'); + + let totalItemsHeight = 0; + for (const item of await items.all()) { + totalItemsHeight += (await item.boundingBox())?.height ?? 0; + } + + // accounts for height lost due to gap + totalItemsHeight -= (await items.all()).length - 1; + + expect((await outerVirtualizedContainer.boundingBox())?.height).toBe( + totalItemsHeight, + ); + }); }); - test('virtualized ComboBox should support dynamic sizing', async ({ - page, - }) => { - await page.goto('/ComboBox?virtualization=true'); + test.describe('Overflow', () => { + test(`should overflow whenever there is not enough space`, async ({ + page, + }) => { + await page.goto(`/ComboBox?multiple=true&initialValue=all`); + + const setContainerSize = getSetContainerSize(page); + const expectOverflowState = getExpectOverflowState(page); + + await expectOverflowState({ + expectedItemLength: 7, + expectedLastTagTextContent: 'Item 11', + }); - await page.keyboard.press('Tab'); - const comboBoxList = page.getByRole('listbox'); - const outerVirtualizedContainer = comboBoxList.locator('>div', { - has: page.locator('slot'), + await setContainerSize('500px'); + + await expectOverflowState({ + expectedItemLength: 4, + expectedLastTagTextContent: '+4 item(s)', + }); }); - const items = page.getByRole('option'); - let totalItemsHeight = 0; - for (const item of await items.all()) { - totalItemsHeight += (await item.boundingBox())?.height ?? 0; - } + test(`should at minimum always show one overflow tag`, async ({ page }) => { + await page.goto(`/ComboBox?multiple=true&initialValue=all`); - // accounts for height lost due to gap - totalItemsHeight -= (await items.all()).length - 1; + const setContainerSize = getSetContainerSize(page); + const expectOverflowState = getExpectOverflowState(page); - expect((await outerVirtualizedContainer.boundingBox())?.height).toBe( - totalItemsHeight, - ); + await expectOverflowState({ + expectedItemLength: 7, + expectedLastTagTextContent: 'Item 11', + }); + + await setContainerSize('10px'); + await page.waitForTimeout(60); + + await expectOverflowState({ + 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]`); + + const setContainerSize = getSetContainerSize(page); + const expectOverflowState = getExpectOverflowState(page); + + await expectOverflowState({ + expectedItemLength: 1, + expectedLastTagTextContent: 'Item 11', + }); + + await setContainerSize('50px'); + + await expectOverflowState({ + expectedItemLength: 1, + expectedLastTagTextContent: 'Item 11', + }); + }); }); }); + +// ---------------------------------------------------------------------------- + +const getSetContainerSize = (page: Page) => { + return async (dimension: string | undefined) => { + await page.getByTestId('container').evaluate( + (element, args) => { + if (args.dimension != null) { + element.style.setProperty('width', args.dimension); + } else { + element.style.removeProperty('width'); + } + }, + { dimension }, + ); + await page.waitForTimeout(30); + }; +}; + +const getExpectOverflowState = (page: Page) => { + return async ({ + expectedItemLength, + expectedLastTagTextContent, + }: { + expectedItemLength: number; + expectedLastTagTextContent: string | undefined; + }) => { + const tags = await page.locator('div[id$="-selected-live"] > span').all(); + expect(tags).toHaveLength(expectedItemLength); + + const lastTag = tags[tags.length - 1]; + + if (expectedLastTagTextContent != null) { + await expect(lastTag).toHaveText(expectedLastTagTextContent); + } else { + expect(tags).toHaveLength(0); + } + }; +}; 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..f3a7832178f --- /dev/null +++ b/testing/e2e/app/routes/MiddleTextTruncation/spec.ts @@ -0,0 +1,90 @@ +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 setContainerSize = getSetContainerSize(page); + + const middleTextTruncation = page.getByTestId('middleTextTruncation'); + expect(await middleTextTruncation.first().textContent()).toHaveLength( + longItem.length, + ); + + await setContainerSize('200px'); + + expect(await middleTextTruncation.first().textContent()).toHaveLength( + 'MyFileWithAReallyLon…2.html'.length, + ); + + await setContainerSize(undefined); + + // should restore hidden items when space is available again + expect(await middleTextTruncation.first().textContent()).toHaveLength( + longItem.length, + ); + }); + + test(`should at minimum always show ellipses and endCharsCount number of characters`, async ({ + page, + }) => { + await page.goto(`/MiddleTextTruncation`); + + const endCharsCount = 6; + const setContainerSize = getSetContainerSize(page); + + const middleTextTruncation = page.getByTestId('container'); + expect(await middleTextTruncation.first().textContent()).toHaveLength( + longItem.length, + ); + + await setContainerSize('20px'); + + expect(await middleTextTruncation.first().textContent()).toHaveLength( + endCharsCount + 1, // +1 for the ellipses + ); + + await setContainerSize(undefined); + + // should restore hidden items when space is available again + expect(await middleTextTruncation.first().textContent()).toHaveLength( + longItem.length, + ); + }); + + test('should render custom text', async ({ page }) => { + await page.goto(`/MiddleTextTruncation?shouldUseCustomRenderer=true`); + + const setContainerSize = getSetContainerSize(page); + await setContainerSize('500px'); + + await expect(page.getByTestId('custom-text')).toHaveText( + 'MyFileWithAReallyLongNameThatWillBeTruncat…2.html - some additional text', + ); + + await page.waitForTimeout(100); + }); +}); + +// ---------------------------------------------------------------------------- + +const getSetContainerSize = (page: Page) => { + return async (dimension: string | undefined) => { + await page.getByTestId('container').evaluate( + (element, args) => { + if (args.dimension != null) { + element.style.setProperty('width', args.dimension); + } else { + element.style.removeProperty('width'); + } + }, + { dimension }, + ); + await page.waitForTimeout(30); + }; +}; diff --git a/testing/e2e/app/routes/Select/route.tsx b/testing/e2e/app/routes/Select/route.tsx new file mode 100644 index 00000000000..6db3add87a2 --- /dev/null +++ b/testing/e2e/app/routes/Select/route.tsx @@ -0,0 +1,47 @@ +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 = + searchParamValue != null + ? searchParamValue === 'all' + ? options.map((option) => option.value) + : (JSON.parse(searchParamValue) as number | number[]) + : undefined; + + const multiple = searchParams.get('multiple') === 'true'; + + return ( + <> +
+