Skip to content

Commit 0deee3d

Browse files
committed
merge
2 parents 3738016 + ee47660 commit 0deee3d

11 files changed

+101
-11
lines changed

core/package-lock.json

Lines changed: 7 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"@stencil/angular-output-target": "^0.10.0",
5151
"@stencil/react-output-target": "0.5.3",
5252
"@stencil/sass": "^3.0.9",
53-
"@stencil/vue-output-target": "0.10.7",
53+
"@stencil/vue-output-target": "0.10.8",
5454
"@types/jest": "^29.5.6",
5555
"@types/node": "^14.6.0",
5656
"@typescript-eslint/eslint-plugin": "^6.7.2",

core/src/components/modal/modal.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
7575
private currentBreakpoint?: number;
7676
private wrapperEl?: HTMLElement;
7777
private backdropEl?: HTMLIonBackdropElement;
78+
private dragHandleEl?: HTMLButtonElement;
7879
private sortedBreakpoints?: number[];
7980
private keyboardOpenCallback?: () => void;
8081
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise<void>;
@@ -961,6 +962,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
961962
}
962963
};
963964

965+
/**
966+
* When the modal receives focus directly, pass focus to the handle
967+
* if it exists and is focusable, otherwise let the focus trap handle it.
968+
*/
969+
private onModalFocus = (ev: FocusEvent) => {
970+
const { dragHandleEl, el } = this;
971+
// Only handle focus if the modal itself was focused (not a child element)
972+
if (ev.target === el && dragHandleEl && dragHandleEl.tabIndex !== -1) {
973+
dragHandleEl.focus();
974+
}
975+
};
976+
964977
private initViewTransitionListener() {
965978
// Only enable for iOS card modals when no custom animations are provided
966979
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
@@ -1057,11 +1070,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
10571070
const mode = getIonMode(this);
10581071
const isCardModal = presentingElement !== undefined && mode === 'ios';
10591072
const isHandleCycle = handleBehavior === 'cycle';
1073+
const isSheetModalWithHandle = isSheetModal && showHandle;
10601074

10611075
return (
10621076
<Host
10631077
no-router
1064-
tabindex="-1"
1078+
// Allow the modal to be navigable when the handle is focusable
1079+
tabIndex={isHandleCycle && isSheetModalWithHandle ? 0 : -1}
10651080
{...(htmlAttributes as any)}
10661081
style={{
10671082
zIndex: `${20000 + this.overlayIndex}`,
@@ -1081,6 +1096,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
10811096
onIonModalWillPresent={this.onLifecycle}
10821097
onIonModalWillDismiss={this.onLifecycle}
10831098
onIonModalDidDismiss={this.onLifecycle}
1099+
onFocus={this.onModalFocus}
10841100
>
10851101
<ion-backdrop
10861102
ref={(el) => (this.backdropEl = el)}
@@ -1113,6 +1129,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
11131129
aria-label="Activate to adjust the size of the dialog overlaying the screen"
11141130
onClick={isHandleCycle ? this.onHandleClick : undefined}
11151131
part="handle"
1132+
ref={(el) => (this.dragHandleEl = el)}
11161133
></button>
11171134
)}
11181135
<slot></slot>

core/src/components/modal/test/sheet/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
>
107107
Present Sheet Modal (Scroll at any breakpoint)
108108
</button>
109+
<button
110+
id="cycle-scroll-no-backdrop"
111+
onclick="presentModal({ handleBehavior: 'cycle', backdropBreakpoint: 1, backdropDismiss: false, initialBreakpoint: 0.5, breakpoints: [0, 0.25, 0.5, 0.75, 1], expandToScroll: false })"
112+
>
113+
Present Sheet Modal (Cycle Handle, Scroll at any breakpoint)
114+
</button>
109115
<button
110116
id="custom-backdrop-modal"
111117
onclick="presentModal({ backdropBreakpoint: 0.5, initialBreakpoint: 0.5 })"

core/src/components/modal/test/sheet/modal.e2e.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@playwright/test';
2-
import { configs, test, dragElementBy } from '@utils/test/playwright';
2+
import { configs, dragElementBy, test } from '@utils/test/playwright';
33

44
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
55
test.describe(title('sheet modal: rendering'), () => {
@@ -30,6 +30,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
3030
test.beforeEach(async ({ page }) => {
3131
await page.goto('/src/components/modal/test/sheet', config);
3232
});
33+
3334
test('should dismiss the sheet modal when clicking the active backdrop', async ({ page }) => {
3435
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
3536
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
@@ -42,6 +43,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
4243

4344
await ionModalDidDismiss.next();
4445
});
46+
4547
test('should present another sheet modal when clicking an inactive backdrop', async ({ page }) => {
4648
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
4749
const modal = page.locator('.custom-height');
@@ -54,6 +56,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
5456

5557
await expect(modal).toBeVisible();
5658
});
59+
5760
test('input outside sheet modal should be focusable when backdrop is inactive', async ({ page }) => {
5861
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
5962

@@ -66,6 +69,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
6669
await expect(input).toBeFocused();
6770
});
6871
});
72+
6973
test.describe(title('sheet modal: setting the breakpoint'), () => {
7074
test.describe('sheet modal: invalid values', () => {
7175
let warnings: string[] = [];
@@ -88,18 +92,21 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
8892
const modal = page.locator('ion-modal');
8993
await modal.evaluate((el: HTMLIonModalElement) => el.setCurrentBreakpoint(0.01));
9094
});
95+
9196
test('it should not change the breakpoint when setting to an invalid value', async ({ page }) => {
9297
const modal = page.locator('ion-modal');
9398
const breakpoint = await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint());
9499
expect(breakpoint).toBe(0.25);
95100
});
101+
96102
test('it should warn when setting an invalid breakpoint', async () => {
97103
expect(warnings.length).toBe(1);
98104
expect(warnings[0]).toBe(
99105
'[Ionic Warning]: [ion-modal] - Attempted to set invalid breakpoint value 0.01. Please double check that the breakpoint value is part of your defined breakpoints.'
100106
);
101107
});
102108
});
109+
103110
test.describe('sheet modal: valid values', () => {
104111
test.beforeEach(async ({ page }) => {
105112
await page.goto('/src/components/modal/test/sheet', config);
@@ -108,6 +115,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
108115
await page.click('#sheet-modal');
109116
await ionModalDidPresent.next();
110117
});
118+
111119
test('should update the current breakpoint', async ({ page }) => {
112120
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
113121
const modal = page.locator('.modal-sheet');
@@ -118,6 +126,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
118126
const breakpoint = await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint());
119127
expect(breakpoint).toBe(0.5);
120128
});
129+
121130
test('should emit ionBreakpointDidChange', async ({ page }) => {
122131
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
123132
const modal = page.locator('.modal-sheet');
@@ -126,6 +135,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
126135
await ionBreakpointDidChange.next();
127136
expect(ionBreakpointDidChange.events.length).toBe(1);
128137
});
138+
129139
test('should emit ionBreakpointDidChange when breakpoint is set to 0', async ({ page }) => {
130140
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
131141
const modal = page.locator('.modal-sheet');
@@ -134,6 +144,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
134144
await ionBreakpointDidChange.next();
135145
expect(ionBreakpointDidChange.events.length).toBe(1);
136146
});
147+
137148
test('should emit ionBreakpointDidChange when the sheet is swiped to breakpoint 0', async ({ page }) => {
138149
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
139150
const header = page.locator('.modal-sheet ion-header');
@@ -211,6 +222,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
211222
expect(updatedBreakpoint).toBe(0.5);
212223
});
213224
});
225+
214226
test.describe(title('sheet modal: clicking the handle'), () => {
215227
test.beforeEach(async ({ page }) => {
216228
await page.goto('/src/components/modal/test/sheet', config);
@@ -285,4 +297,60 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
285297
await expect(await modal.evaluate((el: HTMLIonModalElement) => el.getCurrentBreakpoint())).toBe(0.75);
286298
});
287299
});
300+
301+
test.describe(title('sheet modal: accessibility'), () => {
302+
test('it should allow focus on the drag handle from outside of the modal', async ({ page }) => {
303+
// In this scenario, the modal is opened and has no backdrop, allowing
304+
// the background content to be focused. We need to ensure that we can
305+
// navigate to the drag handle using the keyboard and voiceover/talkback.
306+
await page.goto('/src/components/modal/test/sheet', config);
307+
308+
await page.setContent(
309+
`
310+
<ion-content>
311+
<button id="open-modal">Open</button>
312+
<ion-modal trigger="open-modal" initial-breakpoint="0.25">
313+
<ion-content>
314+
<ion-button id="dismiss" onclick="modal.dismiss();">Dismiss</ion-button>
315+
<ion-button id="set-breakpoint">Set breakpoint</ion-button>
316+
</ion-content>
317+
</ion-modal>
318+
</ion-content>
319+
<script>
320+
const modal = document.querySelector('ion-modal');
321+
const setBreakpointButton = document.querySelector('#set-breakpoint');
322+
323+
modal.breakpoints = [0.25, 0.5, 1];
324+
modal.handleBehavior = 'cycle';
325+
modal.backdropBreakpoint = 1;
326+
modal.backdropDismiss = false;
327+
modal.expandToScroll = false;
328+
329+
setBreakpointButton.addEventListener('click', () => {
330+
modal.setCurrentBreakpoint(0.5);
331+
});
332+
</script>
333+
`,
334+
config
335+
);
336+
337+
const openButton = page.locator('#open-modal');
338+
339+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
340+
341+
await openButton.click();
342+
await ionModalDidPresent.next();
343+
344+
const dragHandle = page.locator('ion-modal .modal-handle');
345+
await expect(dragHandle).toBeVisible();
346+
347+
openButton.focus();
348+
await expect(openButton).toBeFocused();
349+
350+
// Tab should now bring us to the drag handle
351+
await page.keyboard.press('Tab');
352+
353+
await expect(dragHandle).toBeFocused();
354+
});
355+
});
288356
});
737 Bytes
Loading
1.48 KB
Loading
1.45 KB
Loading
752 Bytes
Loading
1.3 KB
Loading

0 commit comments

Comments
 (0)