Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,23 @@ export const createSheetGesture = (
return true;
};

/**
* The wrapper element carries `tabindex="-1"` so that `present()` can
* move focus to the element that declares the dialog role (see
* modal.tsx). Firefox treats a focusable element as a selection root:
* pressing the pointer inside it places a text caret, and a later
* press over that caret can begin a native drag and drop session
* instead of delivering pointer events. If that happens while the
* sheet gesture is active, the gesture never receives the final
* pointerup and hangs mid-drag. Canceling any native dragstart while
* the gesture is active prevents the hijack; native drag and drop
* behaves as usual while the sheet is not being dragged.
*/
const preventNativeDragStart = (ev: DragEvent) => ev.preventDefault();

const onStart = (detail: GestureDetail) => {
baseEl.addEventListener('dragstart', preventNativeDragStart);

/**
* If canDismiss is anything other than `true`
* then users should be able to swipe down
Expand Down Expand Up @@ -443,6 +459,8 @@ export const createSheetGesture = (
};

const onEnd = (detail: GestureDetail) => {
baseEl.removeEventListener('dragstart', preventNativeDragStart);

const snapBreakpoint = calculateSnapBreakpoint(detail.deltaY);

const eventDetail: ModalDragEventDetail = {
Expand Down
18 changes: 18 additions & 0 deletions core/src/components/modal/gestures/swipe-to-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,25 @@ export const createSwipeToCloseGesture = (
return false;
};

/**
* The wrapper element carries `tabindex="-1"` so that `present()` can
* move focus to the element that declares the dialog role (see
* modal.tsx). Firefox treats a focusable element as a selection root:
* pressing the pointer inside it places a text caret, and a later
* press over that caret can begin a native drag and drop session
* instead of delivering pointer events. If that happens while the
* swipe-to-close gesture is active, the gesture never receives the
* final pointerup and hangs mid-drag. Canceling any native dragstart
* while the gesture is active prevents the hijack; native drag and
* drop behaves as usual while the modal is not being dragged.
*/
const preventNativeDragStart = (ev: DragEvent) => ev.preventDefault();

const onStart = (detail: GestureDetail) => {
const { deltaY } = detail;

el.addEventListener('dragstart', preventNativeDragStart);

/**
* Get the initial scrollY value so
* that we can correctly reset the scrollY
Expand Down Expand Up @@ -237,6 +253,8 @@ export const createSwipeToCloseGesture = (
};

const onEnd = (detail: GestureDetail) => {
el.removeEventListener('dragstart', preventNativeDragStart);

const velocity = detail.velocityY;
const step = detail.deltaY / height;

Expand Down
12 changes: 12 additions & 0 deletions core/src/components/modal/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ ion-backdrop {
z-index: 10;
}

/**
* The wrapper carries tabindex="-1" and receives focus when the modal
* is presented (see modal.tsx). Suppress the native focus ring that
* browsers may draw for it (for example after keyboard interaction),
* matching the host, which was the previous focus target and does the
* same above. Screen readers draw their own accessibility-focus
* indicator, so assistive technology users are unaffected.
*/
.modal-wrapper {
outline: none;
}

.modal-shadow {
position: absolute;

Expand Down
5 changes: 5 additions & 0 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1644,10 +1644,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
same element. They must also be set inside the
shadow DOM otherwise ion-button will not be highlighted
when using VoiceOver: https://bugs.webkit.org/show_bug.cgi?id=247134

tabIndex={-1} makes this element (rather than the role-less host)
receive focus when present() is called, so assistive technologies
get a proper focus target that carries the dialog's role/label.
*/
role="dialog"
{...inheritedAttributes}
aria-modal="true"
tabIndex={-1}
class="modal-wrapper ion-overlay-wrapper"
part="content"
ref={(el) => (this.wrapperEl = el)}
Expand Down
100 changes: 100 additions & 0 deletions core/src/components/modal/test/a11y/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,105 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});

// Focus the wrapper with role="dialog" on present so screen readers
// (e.g. TalkBack) enter the dialog.
test('should focus the modal wrapper on present', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'FW-7611',
});
await page.goto(`/src/components/modal/test/a11y`, config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const button = page.locator('#open-modal');
const wrapper = page.locator('ion-modal .modal-wrapper');

await button.click();
await ionModalDidPresent.next();

await expect(wrapper).toHaveAttribute('role', 'dialog');
await expect(wrapper).toBeFocused();
});

// The wrapper is focused on present, and browsers may draw a native
// focus ring for it when the modal is opened via keyboard interaction
// (:focus-visible). The wrapper must suppress it like the host does.
test('should not render a focus ring on the wrapper when presented via keyboard', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'FW-7611',
});
await page.goto(`/src/components/modal/test/a11y`, config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const button = page.locator('#open-modal');
const wrapper = page.locator('ion-modal .modal-wrapper');

// Open the modal with the keyboard so the browser applies its
// keyboard-modality focus heuristics (:focus-visible) to the wrapper.
await button.focus();
await page.keyboard.press('Enter');
await ionModalDidPresent.next();

await expect(wrapper).toBeFocused();
await expect(wrapper).toHaveCSS('outline-style', 'none');
});

test('should focus the sheet modal wrapper on present', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'FW-7611',
});
await page.setContent(
`
<ion-modal initial-breakpoint="0.5" breakpoints="[0, 0.5, 1]">
<ion-content>Sheet Modal Content</ion-content>
</ion-modal>
`,
config
);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const modal = page.locator('ion-modal');
const wrapper = page.locator('ion-modal .modal-wrapper');

await modal.evaluate((el: HTMLIonModalElement) => el.present());
await ionModalDidPresent.next();

await expect(wrapper).toHaveAttribute('role', 'dialog');
await expect(wrapper).toBeFocused();
});

test('should focus the card modal wrapper on present', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'FW-7611',
});
await page.setContent(
`
<div class="ion-page">
<ion-content>Root Content</ion-content>
</div>
<ion-modal>
<ion-content>Card Modal Content</ion-content>
</ion-modal>
`,
config
);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
const modal = page.locator('ion-modal');
const wrapper = page.locator('ion-modal .modal-wrapper');

await modal.evaluate((el: HTMLIonModalElement) => {
el.presentingElement = document.querySelector<HTMLElement>('.ion-page')!;
return el.present();
});
await ionModalDidPresent.next();

await expect(wrapper).toHaveAttribute('role', 'dialog');
await expect(wrapper).toBeFocused();
});
});
});
43 changes: 43 additions & 0 deletions core/src/components/modal/test/sheet/modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,49 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(ionDragEnd.length).toBe(1);
expect(Object.keys(dragEndEvent.detail).length).toBe(5);
});

// The wrapper carries tabindex="-1", which makes Firefox treat it as a
// selection root: a press over a text caret inside it can start a native
// drag and drop session that swallows the pointer stream, leaving the
// gesture hanging. The gesture cancels native dragstart only while it is
// active; native drag and drop must work again once the gesture ends.
test('should cancel native drag and drop only while the gesture is active', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'FW-7611',
});
await page.goto('/src/components/modal/test/sheet', config);

const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');

await page.click('#drag-events');
await ionModalDidPresent.next();

const ionDragStart = await page.spyOnEvent('ionDragStart');
const ionDragEnd = await page.spyOnEvent('ionDragEnd');

const dispatchNativeDragStart = () => {
return page.evaluate(() => {
const content = document.querySelector('.modal-sheet ion-content')!;
const ev = new DragEvent('dragstart', { bubbles: true, cancelable: true, composed: true });
content.dispatchEvent(ev);
return ev.defaultPrevented;
});
};

const header = page.locator('.modal-sheet ion-header');

// Hold the drag mid-gesture without releasing
await dragElementBy(header, page, 0, 50, undefined, undefined, false);
await ionDragStart.next();

expect(await dispatchNativeDragStart()).toBe(true);

await page.mouse.up();
await ionDragEnd.next();

expect(await dispatchNativeDragStart()).toBe(false);
});
});

test.describe(title('sheet modal: late breakpoints binding'), () => {
Expand Down
27 changes: 26 additions & 1 deletion core/src/utils/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,32 @@ export const present = async <OverlayPresentOptions>(
* to the overlay container.
*/
if (overlay.keyboardClose && (document.activeElement === null || !overlay.el.contains(document.activeElement))) {
overlay.el.focus();
/**
* Some overlays (modal, alert) declare `role="dialog"`/`alertdialog`,
* `aria-modal`, and the accessible label on the `.ion-overlay-wrapper`
* element inside the overlay rather than on the host itself. Focusing
* `overlay.el` (the host) in that case hands assistive technologies a
* focus target with no accessible role or name -- screen readers that
* rely on the focus/accessibility-focus event to know a dialog opened
* (e.g. Android TalkBack, which does not treat `aria-modal` alone as a
* navigation boundary) get no usable landing point, so their linear
* navigation cursor never actually enters the overlay's content.
*
* Other overlays (action-sheet, loading, popover) keep those
* attributes on the host and leave `.ion-overlay-wrapper` without a
* `tabindex`, in which case it is not focusable and the host remains
* the correct target. Only redirect to the wrapper when it actually
* declares a `tabindex` (i.e. the component authored it to be
* focusable) so overlays that already focus the host correctly are
* left untouched.
*
* `preventScroll` keeps this purely an accessibility-focus move: we
* do not want focusing the wrapper to scroll it into view, since the
* host was already the scroll-neutral, full-viewport container.
*/
const overlayWrapper = getElementRoot(overlay.el).querySelector<HTMLElement>('.ion-overlay-wrapper');
const focusTarget = overlayWrapper?.hasAttribute('tabindex') ? overlayWrapper : overlay.el;
focusTarget.focus({ preventScroll: true });
}

/**
Expand Down
Loading