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>
This commit is contained in:
Shane
2025-06-30 09:33:48 -07:00
committed by GitHub
parent 72a5cdff0d
commit ee47660745
9 changed files with 93 additions and 2 deletions

View File

@ -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>

View File

@ -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 })"

View File

@ -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();
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 49 KiB