From 4be5ab8b7ef055d74c6643cc217727ab471feb42 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 16 Apr 2024 13:32:58 -0400 Subject: [PATCH 01/29] feat(picker): add stubbed assistive cover implementation --- .../picker-column/picker-column.scss | 16 ++++ .../picker-column/picker-column.tsx | 34 ++++++++ .../picker-column/test/picker-column.spec.tsx | 79 +++++++++++++++++++ .../components/picker/test/a11y/index.html | 9 +++ 4 files changed, 138 insertions(+) create mode 100644 core/src/components/picker-column/test/picker-column.spec.tsx diff --git a/core/src/components/picker-column/picker-column.scss b/core/src/components/picker-column/picker-column.scss index e4008f3824e..2dbd67ca4ff 100644 --- a/core/src/components/picker-column/picker-column.scss +++ b/core/src/components/picker-column/picker-column.scss @@ -5,6 +5,7 @@ :host { display: flex; + position: relative; align-items: center; @@ -19,6 +20,21 @@ text-align: center; } +/** + * Renders an invisible element on top of the column that receives focus + * events. This allows screen readers to navigate the column. + */ +.assistive-focusable { + @include position(0, 0, 0, 0); + position: absolute; + + // TODO NOW - Remove this (for debugging only) + background: rgba(0, 255, 0, 0.1); + + z-index: 1; + pointer-events: none; +} + .picker-opts { /** * This padding must be set here and not on the diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index 81b24bc904d..3cfb57d5be0 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -32,6 +32,13 @@ export class PickerColumn implements ComponentInterface { private parentEl?: HTMLIonPickerElement | null; private canExitInputMode = true; + @State() ariaLabel: string | null = null; + + @Watch('aria-label') + ariaLabelChanged(newValue: string) { + this.ariaLabel = newValue; + } + @State() isActive = false; @Element() el!: HTMLIonPickerColumnElement; @@ -206,6 +213,10 @@ export class PickerColumn implements ComponentInterface { } } + connectedCallback() { + this.ariaLabel = this.el.getAttribute('aria-label') ?? 'Select a value'; + } + private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => { const { isColumnVisible, scrollEl } = this; @@ -481,6 +492,28 @@ export class PickerColumn implements ComponentInterface { }); } + /** + * 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 = () => { + return ( +
+ ); + }; + render() { const { color, disabled, isActive, numericInput } = this; const mode = getIonMode(this); @@ -494,6 +527,7 @@ export class PickerColumn implements ComponentInterface { ['picker-column-disabled']: disabled, })} > + {this.renderAssistiveFocusable()}
{ + beforeEach(() => { + const mockIntersectionObserver = jest.fn(); + mockIntersectionObserver.mockReturnValue({ + observe: () => null, + unobserve: () => null, + disconnect: () => null, + }); + global.IntersectionObserver = mockIntersectionObserver; + }); + + it('should have a default label', async () => { + const page = await newSpecPage({ + components: [PickerColumn], + template: () => , + }); + + const pickerCol = page.body.querySelector('ion-picker-column')!; + const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!; + + expect(assistiveFocusable.getAttribute('aria-label')).not.toBe(null); + }); + + it('should have a custom label', async () => { + const page = await newSpecPage({ + components: [PickerColumn], + template: () => , + }); + + const pickerCol = page.body.querySelector('ion-picker-column')!; + const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!; + + expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label'); + }); + + it('should update a custom label', async () => { + const page = await newSpecPage({ + components: [PickerColumn], + template: () => , + }); + + const pickerCol = page.body.querySelector('ion-picker-column')!; + const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!; + + pickerCol.setAttribute('aria-label', 'my label'); + await page.waitForChanges(); + + expect(assistiveFocusable.getAttribute('aria-label')).toBe('my label'); + }); + + it('should receive keyboard focus when enabled', async () => { + const page = await newSpecPage({ + components: [PickerColumn], + template: () => , + }); + + const pickerCol = page.body.querySelector('ion-picker-column')!; + const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!; + + expect(assistiveFocusable.tabIndex).toBe(0); + }); + + it('should not receive keyboard focus when disabled', async () => { + const page = await newSpecPage({ + components: [PickerColumn], + template: () => , + }); + + const pickerCol = page.body.querySelector('ion-picker-column')!; + const assistiveFocusable = pickerCol.shadowRoot!.querySelector('.assistive-focusable')!; + + expect(assistiveFocusable.tabIndex).toBe(-1); + }); +}); diff --git a/core/src/components/picker/test/a11y/index.html b/core/src/components/picker/test/a11y/index.html index ad27eebe1d9..8e2361559c4 100644 --- a/core/src/components/picker/test/a11y/index.html +++ b/core/src/components/picker/test/a11y/index.html @@ -16,6 +16,15 @@

Picker - a11y

+ + First + Second + Third + Fourth + Fifth + Sixth + Seventh + First Second From a71f00959902f7026f93c91a588f84eb0cc9db28 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 16 Apr 2024 16:01:25 -0400 Subject: [PATCH 02/29] feat: implement arrow up, down, home, end, and aria label --- .../picker-column/picker-column.tsx | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index 3cfb57d5be0..f73baa30abf 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -492,6 +492,71 @@ export class PickerColumn implements ComponentInterface { }); } + private findNextOption = () => { + const { activeItem } = this; + if (!activeItem) return undefined; + + let node = activeItem.nextElementSibling as HTMLIonPickerColumnOptionElement | null; + while (node != null) { + if (node.tagName === 'ION-PICKER-COLUMN-OPTION' && !node.disabled) { + return node; + } + node = node.nextElementSibling as HTMLIonPickerColumnOptionElement | null; + } + + return undefined; + } + + private findPreviousOption = () => { + const { activeItem } = this; + if (!activeItem) return undefined; + + let node = activeItem.previousElementSibling as HTMLIonPickerColumnOptionElement | null; + while (node != null) { + if (node.tagName === 'ION-PICKER-COLUMN-OPTION' && !node.disabled) { + return node; + } + node = node.previousElementSibling as HTMLIonPickerColumnOptionElement | null; + } + + return undefined; + } + + private onKeyDown = (ev: KeyboardEvent) => { + let options, newOption; + switch(ev.key) { + case 'ArrowDown': + newOption = this.findNextOption(); + break; + case 'ArrowUp': + newOption = this.findPreviousOption(); + break; + case 'PageUp': + // TODO + break; + case 'PageDown': + // TODO + break; + case 'Home': + options = this.el.querySelectorAll('ion-picker-column-option'); + newOption = options[0]; + break; + case 'End': + options = this.el.querySelectorAll('ion-picker-column-option'); + newOption = options[options.length - 1]; + break; + default: + break; + } + + if (newOption != null) { + this.value = newOption.value; + + // This stops any default browser behavior such as scrolling + ev.preventDefault(); + } + } + /** * 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 @@ -499,6 +564,9 @@ export class PickerColumn implements ComponentInterface { * slider role: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role */ private renderAssistiveFocusable = () => { + const { activeItem } = this; + const valueText = activeItem ? activeItem.innerText : ''; + return (
this.onKeyDown(ev)} >
); }; From 02246175975870d5911a8e4b793c978924055d9c Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 16 Apr 2024 16:02:45 -0400 Subject: [PATCH 03/29] fix: do not focus underlying scroll container --- core/src/components/picker-column/picker-column.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/components/picker-column/picker-column.tsx b/core/src/components/picker-column/picker-column.tsx index f73baa30abf..3f1eb2b4fec 100644 --- a/core/src/components/picker-column/picker-column.tsx +++ b/core/src/components/picker-column/picker-column.tsx @@ -599,6 +599,7 @@ export class PickerColumn implements ComponentInterface { {this.renderAssistiveFocusable()}