fix(popover): only focus trap ion-item children (#24990)

Resolves #24633
This commit is contained in:
Sean Perkins
2022-04-12 17:33:27 -04:00
committed by GitHub
parent 2a313e9117
commit 0cd06a6754
3 changed files with 240 additions and 129 deletions

View File

@@ -1,3 +1,4 @@
import type { E2EPage } from '@stencil/core/testing';
import { newE2EPage } from '@stencil/core/testing';
import { testPopover } from '../test.utils';
@@ -8,51 +9,23 @@ const DIRECTORY = 'basic';
* Focusing happens async inside of popover so we need
* to wait for the requestAnimationFrame to fire.
*/
const expectActiveElementTextToEqual = async (page, textValue) => {
await page.waitFor((text) => document.activeElement.textContent === text, {}, textValue);
const expectActiveElementTextToEqual = async (page: E2EPage, textValue: string) => {
await page.evaluate((text) => document.activeElement!.textContent === text, textValue)
};
test('popover: focus trap', async () => {
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
const getActiveElementSelectionStart = (page: E2EPage) => {
return page.evaluate(() =>
document.activeElement instanceof HTMLTextAreaElement ? document.activeElement.selectionStart : null
);
};
await page.click('#basic-popover');
await page.waitForSelector('#basic-popover');
const popover = await page.find('ion-popover');
expect(popover).not.toBe(null);
await popover.waitForVisible();
await page.keyboard.press('Tab');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
await expectActiveElementTextToEqual(page, 'Item 3');
await page.keyboard.press('Tab');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.press('ArrowDown');
await expectActiveElementTextToEqual(page, 'Item 1');
await page.keyboard.press('ArrowDown');
await expectActiveElementTextToEqual(page, 'Item 2');
await page.keyboard.press('Home');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.press('End');
await expectActiveElementTextToEqual(page, 'Item 3');
});
const getActiveElementScrollTop = (page: E2EPage) => {
return page.evaluate(() => {
// Returns the closest ion-textarea or active element
const target = document.activeElement!.closest('ion-textarea') ?? document.activeElement;
return target!.scrollTop;
});
};
test('popover: basic', async () => {
await testPopover(DIRECTORY, '#basic-popover');
@@ -125,7 +98,141 @@ test('popover: htmlAttributes', async () => {
expect(alert).not.toBe(null);
await alert.waitForVisible();
const attribute = await page.evaluate(() => document.querySelector('ion-popover').getAttribute('data-testid'));
const attribute = await page.evaluate(() => document.querySelector('ion-popover')!.getAttribute('data-testid'));
expect(attribute).toEqual('basic-popover');
});
describe('popover: focus trap', () => {
it('should focus the first ion-item on ArrowDown', async () => {
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
await page.click('#basic-popover');
const popover = await page.find('ion-popover');
expect(popover).not.toBe(null);
await popover.waitForVisible();
await page.keyboard.press('ArrowDown');
await expectActiveElementTextToEqual(page, 'Item 0');
});
it('should work with ion-item children', async () => {
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
await page.click('#basic-popover');
await page.waitForSelector('#basic-popover');
const popover = await page.find('ion-popover');
expect(popover).not.toBe(null);
await popover.waitForVisible();
await page.keyboard.press('Tab');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
await expectActiveElementTextToEqual(page, 'Item 3');
await page.keyboard.press('Tab');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.press('ArrowDown');
await expectActiveElementTextToEqual(page, 'Item 1');
await page.keyboard.press('ArrowDown');
await expectActiveElementTextToEqual(page, 'Item 2');
await page.keyboard.press('Home');
await expectActiveElementTextToEqual(page, 'Item 0');
await page.keyboard.press('End');
await expectActiveElementTextToEqual(page, 'Item 3');
});
it('should not override keyboard interactions for textarea elements', async () => {
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
await page.waitForSelector('#popover-with-textarea');
await page.click('#popover-with-textarea');
const popover = await page.find('ion-popover');
await popover.waitForVisible();
await page.waitForFunction('document.activeElement.tagName === "ION-POPOVER"');
await page.keyboard.press('Tab');
// Checking within ion-textarea
let activeElementTagName = await page.evaluate(() => document.activeElement!.tagName);
let scrollTop = null;
let selectionStart = null;
let previousSelectionStart = null;
// This is the native textarea within ion-textarea
expect(activeElementTagName).toBe('TEXTAREA');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBe(0);
await page.keyboard.press('ArrowDown');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBeGreaterThan(0);
previousSelectionStart = selectionStart;
await page.keyboard.press('ArrowDown');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBeGreaterThan(previousSelectionStart!);
await page.keyboard.press('Tab');
// Checking within HTML textarea
// Reset tracking variables as the focus element has changed
scrollTop = null;
selectionStart = null;
previousSelectionStart = null;
activeElementTagName = await page.evaluate(() => document.activeElement!.tagName);
expect(activeElementTagName).toBe('TEXTAREA');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBe(0);
await page.keyboard.press('ArrowDown');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBeGreaterThan(0);
previousSelectionStart = selectionStart;
await page.keyboard.press('ArrowDown');
selectionStart = await getActiveElementSelectionStart(page);
expect(selectionStart).toBeGreaterThan(previousSelectionStart!);
await page.keyboard.press('Home');
scrollTop = await getActiveElementScrollTop(page);
expect(scrollTop).toBeGreaterThan(0);
const previousScrollTop = scrollTop;
await page.keyboard.press('End');
scrollTop = await getActiveElementScrollTop(page);
expect(scrollTop).toBeGreaterThanOrEqual(previousScrollTop);
});
});

View File

@@ -1,91 +1,67 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Popover - Basic</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<script type="module">
import { popoverController } from '../../../../dist/ionic/index.esm.js';
window.popoverController = popoverController;
</script>
<body>
<ion-app>
<ion-header>
<ion-toolbar color="primary">
<ion-buttons slot="secondary">
<ion-button onclick="presentPopover({component: 'profile-page', event: event })">
<ion-icon slot="icon-only" name="person"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Popover - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding" id="content">
<ion-button
id="basic-popover"
expand="block"
onclick="presentPopover({ component: 'profile-page', event: event, htmlAttributes: { 'data-testid': 'basic-popover' } })"
>Show Popover</ion-button
>
<ion-button
id="translucent-popover"
expand="block"
onclick="presentPopover({ component: 'translucent-page', event: event, translucent: true })"
>Show Translucent Popover</ion-button
>
<ion-button
id="long-list-popover"
expand="block"
color="secondary"
onclick="presentPopover({ component: 'list-page', event: event })"
>Show Long List Popover</ion-button
>
<ion-button
id="no-event-popover"
expand="block"
color="danger"
onclick="presentPopover({ component: 'profile-page' })"
>No Event Popover</ion-button
>
<ion-button
id="custom-class-popover"
expand="block"
color="tertiary"
onclick="presentPopover({ component: 'translucent-page', event: event, cssClass: 'my-custom-class' })"
>Custom Class Popover</ion-button
>
<ion-button id="header-popover" expand="block" onclick="presentPopover({ component: 'header-page' })"
>Popover With Header</ion-button
>
<ion-button
id="translucent-header-popover"
expand="block"
onclick="presentPopover({ component: 'translucent-header-page' })"
>Popover With Translucent Header</ion-button
>
</ion-content>
<head>
<meta charset="UTF-8">
<title>Popover - Basic</title>
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<script type="module">
import { popoverController } from '../../../../dist/ionic/index.esm.js';
window.popoverController = popoverController;
</script>
<ion-footer>
<ion-toolbar color="primary">
<ion-buttons slot="primary">
<ion-button onclick="presentPopover({component: 'profile-page', event: event })">
<ion-icon slot="icon-only" name="person"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Popover</ion-title>
</ion-toolbar>
</ion-footer>
</ion-app>
<body>
<ion-app>
<ion-content class="ion-padding" id="content">
<ion-button id="basic-popover" expand="block"
onclick="presentPopover({ component: 'profile-page', event: event, htmlAttributes: { 'data-testid': 'basic-popover' } })">
Show Popover</ion-button>
<ion-button id="translucent-popover" expand="block"
onclick="presentPopover({ component: 'translucent-page', event: event, translucent: true })">Show Translucent
Popover</ion-button>
<ion-button id="long-list-popover" expand="block" color="secondary"
onclick="presentPopover({ component: 'list-page', event: event })">Show Long List Popover</ion-button>
<ion-button id="no-event-popover" expand="block" color="danger"
onclick="presentPopover({ component: 'profile-page' })">No Event Popover</ion-button>
<ion-button id="custom-class-popover" expand="block" color="tertiary"
onclick="presentPopover({ component: 'translucent-page', event: event, cssClass: 'my-custom-class' })">Custom
Class Popover</ion-button>
<ion-button id="header-popover" expand="block" onclick="presentPopover({ component: 'header-page' })">Popover With
Header</ion-button>
<ion-button id="translucent-header-popover" expand="block"
onclick="presentPopover({ component: 'translucent-header-page' })">Popover With Translucent Header</ion-button>
</ion-content>
<ion-content class="ion-padding" id="content">
<ion-button id="basic-popover" expand="block"
onclick="presentPopover({ component: 'profile-page', event: event, htmlAttributes: { 'data-testid': 'basic-popover' } })">
Show Popover</ion-button>
<ion-button id="translucent-popover" expand="block"
onclick="presentPopover({ component: 'translucent-page', event: event, translucent: true })">Show Translucent
Popover</ion-button>
<ion-button id="long-list-popover" expand="block" color="secondary"
onclick="presentPopover({ component: 'list-page', event: event })">Show Long List Popover</ion-button>
<ion-button id="no-event-popover" expand="block" color="danger"
onclick="presentPopover({ component: 'profile-page' })">No Event Popover</ion-button>
<ion-button id="custom-class-popover" expand="block" color="tertiary"
onclick="presentPopover({ component: 'translucent-page', event: event, cssClass: 'my-custom-class' })">Custom
Class Popover</ion-button>
<ion-button id="header-popover" expand="block" onclick="presentPopover({ component: 'header-page' })">Popover With
Header</ion-button>
<ion-button id="translucent-header-popover" expand="block"
onclick="presentPopover({ component: 'translucent-header-page' })">Popover With Translucent Header</ion-button>
<ion-button id="popover-with-textarea" expand="block"
onclick="presentPopover({ component: 'textarea-page', event: event, htmlAttributes: { 'data-testid': 'popover-with-textarea'} })">
Popover With Textarea</ion-button>
</ion-content>
<style>
.my-custom-class {
@@ -217,6 +193,26 @@
}
customElements.define('translucent-header-page', TranslucentHeaderPage);
class TextAreaPage extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `
<ion-content>
<ion-textarea rows="4" style="max-height: 44px; overflow-y: auto;" value="the cursor in this <ion-textarea>\nmust be able to be moved\nwith the arrow keys and\nhome and end keys"></ion-textarea>
<textarea rows="4" style="display:block;width: 100%;border: 0;padding: 8px; max-height: 44px; overflow-y: auto">the cursor in this <textarea>\nmust be able to be moved\nwith the arrow keys and\nhome and end keys</textarea>
</ion-content>
`;
}
}
customElements.define('textarea-page', TextAreaPage);
</script>
</body>
</ion-app>
</body>
</html>

View File

@@ -337,6 +337,14 @@ export const configureKeyboardInteraction = (popoverEl: HTMLIonPopoverElement) =
const activeElement = document.activeElement as HTMLElement | null;
let items: HTMLIonItemElement[] = [];
const targetTagName = (ev.target as HTMLElement)?.tagName;
/**
* Only handle custom keyboard interactions for the host popover element
* and children ion-item elements.
*/
if (targetTagName !== 'ION-POPOVER' && targetTagName !== 'ION-ITEM') {
return;
}
/**
* Complex selectors with :not() are :not supported
* in older versions of Chromium so we need to do a
@@ -353,7 +361,7 @@ export const configureKeyboardInteraction = (popoverEl: HTMLIonPopoverElement) =
) as NodeListOf<HTMLIonItemElement>
);
/* eslint-disable-next-line */
} catch {}
} catch { }
switch (ev.key) {
/**