{
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)}
>
diff --git a/core/src/components/picker-column/test/picker-column.spec.tsx b/core/src/components/picker-column/test/picker-column.spec.tsx
index 55a9c1c88da..479daba5352 100644
--- a/core/src/components/picker-column/test/picker-column.spec.tsx
+++ b/core/src/components/picker-column/test/picker-column.spec.tsx
@@ -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
('.assistive-focusable')!;
+ const pickerOpts = pickerCol.shadowRoot!.querySelector('.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('.assistive-focusable')!;
+ const pickerOpts = pickerCol.shadowRoot!.querySelector('.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');
});
});
diff --git a/core/src/components/picker/picker.tsx b/core/src/components/picker/picker.tsx
index e8cf8e29fd0..5fd299edfe1 100644
--- a/core/src/components/picker/picker.tsx
+++ b/core/src/components/picker/picker.tsx
@@ -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 (
- this.onPointerDown(ev)} onClick={() => this.onClick()}>
+ this.onPointerDown(ev)}
+ onClick={(ev: PointerEvent) => this.onClick(ev)}
+ >
(
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 (
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 (
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';
diff --git a/core/src/utils/test/overlays/overlays.spec.ts b/core/src/utils/test/overlays/overlays.spec.ts
deleted file mode 100644
index 29a77c3c268..00000000000
--- a/core/src/utils/test/overlays/overlays.spec.ts
+++ /dev/null
@@ -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: `
-
- `,
- });
-
- 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: `
-
- `,
- });
-
- 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: `
-
-
- `,
- });
-
- 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: `
-
- `,
- });
-
- setRootAriaHidden(true);
- });
-
- it('should remove router-outlet from accessibility tree when overlay is presented', async () => {
- const page = await newSpecPage({
- components: [RouterOutlet, Modal],
- html: `
-
-
-
- `,
- });
-
- 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: `
-
-
-
-
- `,
- });
-
- const routerOutlet = page.body.querySelector('ion-router-outlet')!;
- const modalOne = page.body.querySelector('ion-modal#one')!;
- const modalTwo = page.body.querySelector('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: `
-
-
- `,
- });
-
- const modalOne = page.body.querySelector('ion-modal#one')!;
- const modalTwo = page.body.querySelector('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: `
-
-
- `,
- });
-
- const modalOne = page.body.querySelector('ion-modal#one')!;
- const modalTwo = page.body.querySelector('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: `
-
-
- `,
- });
-
- const modalOne = page.body.querySelector('ion-modal#one')!;
- const modalTwo = page.body.querySelector('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: `
-
-
-
-
- `,
- });
-
- const modalOne = page.body.querySelector('ion-modal#m-one')!;
- const modalTwo = page.body.querySelector('ion-modal#m-two')!;
- const toastOne = page.body.querySelector('ion-toast#t-one')!;
- const toastTwo = page.body.querySelector('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: `
-
-
-
- `,
- });
-
- const modalOne = page.body.querySelector('ion-modal#m-one')!;
- const modalTwo = page.body.querySelector('ion-modal#m-two')!;
- const toastOne = page.body.querySelector('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);
- });
-});