diff --git a/core/src/components/radio/test/a11y/radio.e2e-legacy.ts b/core/src/components/radio/test/a11y/radio.e2e-legacy.ts index a31e238dd5..e12e1c417a 100644 --- a/core/src/components/radio/test/a11y/radio.e2e-legacy.ts +++ b/core/src/components/radio/test/a11y/radio.e2e-legacy.ts @@ -14,15 +14,70 @@ test.describe('radio: a11y', () => { const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); }); +}); - // TODO FW-3747 - test.skip('using arrow keys should move between enabled radios within group', async ({ page, browserName }) => { - const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; - await page.goto(`/src/components/radio/test/a11y`); +// TODO: FW-4155 - Enable tests once tab behavior is fixed for modern syntax. +test.describe.skip('radio: keyboard navigation', () => { + test.beforeEach(async ({ page, skip }) => { + skip.rtl(); + await page.setContent(` + + + + + + Huey + + + Dewey + + + Fooey + + + Louie + + + + + + + Huey + + + Dewey + + + Fooey + + + Louie + + + + + + `); + }); + + test('tabbing should switch between radio groups', async ({ page, pageUtils }) => { + const firstGroupRadios = page.locator('#first-group ion-radio'); + const secondGroupRadios = page.locator('#second-group ion-radio'); + + await pageUtils.pressKeys('Tab'); + await expect(firstGroupRadios.nth(0)).toBeFocused(); + + await pageUtils.pressKeys('Tab'); + await expect(secondGroupRadios.nth(0)).toBeFocused(); + + await pageUtils.pressKeys('shift+Tab'); + await expect(firstGroupRadios.nth(0)).toBeFocused(); + }); + test('using arrow keys should move between enabled radios within group', async ({ page, pageUtils }) => { const firstGroupRadios = page.locator('#first-group ion-radio'); - await page.keyboard.press(tabKey); + await pageUtils.pressKeys('Tab'); await expect(firstGroupRadios.nth(0)).toBeFocused(); await page.keyboard.press('ArrowDown'); diff --git a/core/src/components/radio/test/legacy/a11y/radio.e2e-legacy.ts b/core/src/components/radio/test/legacy/a11y/radio.e2e-legacy.ts index 7e0a2739d1..db922441c2 100644 --- a/core/src/components/radio/test/legacy/a11y/radio.e2e-legacy.ts +++ b/core/src/components/radio/test/legacy/a11y/radio.e2e-legacy.ts @@ -1,34 +1,31 @@ import { expect } from '@playwright/test'; import { test } from '@utils/test/playwright'; -// TODO FW-3747 -test.describe.skip('radio: a11y', () => { +test.describe('radio: a11y', () => { test.beforeEach(({ skip }) => { skip.rtl(); }); - test('tabbing should switch between radio groups', async ({ page, browserName }) => { - const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + test('tabbing should switch between radio groups', async ({ page, pageUtils }) => { await page.goto(`/src/components/radio/test/legacy/a11y`); const firstGroupRadios = page.locator('#first-group ion-radio'); const secondGroupRadios = page.locator('#second-group ion-radio'); - await page.keyboard.press(tabKey); + await pageUtils.pressKeys('Tab'); await expect(firstGroupRadios.nth(0)).toBeFocused(); - await page.keyboard.press(tabKey); + await pageUtils.pressKeys('Tab'); await expect(secondGroupRadios.nth(0)).toBeFocused(); - await page.keyboard.press(`Shift+${tabKey}`); + await pageUtils.pressKeys('shift+Tab'); await expect(firstGroupRadios.nth(0)).toBeFocused(); }); - test('using arrow keys should move between enabled radios within group', async ({ page, browserName }) => { - const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; + test('using arrow keys should move between enabled radios within group', async ({ page, pageUtils }) => { await page.goto(`/src/components/radio/test/legacy/a11y`); const firstGroupRadios = page.locator('#first-group ion-radio'); - await page.keyboard.press(tabKey); + await pageUtils.pressKeys('Tab'); await expect(firstGroupRadios.nth(0)).toBeFocused(); await page.keyboard.press('ArrowDown'); diff --git a/core/src/utils/test/playwright/playwright-page.ts b/core/src/utils/test/playwright/playwright-page.ts index 224beac78d..bbab9b9b0c 100644 --- a/core/src/utils/test/playwright/playwright-page.ts +++ b/core/src/utils/test/playwright/playwright-page.ts @@ -7,6 +7,8 @@ import type { } from '@playwright/test'; import { test as base } from '@playwright/test'; +import { PageUtils } from '../press-keys'; + import { initPageEvents } from './page/event-spy'; import { getSnapshotSettings, @@ -36,6 +38,7 @@ type CustomTestArgs = PlaywrightTestArgs & type CustomFixtures = { page: E2EPage; skip: E2ESkip; + pageUtils: PageUtils; }; /** @@ -91,4 +94,7 @@ export const test = base.extend({ base.skip(base.info().project.metadata.mode === mode, reason); }, }, + pageUtils: async ({ page }, use) => { + await use(new PageUtils({ page })); + }, }); diff --git a/core/src/utils/test/press-keys.ts b/core/src/utils/test/press-keys.ts new file mode 100644 index 0000000000..fbee9d410f --- /dev/null +++ b/core/src/utils/test/press-keys.ts @@ -0,0 +1,154 @@ +import type { Browser, BrowserContext, Page } from '@playwright/test'; + +/** + * The purpose of this utility is to provide a way to press keys in a way that + * is consistent across browsers and platforms. Playwright does not automatically + * normalize key presses, so we need to do it ourselves. + * + * In certain environments, such as Webkit on macOS, the browser will not focus + * the correct element in the DOM when the tab key is pressed. + * This utility will detect if the browser has natural tab navigation and + * will use the appropriate key combination to simulate a tab press. + * The utility will normalize key presses for other combinations as well. + */ + +const SHIFT = 'shift'; +const CTRL = 'ctrl'; +const ALT = 'alt'; +const COMMAND = 'meta'; + +/** + * Source: https://github.com/WordPress/gutenberg/blob/f0d0d569a06c42833670c9b5285d04a63968a220/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts + * Slimmed down version of WordPress' pressKeys utility. + */ +export class PageUtils { + browser: Browser; + page: Page; + context: BrowserContext; + + constructor({ page }: { page: Page }) { + this.page = page; + this.context = page.context(); + this.browser = this.context.browser()!; + } + + pressKeys: typeof pressKeys = pressKeys.bind(this); +} + +const baseModifiers = { + primary: (_isApple: any) => (_isApple() ? [COMMAND] : [CTRL]), + primaryShift: (_isApple: any) => (_isApple() ? [SHIFT, COMMAND] : [CTRL, SHIFT]), + primaryAlt: (_isApple: any) => (_isApple() ? [ALT, COMMAND] : [CTRL, ALT]), + secondary: (_isApple: any) => (_isApple() ? [SHIFT, ALT, COMMAND] : [CTRL, SHIFT, ALT]), + access: (_isApple: any) => (_isApple() ? [CTRL, ALT] : [SHIFT, ALT]), + ctrl: () => [CTRL], + alt: () => [ALT], + ctrlShift: () => [CTRL, SHIFT], + shift: () => [SHIFT], + shiftAlt: () => [SHIFT, ALT], + undefined: () => [], +}; + +const isAppleOS = () => process.platform === 'darwin'; +const isWebkit = (page: Page) => page.context().browser()!.browserType().name() === 'webkit'; +const browserCache = new WeakMap(); + +/** + * Detects if the browser has natural tab navigation. + * Natural tab navigation means that the browser will focus the next element + * in the DOM when the tab key is pressed. + */ +const getHasNaturalTabNavigation = async (page: Page) => { + if (!isAppleOS() || !isWebkit(page)) { + return true; + } + if (browserCache.has(page.context().browser()!)) { + return browserCache.get(page.context().browser()!); + } + const testPage = await page.context().newPage(); + await testPage.setContent(``); + await testPage.getByText('1').focus(); + await testPage.keyboard.press('Tab'); + const featureDetected = await testPage.getByText('2').evaluate((node) => node === document.activeElement); + browserCache.set(page.context().browser()!, featureDetected); + await testPage.close(); + return featureDetected; +}; + +type Options = { + /** + * Number of times to press the key. + */ + times?: number; + /** + * Delay between each key press in milliseconds. + */ + delay?: number; +}; + +const modifiers = { + ...baseModifiers, + shiftAlt: (_isApple: () => boolean) => (_isApple() ? [SHIFT, ALT] : [SHIFT, CTRL]), +}; + +/** + * Presses a key combination. + * @param key - Key combination to press. + * @param options - Options for the key press. + * @example + * ```ts + * await pressKeys('a'); + * await pressKeys('a', { times: 2 }); + * await pressKeys('a', { delay: 100 }); + * await pressKeys('Shift+Tab'); + * ``` + */ +export async function pressKeys(this: PageUtils, key: string, { times, ...pressOptions }: Options = {}) { + const hasNaturalTabNavigation = await getHasNaturalTabNavigation(this.page); + /** + * Split the key combination into individual keys and map each key to its + * corresponding modifier. + */ + const keys = key.split('+').flatMap((keyCode) => { + /** + * If the key is a modifier, we need to map it to the correct modifier for + * the current platform. + */ + if (keyCode in modifiers) { + return modifiers[keyCode as keyof typeof modifiers](isAppleOS).map((modifier) => + modifier === CTRL ? 'Control' : capitalCase(modifier) + ); + } else if (keyCode === 'Tab' && !hasNaturalTabNavigation) { + /** + * If the key is the tab key and the browser does not have natural tab + * navigation, we need to simulate the tab key press by pressing the Alt key + * and the Tab key. + */ + return ['Alt', 'Tab']; + } + // If the key is not a modifier, we can just return the key. + return keyCode; + }); + const normalizedKeys = keys.join('+'); + const command = () => this.page.keyboard.press(normalizedKeys); + + times = times ?? 1; + for (let i = 0; i < times; i += 1) { + await command(); + + if (times > 1 && pressOptions.delay !== undefined) { + /** + * If we are pressing the key multiple times, we need to wait for the + * delay between each key press. + */ + await this.page.waitForTimeout(pressOptions.delay); + } + } +} + +/** + * Capitalizes the first letter of a string. + */ +function capitalCase(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +}