mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
Compare commits
14 Commits
ionic-modu
...
ROU-11368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e619c72d43 | ||
|
|
07d257f7f5 | ||
|
|
12ad1ed76c | ||
|
|
131de8b8ea | ||
|
|
74710d4cf6 | ||
|
|
3626302d32 | ||
|
|
c1574ffe1f | ||
|
|
e50ba30c22 | ||
|
|
7ba6b9d0c2 | ||
|
|
56b8b1de81 | ||
|
|
a1ea905c97 | ||
|
|
22ce04fc88 | ||
|
|
fbe3116ed5 | ||
|
|
120fabbc98 |
@@ -5,6 +5,7 @@ import { Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTa
|
||||
import { startFocusVisible } from '@utils/focus-visible';
|
||||
import { getElementRoot, raf, renderHiddenInput } from '@utils/helpers';
|
||||
import { printIonError, printIonWarning } from '@utils/logging';
|
||||
import { FOCUS_TRAP_DISABLE_CLASS } from '@utils/overlays';
|
||||
import { isRTL } from '@utils/rtl';
|
||||
import { createColorClasses } from '@utils/theme';
|
||||
import { caretDownSharp, caretUpSharp, chevronBack, chevronDown, chevronForward } from 'ionicons/icons';
|
||||
@@ -1606,7 +1607,7 @@ export class Datetime implements ComponentInterface {
|
||||
forcePresentation === 'time-date'
|
||||
? [this.renderTimePickerColumns(forcePresentation), this.renderDatePickerColumns(forcePresentation)]
|
||||
: [this.renderDatePickerColumns(forcePresentation), this.renderTimePickerColumns(forcePresentation)];
|
||||
return <ion-picker>{renderArray}</ion-picker>;
|
||||
return <ion-picker class={FOCUS_TRAP_DISABLE_CLASS}>{renderArray}</ion-picker>;
|
||||
}
|
||||
|
||||
private renderDatePickerColumns(forcePresentation: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Picker Column
|
||||
// --------------------------------------------------
|
||||
|
||||
button {
|
||||
.picker-column-option-button {
|
||||
@include padding(0);
|
||||
@include margin(0);
|
||||
|
||||
@@ -40,6 +40,6 @@ button {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
:host(.option-disabled) button {
|
||||
:host(.option-disabled) .picker-column-option-button {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ export class PickerColumnOption implements ComponentInterface {
|
||||
['option-disabled']: disabled,
|
||||
})}
|
||||
>
|
||||
<button tabindex="-1" aria-label={ariaLabel} disabled={disabled} onClick={() => this.onClick()}>
|
||||
<div class={'picker-column-option-button'} role="button" aria-label={ariaLabel} onClick={() => this.onClick()}>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</div>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { newSpecPage } from '@stencil/core/testing';
|
||||
import { PickerColumnOption } from '../picker-column-option';
|
||||
|
||||
describe('picker column option', () => {
|
||||
it('button should be enabled by default', async () => {
|
||||
it('should be enabled by default', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [PickerColumnOption],
|
||||
html: `
|
||||
@@ -12,12 +12,11 @@ describe('picker column option', () => {
|
||||
});
|
||||
|
||||
const option = page.body.querySelector('ion-picker-column-option')!;
|
||||
const button = option.shadowRoot!.querySelector('button')!;
|
||||
|
||||
await expect(button.hasAttribute('disabled')).toEqual(false);
|
||||
await expect(option.classList.contains('option-disabled')).toEqual(false);
|
||||
});
|
||||
|
||||
it('button should be disabled if specified', async () => {
|
||||
it('should be disabled if specified', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [PickerColumnOption],
|
||||
html: `
|
||||
@@ -26,8 +25,7 @@ describe('picker column option', () => {
|
||||
});
|
||||
|
||||
const option = page.body.querySelector('ion-picker-column-option')!;
|
||||
const button = option.shadowRoot!.querySelector('button')!;
|
||||
|
||||
await expect(button.hasAttribute('disabled')).toEqual(true);
|
||||
await expect(option.classList.contains('option-disabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -654,39 +654,6 @@ export class PickerColumn implements ComponentInterface {
|
||||
return el ? el.getAttribute('aria-label') ?? el.innerText : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an element that overlays the column. This element is for assistive
|
||||
* tech to allow users to navigate the column up/down. This element should receive
|
||||
* focus as it listens for synthesized keyboard events as required by the
|
||||
* slider role: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role
|
||||
*/
|
||||
private renderAssistiveFocusable = () => {
|
||||
const { activeItem } = this;
|
||||
const valueText = this.getOptionValueText(activeItem);
|
||||
|
||||
/**
|
||||
* When using the picker, the valuetext provides important context that valuenow
|
||||
* does not. Additionally, using non-zero valuemin/valuemax values can cause
|
||||
* WebKit to incorrectly announce numeric valuetext values (such as a year
|
||||
* like "2024") as percentages: https://bugs.webkit.org/show_bug.cgi?id=273126
|
||||
*/
|
||||
return (
|
||||
<div
|
||||
ref={(el) => (this.assistiveFocusable = el)}
|
||||
class="assistive-focusable"
|
||||
role="slider"
|
||||
tabindex={this.disabled ? undefined : 0}
|
||||
aria-label={this.ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
aria-valuetext={valueText}
|
||||
aria-orientation="vertical"
|
||||
onKeyDown={(ev) => this.onKeyDown(ev)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { color, disabled, isActive, numericInput } = this;
|
||||
const theme = getIonTheme(this);
|
||||
@@ -700,33 +667,21 @@ export class PickerColumn implements ComponentInterface {
|
||||
['picker-column-disabled']: disabled,
|
||||
})}
|
||||
>
|
||||
{this.renderAssistiveFocusable()}
|
||||
<slot name="prefix"></slot>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="picker-opts"
|
||||
ref={(el) => {
|
||||
this.scrollEl = el;
|
||||
}}
|
||||
/**
|
||||
* When an element has an overlay scroll style and
|
||||
* a fixed height, Firefox will focus the scrollable
|
||||
* container if the content exceeds the container's
|
||||
* dimensions.
|
||||
*
|
||||
* This causes keyboard navigation to focus to this
|
||||
* element instead of going to the next element in
|
||||
* the tab order.
|
||||
*
|
||||
* The desired behavior is for the user to be able to
|
||||
* focus the assistive focusable element and tab to
|
||||
* the next element in the tab order. Instead of tabbing
|
||||
* to this element.
|
||||
*
|
||||
* To prevent this, we set the tabIndex to -1. This
|
||||
* will match the behavior of the other browsers.
|
||||
*/
|
||||
tabIndex={-1}
|
||||
role="slider"
|
||||
tabindex={this.disabled ? undefined : 0}
|
||||
aria-label={this.ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={0}
|
||||
aria-valuenow={0}
|
||||
aria-valuetext={this.getOptionValueText(this.activeItem)}
|
||||
aria-orientation="vertical"
|
||||
onKeyDown={(ev) => this.onKeyDown(ev)}
|
||||
>
|
||||
<div class="picker-item-empty" aria-hidden="true">
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { h } from '@stencil/core';
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { PickerColumn } from '../picker-column';
|
||||
import { PickerColumnOption } from '../../picker-column-option/picker-column-option';
|
||||
import { PickerColumn } from '../picker-column';
|
||||
|
||||
describe('picker-column: assistive element', () => {
|
||||
describe('picker-column', () => {
|
||||
beforeEach(() => {
|
||||
const mockIntersectionObserver = jest.fn();
|
||||
mockIntersectionObserver.mockReturnValue({
|
||||
@@ -22,9 +22,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).not.toBe(null);
|
||||
expect(pickerOpts.getAttribute('aria-label')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should have a custom label', async () => {
|
||||
@@ -34,9 +34,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label');
|
||||
expect(pickerOpts.getAttribute('aria-label')).toBe('my label');
|
||||
});
|
||||
|
||||
it('should update a custom label', async () => {
|
||||
@@ -46,12 +46,12 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
pickerCol.setAttribute('aria-label', 'my label');
|
||||
await page.waitForChanges();
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label');
|
||||
expect(pickerOpts.getAttribute('aria-label')).toBe('my label');
|
||||
});
|
||||
|
||||
it('should receive keyboard focus when enabled', async () => {
|
||||
@@ -61,9 +61,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector<HTMLElement>('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector<HTMLElement>('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.tabIndex).toBe(0);
|
||||
expect(pickerOpts.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should not receive keyboard focus when disabled', async () => {
|
||||
@@ -73,9 +73,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector<HTMLElement>('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector<HTMLElement>('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.tabIndex).toBe(-1);
|
||||
expect(pickerOpts.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should use option aria-label as assistive element aria-valuetext', async () => {
|
||||
@@ -91,9 +91,9 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-valuetext')).toBe('My Label');
|
||||
expect(pickerOpts.getAttribute('aria-valuetext')).toBe('My Label');
|
||||
});
|
||||
|
||||
it('should use option text as assistive element aria-valuetext when no label provided', async () => {
|
||||
@@ -107,8 +107,8 @@ describe('picker-column: assistive element', () => {
|
||||
});
|
||||
|
||||
const pickerCol = page.body.querySelector('ion-picker-column')!;
|
||||
const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!;
|
||||
const pickerOpts = pickerCol.shadowRoot!.querySelector('.picker-opts')!;
|
||||
|
||||
expect(assistiveFocusable.getAttribute('aria-valuetext')).toBe('My Text');
|
||||
expect(pickerOpts.getAttribute('aria-valuetext')).toBe('My Text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,12 +135,63 @@ export class Picker implements ComponentInterface {
|
||||
* function that has been set in onPointerDown
|
||||
* so that we enter/exit input mode correctly.
|
||||
*/
|
||||
private onClick = () => {
|
||||
private onClick = (ev: PointerEvent) => {
|
||||
const { actionOnClick } = this;
|
||||
if (actionOnClick) {
|
||||
actionOnClick();
|
||||
this.actionOnClick = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* In order to avoid a11y issues we must manage focus
|
||||
* on the picker columns and picker itself.
|
||||
* This is because once picker is clicked we got an issue/warning because
|
||||
* picker input is being focused, and once it has tabindex -1 it can't be focused,
|
||||
* which ends on focusing the picker itself.
|
||||
* During the process above we fall into issues since there is an element
|
||||
* with tabindex -1 and aria-hidden='true' that is focused, which is not allowed.
|
||||
* That said and since onClick is being propagated to the picker itself, we need to
|
||||
* manage focus on the picker columns and picker itself to avoid the issue.
|
||||
*/
|
||||
const clickedTarget = ev.target as HTMLElement;
|
||||
let elementToFocus: HTMLElement | null = null;
|
||||
|
||||
switch (clickedTarget.tagName) {
|
||||
case 'ION-PICKER':
|
||||
/**
|
||||
* If the user clicked the picker itself
|
||||
* then we should focus the first picker options
|
||||
* so that users can scroll through them.
|
||||
*/
|
||||
const ionPickerColumn = this.el.querySelector('ion-picker-column');
|
||||
elementToFocus = ionPickerColumn?.shadowRoot?.querySelector('.picker-opts') as HTMLElement | null;
|
||||
break;
|
||||
|
||||
case 'ION-PICKER-COLUMN':
|
||||
/**
|
||||
* If the user clicked a picker column
|
||||
* then we should focus its own picker options
|
||||
* so that users can scroll through them.
|
||||
*/
|
||||
elementToFocus = clickedTarget.shadowRoot?.querySelector('.picker-opts') as HTMLElement | null;
|
||||
break;
|
||||
|
||||
case 'ION-PICKER-COLUMN-OPTION':
|
||||
/**
|
||||
* If the user clicked a picker column option
|
||||
* then we should focus its picker options parent so that
|
||||
* users can scroll through them.
|
||||
*/
|
||||
const ionPickerColumnOption = clickedTarget.closest('ion-picker-column');
|
||||
if (ionPickerColumnOption) {
|
||||
elementToFocus = ionPickerColumnOption.shadowRoot?.querySelector('.picker-opts') as HTMLElement | null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (elementToFocus) {
|
||||
elementToFocus.focus();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -537,7 +588,10 @@ export class Picker implements ComponentInterface {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Host onPointerDown={(ev: PointerEvent) => this.onPointerDown(ev)} onClick={() => this.onClick()}>
|
||||
<Host
|
||||
onPointerDown={(ev: PointerEvent) => this.onPointerDown(ev)}
|
||||
onClick={(ev: PointerEvent) => this.onClick(ev)}
|
||||
>
|
||||
<input
|
||||
aria-hidden="true"
|
||||
tabindex={-1}
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
getElementRoot,
|
||||
removeEventListener,
|
||||
} from './helpers';
|
||||
import { isPlatform } from './platform';
|
||||
|
||||
let lastOverlayIndex = 0;
|
||||
let lastId = 0;
|
||||
@@ -523,9 +522,6 @@ export const present = async <OverlayPresentOptions>(
|
||||
document.body.classList.add(BACKDROP_NO_SCROLL);
|
||||
}
|
||||
|
||||
hideUnderlyingOverlaysFromScreenReaders(overlay.el);
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
|
||||
overlay.presented = true;
|
||||
overlay.willPresent.emit();
|
||||
overlay.willPresentShorthand?.emit();
|
||||
@@ -674,13 +670,6 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
overlay.presented = false;
|
||||
|
||||
try {
|
||||
/**
|
||||
* There is no need to show the overlay to screen readers during
|
||||
* the dismiss animation. This is because the overlay will be removed
|
||||
* from the DOM after the animation is complete.
|
||||
*/
|
||||
hideAnimatingOverlayFromScreenReaders(overlay.el);
|
||||
|
||||
// Overlay contents should not be clickable during dismiss
|
||||
overlay.el.style.setProperty('pointer-events', 'none');
|
||||
overlay.willDismiss.emit({ data, role });
|
||||
@@ -728,8 +717,6 @@ export const dismiss = async <OverlayDismissOptions>(
|
||||
|
||||
overlay.el.remove();
|
||||
|
||||
revealOverlaysToScreenReaders();
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -967,98 +954,4 @@ export const createTriggerController = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The overlay that is being animated also needs to hide from screen
|
||||
* readers during its animation. This ensures that assistive technologies
|
||||
* like TalkBack do not announce or interact with the content until the
|
||||
* animation is complete, avoiding confusion for users.
|
||||
*
|
||||
* When the overlay is presented on an Android device, TalkBack's focus rings
|
||||
* may appear in the wrong position due to the transition (specifically
|
||||
* `transform` styles). This occurs because the focus rings are initially
|
||||
* displayed at the starting position of the elements before the transition
|
||||
* begins. This workaround ensures the focus rings do not appear in the
|
||||
* incorrect location.
|
||||
*
|
||||
* If this solution is applied to iOS devices, then it leads to a bug where
|
||||
* the overlays cannot be accessed by screen readers. This is due to
|
||||
* VoiceOver not being able to update the accessibility tree when the
|
||||
* `aria-hidden` is removed.
|
||||
*
|
||||
* @param overlay - The overlay that is being animated.
|
||||
*/
|
||||
const hideAnimatingOverlayFromScreenReaders = (overlay: HTMLIonOverlayElement) => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
if (isPlatform('android')) {
|
||||
/**
|
||||
* Once the animation is complete, this attribute will be removed.
|
||||
* This is done at the end of the `present` method.
|
||||
*/
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that underlying overlays have aria-hidden if necessary so that screen readers
|
||||
* cannot move focus to these elements. Note that we cannot rely on focus/focusin/focusout
|
||||
* events here because those events do not fire when the screen readers moves to a non-focusable
|
||||
* element such as text.
|
||||
* Without this logic screen readers would be able to move focus outside of the top focus-trapped overlay.
|
||||
*
|
||||
* @param newTopMostOverlay - The overlay that is being presented. Since the overlay has not been
|
||||
* fully presented yet at the time this function is called it will not be included in the getPresentedOverlays result.
|
||||
*/
|
||||
const hideUnderlyingOverlaysFromScreenReaders = (newTopMostOverlay: HTMLIonOverlayElement) => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
const overlays = getPresentedOverlays(doc);
|
||||
|
||||
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||
const presentedOverlay = overlays[i];
|
||||
const nextPresentedOverlay = overlays[i + 1] ?? newTopMostOverlay;
|
||||
|
||||
/**
|
||||
* If next overlay has aria-hidden then all remaining overlays will have it too.
|
||||
* Or, if the next overlay is a Toast that does not have aria-hidden then current overlay
|
||||
* should not have aria-hidden either so focus can remain in the current overlay.
|
||||
*/
|
||||
if (nextPresentedOverlay.hasAttribute('aria-hidden') || nextPresentedOverlay.tagName !== 'ION-TOAST') {
|
||||
presentedOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When dismissing an overlay we need to reveal the new top-most overlay to screen readers.
|
||||
* If the top-most overlay is a Toast we potentially need to reveal more overlays since
|
||||
* focus is never automatically moved to the Toast.
|
||||
*/
|
||||
const revealOverlaysToScreenReaders = () => {
|
||||
if (doc === undefined) return;
|
||||
|
||||
const overlays = getPresentedOverlays(doc);
|
||||
|
||||
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||
const currentOverlay = overlays[i];
|
||||
|
||||
/**
|
||||
* If the current we are looking at is a Toast then we can remove aria-hidden.
|
||||
* However, we potentially need to keep looking at the overlay stack because there
|
||||
* could be more Toasts underneath. Additionally, we need to unhide the closest non-Toast
|
||||
* overlay too so focus can move there since focus is never automatically moved to the Toast.
|
||||
*/
|
||||
currentOverlay.removeAttribute('aria-hidden');
|
||||
|
||||
/**
|
||||
* If we found a non-Toast element then we can just remove aria-hidden and stop searching entirely
|
||||
* since this overlay should always receive focus. As a result, all underlying overlays should still
|
||||
* be hidden from screen readers.
|
||||
*/
|
||||
if (currentOverlay.tagName !== 'ION-TOAST') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const FOCUS_TRAP_DISABLE_CLASS = 'ion-disable-focus-trap';
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { newSpecPage } from '@stencil/core/testing';
|
||||
|
||||
import { Modal } from '../../../components/modal/modal';
|
||||
import { Toast } from '../../../components/toast/toast';
|
||||
import { Nav } from '../../../components/nav/nav';
|
||||
import { RouterOutlet } from '../../../components/router-outlet/router-outlet';
|
||||
import { setRootAriaHidden } from '../../overlays';
|
||||
|
||||
describe('setRootAriaHidden()', () => {
|
||||
it('should correctly remove and re-add router outlet from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet],
|
||||
html: `
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should correctly remove and re-add nav from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Nav],
|
||||
html: `
|
||||
<ion-nav></ion-nav>
|
||||
`,
|
||||
});
|
||||
|
||||
const nav = page.body.querySelector('ion-nav')!;
|
||||
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(nav.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should correctly remove and re-add custom container from accessibility tree', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [],
|
||||
html: `
|
||||
<div id="ion-view-container-root"></div>
|
||||
<div id="not-container-root"></div>
|
||||
`,
|
||||
});
|
||||
|
||||
const containerRoot = page.body.querySelector('#ion-view-container-root')!;
|
||||
const notContainerRoot = page.body.querySelector('#not-container-root')!;
|
||||
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(true);
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
setRootAriaHidden(false);
|
||||
expect(containerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(notContainerRoot.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not error if router outlet was not found', async () => {
|
||||
await newSpecPage({
|
||||
components: [],
|
||||
html: `
|
||||
<div></div>
|
||||
`,
|
||||
});
|
||||
|
||||
setRootAriaHidden(true);
|
||||
});
|
||||
|
||||
it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
const modal = page.body.querySelector('ion-modal')!;
|
||||
|
||||
await modal.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should add router-outlet from accessibility tree when then final overlay is dismissed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [RouterOutlet, Modal],
|
||||
html: `
|
||||
<ion-router-outlet>
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
</ion-router-outlet>
|
||||
`,
|
||||
});
|
||||
|
||||
const routerOutlet = page.body.querySelector('ion-router-outlet')!;
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
|
||||
|
||||
await modalOne.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.present();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalOne.dismiss();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(true);
|
||||
|
||||
await modalTwo.dismiss();
|
||||
|
||||
expect(routerOutlet.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aria-hidden on individual overlays', () => {
|
||||
it('should hide non-topmost overlays from screen readers', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
html: `
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
|
||||
|
||||
await modalOne.present();
|
||||
await modalTwo.present();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should unhide new topmost overlay from screen readers when topmost is dismissed', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
html: `
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
|
||||
|
||||
await modalOne.present();
|
||||
await modalTwo.present();
|
||||
|
||||
// dismiss modalTwo so that modalOne becomes the new topmost overlay
|
||||
await modalTwo.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not keep overlays hidden from screen readers if presented after being dismissed while non-topmost', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal],
|
||||
html: `
|
||||
<ion-modal id="one"></ion-modal>
|
||||
<ion-modal id="two"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#two')!;
|
||||
|
||||
await modalOne.present();
|
||||
await modalTwo.present();
|
||||
|
||||
// modalOne is not the topmost overlay at this point and is hidden from screen readers
|
||||
await modalOne.dismiss();
|
||||
|
||||
// modalOne will become the topmost overlay; ensure it isn't still hidden from screen readers
|
||||
await modalOne.present();
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not hide previous overlay if top-most overlay is toast', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal, Toast],
|
||||
html: `
|
||||
<ion-modal id="m-one"></ion-modal>
|
||||
<ion-modal id="m-two"></ion-modal>
|
||||
<ion-toast id="t-one"></ion-toast>
|
||||
<ion-toast id="t-two"></ion-toast>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
|
||||
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
|
||||
const toastTwo = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-two')!;
|
||||
|
||||
await modalOne.present();
|
||||
await modalTwo.present();
|
||||
await toastOne.present();
|
||||
await toastTwo.present();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await toastTwo.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await toastOne.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should hide previous overlay even with a toast that is not the top-most overlay', async () => {
|
||||
const page = await newSpecPage({
|
||||
components: [Modal, Toast],
|
||||
html: `
|
||||
<ion-modal id="m-one"></ion-modal>
|
||||
<ion-toast id="t-one"></ion-toast>
|
||||
<ion-modal id="m-two"></ion-modal>
|
||||
`,
|
||||
});
|
||||
|
||||
const modalOne = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-one')!;
|
||||
const modalTwo = page.body.querySelector<HTMLIonModalElement>('ion-modal#m-two')!;
|
||||
const toastOne = page.body.querySelector<HTMLIonModalElement>('ion-toast#t-one')!;
|
||||
|
||||
await modalOne.present();
|
||||
await toastOne.present();
|
||||
await modalTwo.present();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(true);
|
||||
expect(modalTwo.hasAttribute('aria-hidden')).toEqual(false);
|
||||
|
||||
await modalTwo.dismiss();
|
||||
|
||||
expect(modalOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
expect(toastOne.hasAttribute('aria-hidden')).toEqual(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user