mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-20 12:29:55 +08:00
fix(overlays): trap focus inside overlay components except toast (#21716)
fixes #21647
This commit is contained in:
@ -26,6 +26,7 @@ import { mdLeaveAnimation } from './animations/md.leave';
|
|||||||
export class ActionSheet implements ComponentInterface, OverlayInterface {
|
export class ActionSheet implements ComponentInterface, OverlayInterface {
|
||||||
|
|
||||||
presented = false;
|
presented = false;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
animation?: any;
|
animation?: any;
|
||||||
private wrapperEl?: HTMLElement;
|
private wrapperEl?: HTMLElement;
|
||||||
private groupEl?: HTMLElement;
|
private groupEl?: HTMLElement;
|
||||||
@ -250,7 +251,10 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
|||||||
onIonBackdropTap={this.onBackdropTap}
|
onIonBackdropTap={this.onBackdropTap}
|
||||||
>
|
>
|
||||||
<ion-backdrop tappable={this.backdropDismiss}/>
|
<ion-backdrop tappable={this.backdropDismiss}/>
|
||||||
<div class="action-sheet-wrapper" role="dialog" ref={el => this.wrapperEl = el}>
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
|
<div class="action-sheet-wrapper ion-overlay-wrapper" role="dialog" ref={el => this.wrapperEl = el}>
|
||||||
<div class="action-sheet-container">
|
<div class="action-sheet-container">
|
||||||
<div class="action-sheet-group" ref={el => this.groupEl = el}>
|
<div class="action-sheet-group" ref={el => this.groupEl = el}>
|
||||||
{this.header !== undefined &&
|
{this.header !== undefined &&
|
||||||
@ -292,6 +296,8 @@ export class ActionSheet implements ComponentInterface, OverlayInterface {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,40 @@
|
|||||||
import { testActionSheet, testActionSheetAlert, testActionSheetBackdrop } from '../test.utils';
|
import { testActionSheet, testActionSheetAlert, testActionSheetBackdrop } from '../test.utils';
|
||||||
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
const DIRECTORY = 'basic';
|
const DIRECTORY = 'basic';
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('action-sheet: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/action-sheet/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#basic');
|
||||||
|
await page.waitForSelector('#basic');
|
||||||
|
|
||||||
|
let actionSheet = await page.find('ion-action-sheet');
|
||||||
|
|
||||||
|
expect(actionSheet).not.toBe(null);
|
||||||
|
await actionSheet.waitForVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Delete');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('Cancel');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Delete');
|
||||||
|
});
|
||||||
|
|
||||||
test('action-sheet: basic', async () => {
|
test('action-sheet: basic', async () => {
|
||||||
await testActionSheet(DIRECTORY, '#basic');
|
await testActionSheet(DIRECTORY, '#basic');
|
||||||
|
@ -34,6 +34,7 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
|||||||
private gesture?: Gesture;
|
private gesture?: Gesture;
|
||||||
|
|
||||||
presented = false;
|
presented = false;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
|
|
||||||
@Element() el!: HTMLIonAlertElement;
|
@Element() el!: HTMLIonAlertElement;
|
||||||
|
|
||||||
@ -514,7 +515,9 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
|||||||
|
|
||||||
<ion-backdrop tappable={this.backdropDismiss}/>
|
<ion-backdrop tappable={this.backdropDismiss}/>
|
||||||
|
|
||||||
<div class="alert-wrapper" ref={el => this.wrapperEl = el}>
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
|
<div class="alert-wrapper ion-overlay-wrapper" ref={el => this.wrapperEl = el}>
|
||||||
|
|
||||||
<div class="alert-head">
|
<div class="alert-head">
|
||||||
{header && <h2 id={hdrId} class="alert-title">{header}</h2>}
|
{header && <h2 id={hdrId} class="alert-title">{header}</h2>}
|
||||||
@ -527,6 +530,8 @@ export class Alert implements ComponentInterface, OverlayInterface {
|
|||||||
{this.renderAlertButtons()}
|
{this.renderAlertButtons()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,40 @@
|
|||||||
import { testAlert } from '../test.utils';
|
import { testAlert } from '../test.utils';
|
||||||
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
const DIRECTORY = 'basic';
|
const DIRECTORY = 'basic';
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('alert: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/alert/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#multipleButtons');
|
||||||
|
await page.waitForSelector('#multipleButtons');
|
||||||
|
|
||||||
|
let alert = await page.find('ion-alert');
|
||||||
|
|
||||||
|
expect(alert).not.toBe(null);
|
||||||
|
await alert.waitForVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Open Modal');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('Cancel');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Open Modal');
|
||||||
|
});
|
||||||
|
|
||||||
test(`alert: basic`, async () => {
|
test(`alert: basic`, async () => {
|
||||||
await testAlert(DIRECTORY, '#basic');
|
await testAlert(DIRECTORY, '#basic');
|
||||||
|
@ -1,5 +1,42 @@
|
|||||||
import { newE2EPage } from '@stencil/core/testing';
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('datetime/picker: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/datetime/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#datetime-part');
|
||||||
|
await page.waitForSelector('#datetime-part');
|
||||||
|
|
||||||
|
let datetime = await page.find('ion-datetime');
|
||||||
|
|
||||||
|
expect(datetime).not.toBe(null);
|
||||||
|
await datetime.waitForVisible();
|
||||||
|
|
||||||
|
// TODO fix
|
||||||
|
await page.waitFor(100);
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Cancel');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('1920');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Cancel');
|
||||||
|
});
|
||||||
|
|
||||||
test('datetime: basic', async () => {
|
test('datetime: basic', async () => {
|
||||||
const page = await newE2EPage({
|
const page = await newE2EPage({
|
||||||
url: '/src/components/datetime/test/basic?ionic:_testing=true'
|
url: '/src/components/datetime/test/basic?ionic:_testing=true'
|
||||||
|
@ -27,6 +27,7 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
|||||||
private durationTimeout: any;
|
private durationTimeout: any;
|
||||||
|
|
||||||
presented = false;
|
presented = false;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
|
|
||||||
@Element() el!: HTMLIonLoadingElement;
|
@Element() el!: HTMLIonLoadingElement;
|
||||||
|
|
||||||
@ -194,7 +195,10 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} />
|
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss} />
|
||||||
<div class="loading-wrapper" role="dialog">
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
|
<div class="loading-wrapper ion-overlay-wrapper" role="dialog">
|
||||||
{spinner && (
|
{spinner && (
|
||||||
<div class="loading-spinner">
|
<div class="loading-spinner">
|
||||||
<ion-spinner name={spinner} aria-hidden="true" />
|
<ion-spinner name={spinner} aria-hidden="true" />
|
||||||
@ -203,6 +207,8 @@ export class Loading implements ComponentInterface, OverlayInterface {
|
|||||||
|
|
||||||
{message && <div class="loading-content" innerHTML={sanitizeDOMString(message)}></div>}
|
{message && <div class="loading-content" innerHTML={sanitizeDOMString(message)}></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,40 @@
|
|||||||
import { testLoading } from '../test.utils';
|
import { testLoading } from '../test.utils';
|
||||||
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
const DIRECTORY = 'basic';
|
const DIRECTORY = 'basic';
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('loading: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/loading/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#html-content-loading');
|
||||||
|
await page.waitForSelector('#html-content-loading');
|
||||||
|
|
||||||
|
let loading = await page.find('ion-loading');
|
||||||
|
|
||||||
|
expect(loading).not.toBe(null);
|
||||||
|
await loading.waitForVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Click impatiently to load faster');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('Click impatiently to load faster');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Click impatiently to load faster');
|
||||||
|
});
|
||||||
|
|
||||||
test('loading: basic', async () => {
|
test('loading: basic', async () => {
|
||||||
await testLoading(DIRECTORY, '#basic-loading');
|
await testLoading(DIRECTORY, '#basic-loading');
|
||||||
|
@ -34,6 +34,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
|||||||
// Whether or not modal is being dismissed via gesture
|
// Whether or not modal is being dismissed via gesture
|
||||||
private gestureAnimationDismissing = false;
|
private gestureAnimationDismissing = false;
|
||||||
presented = false;
|
presented = false;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
animation?: Animation;
|
animation?: Animation;
|
||||||
|
|
||||||
@Element() el!: HTMLIonModalElement;
|
@Element() el!: HTMLIonModalElement;
|
||||||
@ -289,11 +290,16 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
|||||||
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}/>
|
<ion-backdrop visible={this.showBackdrop} tappable={this.backdropDismiss}/>
|
||||||
|
|
||||||
{mode === 'ios' && <div class="modal-shadow"></div>}
|
{mode === 'ios' && <div class="modal-shadow"></div>}
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
class="modal-wrapper"
|
class="modal-wrapper ion-overlay-wrapper"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,43 @@
|
|||||||
import { testModal } from '../test.utils';
|
import { testModal } from '../test.utils';
|
||||||
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
const DIRECTORY = 'basic';
|
const DIRECTORY = 'basic';
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('modal: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/modal/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#basic-modal');
|
||||||
|
await page.waitForSelector('#basic-modal');
|
||||||
|
|
||||||
|
let modal = await page.find('ion-modal');
|
||||||
|
|
||||||
|
expect(modal).not.toBe(null);
|
||||||
|
await modal.waitForVisible();
|
||||||
|
|
||||||
|
// TODO fix
|
||||||
|
await page.waitFor(50);
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Dismiss Modal');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('Dismiss Modal');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Dismiss Modal');
|
||||||
|
});
|
||||||
|
|
||||||
test('modal: basic', async () => {
|
test('modal: basic', async () => {
|
||||||
await testModal(DIRECTORY, '#basic-modal');
|
await testModal(DIRECTORY, '#basic-modal');
|
||||||
|
@ -21,6 +21,7 @@ import { iosLeaveAnimation } from './animations/ios.leave';
|
|||||||
})
|
})
|
||||||
export class Picker implements ComponentInterface, OverlayInterface {
|
export class Picker implements ComponentInterface, OverlayInterface {
|
||||||
private durationTimeout: any;
|
private durationTimeout: any;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
|
|
||||||
@Element() el!: HTMLIonPickerElement;
|
@Element() el!: HTMLIonPickerElement;
|
||||||
|
|
||||||
@ -236,7 +237,10 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
|||||||
tappable={this.backdropDismiss}
|
tappable={this.backdropDismiss}
|
||||||
>
|
>
|
||||||
</ion-backdrop>
|
</ion-backdrop>
|
||||||
<div class="picker-wrapper" role="dialog">
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
|
<div class="picker-wrapper ion-overlay-wrapper" role="dialog">
|
||||||
<div class="picker-toolbar">
|
<div class="picker-toolbar">
|
||||||
{this.buttons.map(b => (
|
{this.buttons.map(b => (
|
||||||
<div class={buttonWrapperClass(b)}>
|
<div class={buttonWrapperClass(b)}>
|
||||||
@ -259,6 +263,8 @@ export class Picker implements ComponentInterface, OverlayInterface {
|
|||||||
<div class="picker-below-highlight"></div>
|
<div class="picker-below-highlight"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ export class Popover implements ComponentInterface, OverlayInterface {
|
|||||||
private usersElement?: HTMLElement;
|
private usersElement?: HTMLElement;
|
||||||
|
|
||||||
presented = false;
|
presented = false;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
|
|
||||||
@Element() el!: HTMLIonPopoverElement;
|
@Element() el!: HTMLIonPopoverElement;
|
||||||
|
|
||||||
@ -219,10 +220,15 @@ export class Popover implements ComponentInterface, OverlayInterface {
|
|||||||
onIonBackdropTap={this.onBackdropTap}
|
onIonBackdropTap={this.onBackdropTap}
|
||||||
>
|
>
|
||||||
<ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop}/>
|
<ion-backdrop tappable={this.backdropDismiss} visible={this.showBackdrop}/>
|
||||||
<div class="popover-wrapper">
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
|
|
||||||
|
<div class="popover-wrapper ion-overlay-wrapper">
|
||||||
<div class="popover-arrow"></div>
|
<div class="popover-arrow"></div>
|
||||||
<div class="popover-content"></div>
|
<div class="popover-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div tabindex="0"></div>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,42 @@
|
|||||||
import { testPopover } from '../test.utils';
|
import { testPopover } from '../test.utils';
|
||||||
|
import { newE2EPage } from '@stencil/core/testing';
|
||||||
|
|
||||||
const DIRECTORY = 'basic';
|
const DIRECTORY = 'basic';
|
||||||
|
|
||||||
|
const getActiveElementText = async (page) => {
|
||||||
|
const activeElement = await page.evaluateHandle(() => document.activeElement);
|
||||||
|
return await page.evaluate(el => el && el.textContent, activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('popover: focus trap', async () => {
|
||||||
|
const page = await newE2EPage({ url: '/src/components/popover/test/basic?ionic:_testing=true' });
|
||||||
|
|
||||||
|
await page.click('#basic-popover');
|
||||||
|
await page.waitForSelector('#basic-popover');
|
||||||
|
|
||||||
|
let popover = await page.find('ion-popover');
|
||||||
|
|
||||||
|
expect(popover).not.toBe(null);
|
||||||
|
await popover.waitForVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementText = await getActiveElementText(page);
|
||||||
|
expect(activeElementText).toEqual('Item 0');
|
||||||
|
|
||||||
|
await page.keyboard.down('Shift');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.up('Shift');
|
||||||
|
|
||||||
|
const activeElementTextTwo = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextTwo).toEqual('Item 3');
|
||||||
|
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
const activeElementTextThree = await getActiveElementText(page);
|
||||||
|
expect(activeElementTextThree).toEqual('Item 0');
|
||||||
|
});
|
||||||
|
|
||||||
test('popover: basic', async () => {
|
test('popover: basic', async () => {
|
||||||
await testPopover(DIRECTORY, '#basic-popover');
|
await testPopover(DIRECTORY, '#basic-popover');
|
||||||
});
|
});
|
||||||
|
@ -75,10 +75,10 @@
|
|||||||
<ion-content>
|
<ion-content>
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-list-header><ion-label>Ionic</ion-label></ion-list-header>
|
<ion-list-header><ion-label>Ionic</ion-label></ion-list-header>
|
||||||
<ion-item><ion-label>Item 0</ion-label></ion-item>
|
<ion-item button><ion-label>Item 0</ion-label></ion-item>
|
||||||
<ion-item><ion-label>Item 1</ion-label></ion-item>
|
<ion-item button><ion-label>Item 1</ion-label></ion-item>
|
||||||
<ion-item><ion-label>Item 2</ion-label></ion-item>
|
<ion-item button><ion-label>Item 2</ion-label></ion-item>
|
||||||
<ion-item><ion-label>Item 3</ion-label></ion-item>
|
<ion-item button><ion-label>Item 3</ion-label></ion-item>
|
||||||
</ion-list>
|
</ion-list>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
`;
|
`;
|
||||||
|
@ -35,6 +35,7 @@ export interface OverlayController {
|
|||||||
export interface HTMLIonOverlayElement extends HTMLStencilElement {
|
export interface HTMLIonOverlayElement extends HTMLStencilElement {
|
||||||
overlayIndex: number;
|
overlayIndex: number;
|
||||||
backdropDismiss?: boolean;
|
backdropDismiss?: boolean;
|
||||||
|
lastFocus?: HTMLElement;
|
||||||
|
|
||||||
dismiss(data?: any, role?: string): Promise<boolean>;
|
dismiss(data?: any, role?: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { getIonMode } from '../global/ionic-global';
|
|||||||
import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
|
import { ActionSheetOptions, AlertOptions, Animation, AnimationBuilder, BackButtonEvent, HTMLIonOverlayElement, IonicConfig, LoadingOptions, ModalOptions, OverlayInterface, PickerOptions, PopoverOptions, ToastOptions } from '../interface';
|
||||||
|
|
||||||
import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button';
|
import { OVERLAY_BACK_BUTTON_PRIORITY } from './hardware-back-button';
|
||||||
|
import { getElementRoot } from './helpers';
|
||||||
|
|
||||||
let lastId = 0;
|
let lastId = 0;
|
||||||
|
|
||||||
@ -62,19 +63,128 @@ export const createOverlay = <T extends HTMLIonOverlayElement>(tagName: string,
|
|||||||
return Promise.resolve() as any;
|
return Promise.resolve() as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusableQueryString = '[tabindex]:not([tabindex^="-"]), input, textarea, button, select, .ion-focusable';
|
||||||
|
|
||||||
|
const focusFirstDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
||||||
|
let firstInput = ref.querySelector(focusableQueryString) as HTMLElement | null;
|
||||||
|
|
||||||
|
const shadowRoot = firstInput && firstInput.shadowRoot;
|
||||||
|
if (shadowRoot) {
|
||||||
|
firstInput = shadowRoot.querySelector('input, textarea, button, select');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstInput) {
|
||||||
|
firstInput.focus();
|
||||||
|
} else {
|
||||||
|
// Focus overlay instead of letting focus escape
|
||||||
|
overlay.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusLastDescendant = (ref: Element, overlay: HTMLIonOverlayElement) => {
|
||||||
|
const inputs = Array.from(ref.querySelectorAll(focusableQueryString)) as HTMLElement[];
|
||||||
|
let lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
|
||||||
|
|
||||||
|
const shadowRoot = lastInput && lastInput.shadowRoot;
|
||||||
|
if (shadowRoot) {
|
||||||
|
lastInput = shadowRoot.querySelector('input, textarea, button, select');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastInput) {
|
||||||
|
lastInput.focus();
|
||||||
|
} else {
|
||||||
|
// Focus overlay instead of letting focus escape
|
||||||
|
overlay.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traps keyboard focus inside of overlay components.
|
||||||
|
* Based on https://w3c.github.io/aria-practices/examples/dialog-modal/alertdialog.html
|
||||||
|
* This includes the following components: Action Sheet, Alert, Loading, Modal,
|
||||||
|
* Picker, and Popover.
|
||||||
|
* Should NOT include: Toast
|
||||||
|
*/
|
||||||
|
const trapKeyboardFocus = (ev: Event, doc: Document) => {
|
||||||
|
const lastOverlay = getOverlay(doc);
|
||||||
|
const target = ev.target as HTMLElement | null;
|
||||||
|
|
||||||
|
// If no active overlay, ignore this event
|
||||||
|
if (!lastOverlay || !target) { return; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we are focusing the overlay, clear
|
||||||
|
* the last focused element so that hitting
|
||||||
|
* tab activates the first focusable element
|
||||||
|
* in the overlay wrapper.
|
||||||
|
*/
|
||||||
|
if (lastOverlay === target) {
|
||||||
|
lastOverlay.lastFocus = undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otherwise, we must be focusing an element
|
||||||
|
* inside of the overlay. The two possible options
|
||||||
|
* here are an input/button/etc or the ion-focus-trap
|
||||||
|
* element. The focus trap element is used to prevent
|
||||||
|
* the keyboard focus from leaving the overlay when
|
||||||
|
* using Tab or screen assistants.
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* We do not want to focus the traps, so get the overlay
|
||||||
|
* wrapper element as the traps live outside of the wrapper.
|
||||||
|
*/
|
||||||
|
const overlayRoot = getElementRoot(lastOverlay);
|
||||||
|
const overlayWrapper = overlayRoot.querySelector('.ion-overlay-wrapper');
|
||||||
|
|
||||||
|
if (!overlayWrapper) { return; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the target is inside the wrapper, let the browser
|
||||||
|
* focus as normal and keep a log of the last focused element.
|
||||||
|
*/
|
||||||
|
if (overlayWrapper.contains(target)) {
|
||||||
|
lastOverlay.lastFocus = target;
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* Otherwise, we must have focused one of the focus traps.
|
||||||
|
* We need to wrap the focus to either the first element
|
||||||
|
* or the last element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once we call `focusFirstDescendant` and focus the first
|
||||||
|
* descendant, another focus event will fire which will
|
||||||
|
* cause `lastOverlay.lastFocus` to be updated before
|
||||||
|
* we can run the code after that. We will cache the value
|
||||||
|
* here to avoid that.
|
||||||
|
*/
|
||||||
|
const lastFocus = lastOverlay.lastFocus;
|
||||||
|
|
||||||
|
// Focus the first element in the overlay wrapper
|
||||||
|
focusFirstDescendant(overlayWrapper, lastOverlay);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the cached last focused element is the
|
||||||
|
* same as the active element, then we need
|
||||||
|
* to wrap focus to the last descendant. This happens
|
||||||
|
* when the first descendant is focused, and the user
|
||||||
|
* presses Shift + Tab. The previous line will focus
|
||||||
|
* the same descendant again (the first one), causing
|
||||||
|
* last focus to equal the active element.
|
||||||
|
*/
|
||||||
|
if (lastFocus === doc.activeElement) {
|
||||||
|
focusLastDescendant(overlayWrapper, lastOverlay);
|
||||||
|
}
|
||||||
|
lastOverlay.lastFocus = doc.activeElement as HTMLElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const connectListeners = (doc: Document) => {
|
export const connectListeners = (doc: Document) => {
|
||||||
if (lastId === 0) {
|
if (lastId === 0) {
|
||||||
lastId = 1;
|
lastId = 1;
|
||||||
// trap focus inside overlays
|
doc.addEventListener('focus', ev => trapKeyboardFocus(ev, doc), true);
|
||||||
doc.addEventListener('focusin', ev => {
|
|
||||||
const lastOverlay = getOverlay(doc);
|
|
||||||
if (lastOverlay && lastOverlay.backdropDismiss && !isDescendant(lastOverlay, ev.target as HTMLElement)) {
|
|
||||||
const firstInput = lastOverlay.querySelector('input,button') as HTMLElement | null;
|
|
||||||
if (firstInput) {
|
|
||||||
firstInput.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// handle back-button click
|
// handle back-button click
|
||||||
doc.addEventListener('ionBackButton', ev => {
|
doc.addEventListener('ionBackButton', ev => {
|
||||||
@ -247,16 +357,6 @@ export const isCancel = (role: string | undefined): boolean => {
|
|||||||
return role === 'cancel' || role === BACKDROP;
|
return role === 'cancel' || role === BACKDROP;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDescendant = (parent: HTMLElement, child: HTMLElement | null) => {
|
|
||||||
while (child) {
|
|
||||||
if (child === parent) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
child = child.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultGate = (h: any) => h();
|
const defaultGate = (h: any) => h();
|
||||||
|
|
||||||
export const safeCall = (handler: any, arg?: any) => {
|
export const safeCall = (handler: any, arg?: any) => {
|
||||||
|
Reference in New Issue
Block a user