fix(modal): add conditional tabIndex for handle cycling (#30510)
Issue number: resolves internal --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Currently, you cannot tab into a sheet modal from outside of it (the background), even with `handleBehavior` set to `cycle`. This destroys the accessibility of moving from the background behind a sheet modal to the contents of a sheet modal/the drag bar to be able to cycle the size. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> Now you can get into a sheet modal from outside of it and interact with its contents/drag handle when `handleBehavior` is set to `cycle`. This opens up the accessibility of the sheet modal and allows for interacting with background elements with sheet modals open using accessibility tools like VoiceOver and TalkBack. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> [Relevant test screen](https://ionic-framework-git-fw-6523-ionic1.vercel.app/src/components/modal/test/sheet) Dev build: `8.6.3-dev.11750971489.140836b0` --------- Co-authored-by: ionitron <hi@ionicframework.com>
@ -74,6 +74,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
private currentBreakpoint?: number;
|
||||
private wrapperEl?: HTMLElement;
|
||||
private backdropEl?: HTMLIonBackdropElement;
|
||||
private dragHandleEl?: HTMLButtonElement;
|
||||
private sortedBreakpoints?: number[];
|
||||
private keyboardOpenCallback?: () => void;
|
||||
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise<void>;
|
||||
@ -950,6 +951,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When the modal receives focus directly, pass focus to the handle
|
||||
* if it exists and is focusable, otherwise let the focus trap handle it.
|
||||
*/
|
||||
private onModalFocus = (ev: FocusEvent) => {
|
||||
const { dragHandleEl, el } = this;
|
||||
// Only handle focus if the modal itself was focused (not a child element)
|
||||
if (ev.target === el && dragHandleEl && dragHandleEl.tabIndex !== -1) {
|
||||
dragHandleEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
handle,
|
||||
@ -965,11 +978,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
const mode = getIonMode(this);
|
||||
const isCardModal = presentingElement !== undefined && mode === 'ios';
|
||||
const isHandleCycle = handleBehavior === 'cycle';
|
||||
const isSheetModalWithHandle = isSheetModal && showHandle;
|
||||
|
||||
return (
|
||||
<Host
|
||||
no-router
|
||||
tabindex="-1"
|
||||
// Allow the modal to be navigable when the handle is focusable
|
||||
tabIndex={isHandleCycle && isSheetModalWithHandle ? 0 : -1}
|
||||
{...(htmlAttributes as any)}
|
||||
style={{
|
||||
zIndex: `${20000 + this.overlayIndex}`,
|
||||
@ -989,6 +1004,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
onIonModalWillPresent={this.onLifecycle}
|
||||
onIonModalWillDismiss={this.onLifecycle}
|
||||
onIonModalDidDismiss={this.onLifecycle}
|
||||
onFocus={this.onModalFocus}
|
||||
>
|
||||
<ion-backdrop
|
||||
ref={(el) => (this.backdropEl = el)}
|
||||
@ -1021,6 +1037,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
aria-label="Activate to adjust the size of the dialog overlaying the screen"
|
||||
onClick={isHandleCycle ? this.onHandleClick : undefined}
|
||||
part="handle"
|
||||
ref={(el) => (this.dragHandleEl = el)}
|
||||
></button>
|
||||
)}
|
||||
<slot></slot>
|
||||
|
@ -106,6 +106,12 @@
|
||||
>
|
||||
Present Sheet Modal (Scroll at any breakpoint)
|
||||
</button>
|
||||
<button
|
||||
id="cycle-scroll-no-backdrop"
|
||||
onclick="presentModal({ handleBehavior: 'cycle', backdropBreakpoint: 1, backdropDismiss: false, initialBreakpoint: 0.5, breakpoints: [0, 0.25, 0.5, 0.75, 1], expandToScroll: false })"
|
||||
>
|
||||
Present Sheet Modal (Cycle Handle, Scroll at any breakpoint)
|
||||
</button>
|
||||
<button
|
||||
id="custom-backdrop-modal"
|
||||
onclick="presentModal({ backdropBreakpoint: 0.5, initialBreakpoint: 0.5 })"
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { configs, test, dragElementBy } from '@utils/test/playwright';
|
||||
import { configs, dragElementBy, test } from '@utils/test/playwright';
|
||||
|
||||
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
|
||||
test.describe(title('sheet modal: rendering'), () => {
|
||||
@ -30,6 +30,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
});
|
||||
|
||||
test('should dismiss the sheet modal when clicking the active backdrop', async ({ page }) => {
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
|
||||
@ -42,6 +43,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
|
||||
await ionModalDidDismiss.next();
|
||||
});
|
||||
|
||||
test('should present another sheet modal when clicking an inactive backdrop', async ({ page }) => {
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
const modal = page.locator('.custom-height');
|
||||
@ -54,6 +56,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
|
||||
await expect(modal).toBeVisible();
|
||||
});
|
||||
|
||||
test('input outside sheet modal should be focusable when backdrop is inactive', async ({ page }) => {
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
@ -66,6 +69,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await expect(input).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('sheet modal: setting the breakpoint'), () => {
|
||||
test.describe('sheet modal: invalid values', () => {
|
||||
let warnings: string[] = [];
|
||||
@ -88,11 +92,13 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
const modal = page.locator('ion-modal');
|
||||
await modal.evaluate((el: HTMLIonModalElement) => el.setCurrentBreakpoint(0.01));
|
||||
});
|
||||
|
||||
test('it should not change the breakpoint when setting to an invalid value', async ({ page }) => {
|
||||
const modal = page.locator('ion-modal');
|
||||
const breakpoint = await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint());
|
||||
expect(breakpoint).toBe(0.25);
|
||||
});
|
||||
|
||||
test('it should warn when setting an invalid breakpoint', async () => {
|
||||
expect(warnings.length).toBe(1);
|
||||
expect(warnings[0]).toBe(
|
||||
@ -100,6 +106,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('sheet modal: valid values', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
@ -108,6 +115,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await page.click('#sheet-modal');
|
||||
await ionModalDidPresent.next();
|
||||
});
|
||||
|
||||
test('should update the current breakpoint', async ({ page }) => {
|
||||
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
|
||||
const modal = page.locator('.modal-sheet');
|
||||
@ -118,6 +126,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
const breakpoint = await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint());
|
||||
expect(breakpoint).toBe(0.5);
|
||||
});
|
||||
|
||||
test('should emit ionBreakpointDidChange', async ({ page }) => {
|
||||
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
|
||||
const modal = page.locator('.modal-sheet');
|
||||
@ -126,6 +135,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await ionBreakpointDidChange.next();
|
||||
expect(ionBreakpointDidChange.events.length).toBe(1);
|
||||
});
|
||||
|
||||
test('should emit ionBreakpointDidChange when breakpoint is set to 0', async ({ page }) => {
|
||||
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
|
||||
const modal = page.locator('.modal-sheet');
|
||||
@ -134,6 +144,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await ionBreakpointDidChange.next();
|
||||
expect(ionBreakpointDidChange.events.length).toBe(1);
|
||||
});
|
||||
|
||||
test('should emit ionBreakpointDidChange when the sheet is swiped to breakpoint 0', async ({ page }) => {
|
||||
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
|
||||
const header = page.locator('.modal-sheet ion-header');
|
||||
@ -211,6 +222,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
expect(updatedBreakpoint).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('sheet modal: clicking the handle'), () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
@ -285,4 +297,60 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
|
||||
await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.75);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe(title('sheet modal: accessibility'), () => {
|
||||
test('it should allow focus on the drag handle from outside of the modal', async ({ page }) => {
|
||||
// In this scenario, the modal is opened and has no backdrop, allowing
|
||||
// the background content to be focused. We need to ensure that we can
|
||||
// navigate to the drag handle using the keyboard and voiceover/talkback.
|
||||
await page.goto('/src/components/modal/test/sheet', config);
|
||||
|
||||
await page.setContent(
|
||||
`
|
||||
<ion-content>
|
||||
<button id="open-modal">Open</button>
|
||||
<ion-modal trigger="open-modal" initial-breakpoint="0.25">
|
||||
<ion-content>
|
||||
<ion-button id="dismiss" onclick="modal.dismiss();">Dismiss</ion-button>
|
||||
<ion-button id="set-breakpoint">Set breakpoint</ion-button>
|
||||
</ion-content>
|
||||
</ion-modal>
|
||||
</ion-content>
|
||||
<script>
|
||||
const modal = document.querySelector('ion-modal');
|
||||
const setBreakpointButton = document.querySelector('#set-breakpoint');
|
||||
|
||||
modal.breakpoints = [0.25, 0.5, 1];
|
||||
modal.handleBehavior = 'cycle';
|
||||
modal.backdropBreakpoint = 1;
|
||||
modal.backdropDismiss = false;
|
||||
modal.expandToScroll = false;
|
||||
|
||||
setBreakpointButton.addEventListener('click', () => {
|
||||
modal.setCurrentBreakpoint(0.5);
|
||||
});
|
||||
</script>
|
||||
`,
|
||||
config
|
||||
);
|
||||
|
||||
const openButton = page.locator('#open-modal');
|
||||
|
||||
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
|
||||
|
||||
await openButton.click();
|
||||
await ionModalDidPresent.next();
|
||||
|
||||
const dragHandle = page.locator('ion-modal .modal-handle');
|
||||
await expect(dragHandle).toBeVisible();
|
||||
|
||||
openButton.focus();
|
||||
await expect(openButton).toBeFocused();
|
||||
|
||||
// Tab should now bring us to the drag handle
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
await expect(dragHandle).toBeFocused();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |