From 86437ae0ad7e7a9ca6ed78f2a7c4a77bc50d4e1e Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 24 Jun 2025 13:47:18 -0700 Subject: [PATCH 1/5] fix(modal): add conditional tabIndex for handle cycling --- core/src/components/modal/modal.tsx | 3 ++- core/src/components/modal/test/sheet/index.html | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 2ec55fca020..88d16b2eb35 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -967,7 +967,8 @@ export class Modal implements ComponentInterface, OverlayInterface { return ( Present Sheet Modal (Scroll at any breakpoint) + )} From 69258ce04b9f3bae31829a4aa1911c02f663b246 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 30 Jun 2025 06:21:33 -0700 Subject: [PATCH 5/5] test(modal): adding accessibility test for drag handle --- .../components/modal/test/sheet/modal.e2e.ts | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/core/src/components/modal/test/sheet/modal.e2e.ts b/core/src/components/modal/test/sheet/modal.e2e.ts index 469040602fb..723bdcbddcc 100644 --- a/core/src/components/modal/test/sheet/modal.e2e.ts +++ b/core/src/components/modal/test/sheet/modal.e2e.ts @@ -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( + ` + + + + + Dismiss + Set breakpoint + + + + + `, + 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(); + }); + }); });