Skip to content

Commit e38e2e4

Browse files
authored
feat(picker): picker column is easier to select with assistive technology (#29371)
Issue number: resolves #25221
1 parent f91a6ea commit e38e2e4

File tree

5 files changed

+333
-1
lines changed

5 files changed

+333
-1
lines changed

core/src/components/datetime/datetime.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,7 @@ export class Datetime implements ComponentInterface {
16871687

16881688
return (
16891689
<ion-picker-column
1690+
aria-label="Select a date"
16901691
class="date-column"
16911692
color={this.color}
16921693
disabled={disabled}
@@ -1806,6 +1807,7 @@ export class Datetime implements ComponentInterface {
18061807

18071808
return (
18081809
<ion-picker-column
1810+
aria-label="Select a day"
18091811
class="day-column"
18101812
color={this.color}
18111813
disabled={disabled}
@@ -1849,6 +1851,7 @@ export class Datetime implements ComponentInterface {
18491851

18501852
return (
18511853
<ion-picker-column
1854+
aria-label="Select a month"
18521855
class="month-column"
18531856
color={this.color}
18541857
disabled={disabled}
@@ -1891,6 +1894,7 @@ export class Datetime implements ComponentInterface {
18911894

18921895
return (
18931896
<ion-picker-column
1897+
aria-label="Select a year"
18941898
class="year-column"
18951899
color={this.color}
18961900
disabled={disabled}
@@ -1964,6 +1968,7 @@ export class Datetime implements ComponentInterface {
19641968

19651969
return (
19661970
<ion-picker-column
1971+
aria-label="Select an hour"
19671972
color={this.color}
19681973
disabled={disabled}
19691974
value={activePart.hour}
@@ -2003,6 +2008,7 @@ export class Datetime implements ComponentInterface {
20032008

20042009
return (
20052010
<ion-picker-column
2011+
aria-label="Select a minute"
20062012
color={this.color}
20072013
disabled={disabled}
20082014
value={activePart.minute}
@@ -2045,6 +2051,7 @@ export class Datetime implements ComponentInterface {
20452051

20462052
return (
20472053
<ion-picker-column
2054+
aria-label="Select a day period"
20482055
style={isDayPeriodRTL ? { order: '-1' } : {}}
20492056
color={this.color}
20502057
disabled={disabled}

core/src/components/picker-column/picker-column.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
:host {
77
display: flex;
8+
position: relative;
89

910
align-items: center;
1011

@@ -19,6 +20,23 @@
1920
text-align: center;
2021
}
2122

23+
/**
24+
* Renders an invisible element on top of the column that receives focus
25+
* events. This allows screen readers to navigate the column.
26+
*/
27+
.assistive-focusable {
28+
@include position(0, 0, 0, 0);
29+
position: absolute;
30+
31+
z-index: 1;
32+
pointer-events: none;
33+
}
34+
35+
// Hide the focus ring since screen readers will show their own
36+
.assistive-focusable:focus {
37+
outline: none;
38+
}
39+
2240
.picker-opts {
2341
/**
2442
* This padding must be set here and not on the

core/src/components/picker-column/picker-column.tsx

Lines changed: 185 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export class PickerColumn implements ComponentInterface {
3131
private isColumnVisible = false;
3232
private parentEl?: HTMLIonPickerElement | null;
3333
private canExitInputMode = true;
34+
private assistiveFocusable?: HTMLElement;
35+
private updateValueTextOnScroll = false;
36+
37+
@State() ariaLabel: string | null = null;
38+
39+
@Watch('aria-label')
40+
ariaLabelChanged(newValue: string) {
41+
this.ariaLabel = newValue;
42+
}
3443

3544
@State() isActive = false;
3645

@@ -206,6 +215,10 @@ export class PickerColumn implements ComponentInterface {
206215
}
207216
}
208217

218+
connectedCallback() {
219+
this.ariaLabel = this.el.getAttribute('aria-label') ?? 'Select a value';
220+
}
221+
209222
private centerPickerItemInView = (target: HTMLElement, smooth = true, canExitInputMode = true) => {
210223
const { isColumnVisible, scrollEl } = this;
211224

@@ -222,6 +235,7 @@ export class PickerColumn implements ComponentInterface {
222235
* of these can cause a scroll to occur.
223236
*/
224237
this.canExitInputMode = canExitInputMode;
238+
this.updateValueTextOnScroll = false;
225239
scrollEl.scroll({
226240
top,
227241
left: 0,
@@ -396,8 +410,24 @@ export class PickerColumn implements ComponentInterface {
396410
activeEl = newActiveElement;
397411
this.setPickerItemActiveState(newActiveElement, true);
398412

413+
/**
414+
* Set the aria-valuetext even though the value prop has not been updated yet.
415+
* This enables some screen readers to announce the value as the users drag
416+
* as opposed to when their release their pointer from the screen.
417+
*
418+
* When the value is programmatically updated, we will smoothly scroll
419+
* to the new option. However, we do not want to update aria-valuetext mid-scroll
420+
* as that can cause the old value to be briefly set before being set to the
421+
* correct option. This will cause some screen readers to announce the old value
422+
* again before announcing the new value. The correct valuetext will be set on render.
423+
*/
424+
if (this.updateValueTextOnScroll) {
425+
this.assistiveFocusable?.setAttribute('aria-valuetext', this.getOptionValueText(newActiveElement));
426+
}
427+
399428
timeout = setTimeout(() => {
400429
this.isScrolling = false;
430+
this.updateValueTextOnScroll = true;
401431
enableHaptics && hapticSelectionEnd();
402432

403433
/**
@@ -481,6 +511,159 @@ export class PickerColumn implements ComponentInterface {
481511
});
482512
}
483513

514+
/**
515+
* Find the next enabled option after the active option.
516+
* @param stride - How many options to "jump" over in order to select the next option.
517+
* This can be used to implement PageUp/PageDown behaviors where pressing these keys
518+
* scrolls the picker by more than 1 option. For example, a stride of 5 means select
519+
* the enabled option 5 options after the active one. Note that the actual option selected
520+
* may be past the stride if the option at the stride is disabled.
521+
*/
522+
private findNextOption = (stride = 1) => {
523+
const { activeItem } = this;
524+
if (!activeItem) return null;
525+
526+
let prevNode = activeItem;
527+
let node = activeItem.nextElementSibling as HTMLIonPickerColumnOptionElement | null;
528+
while (node != null) {
529+
if (stride > 0) {
530+
stride--;
531+
}
532+
533+
if (node.tagName === 'ION-PICKER-COLUMN-OPTION' && !node.disabled && stride === 0) {
534+
return node;
535+
}
536+
prevNode = node;
537+
538+
// Use nextElementSibling instead of nextSibling to avoid text/comment nodes
539+
node = node.nextElementSibling as HTMLIonPickerColumnOptionElement | null;
540+
}
541+
542+
return prevNode;
543+
};
544+
545+
/**
546+
* Find the next enabled option after the active option.
547+
* @param stride - How many options to "jump" over in order to select the next option.
548+
* This can be used to implement PageUp/PageDown behaviors where pressing these keys
549+
* scrolls the picker by more than 1 option. For example, a stride of 5 means select
550+
* the enabled option 5 options before the active one. Note that the actual option selected
551+
* may be past the stride if the option at the stride is disabled.
552+
*/
553+
private findPreviousOption = (stride: number = 1) => {
554+
const { activeItem } = this;
555+
if (!activeItem) return null;
556+
557+
let nextNode = activeItem;
558+
let node = activeItem.previousElementSibling as HTMLIonPickerColumnOptionElement | null;
559+
while (node != null) {
560+
if (stride > 0) {
561+
stride--;
562+
}
563+
564+
if (node.tagName === 'ION-PICKER-COLUMN-OPTION' && !node.disabled && stride === 0) {
565+
return node;
566+
}
567+
568+
nextNode = node;
569+
570+
// Use previousElementSibling instead of previousSibling to avoid text/comment nodes
571+
node = node.previousElementSibling as HTMLIonPickerColumnOptionElement | null;
572+
}
573+
574+
return nextNode;
575+
};
576+
577+
private onKeyDown = (ev: KeyboardEvent) => {
578+
/**
579+
* The below operations should be inverted when running on a mobile device.
580+
* For example, swiping up will dispatch an "ArrowUp" event. On desktop,
581+
* this should cause the previous option to be selected. On mobile, swiping
582+
* up causes a view to scroll down. As a result, swiping up on mobile should
583+
* cause the next option to be selected. The Home/End operations remain
584+
* unchanged because those always represent the first/last options, respectively.
585+
*/
586+
const mobile = isPlatform('mobile');
587+
let newOption: HTMLIonPickerColumnOptionElement | null = null;
588+
switch (ev.key) {
589+
case 'ArrowDown':
590+
newOption = mobile ? this.findPreviousOption() : this.findNextOption();
591+
break;
592+
case 'ArrowUp':
593+
newOption = mobile ? this.findNextOption() : this.findPreviousOption();
594+
break;
595+
case 'PageUp':
596+
newOption = mobile ? this.findNextOption(5) : this.findPreviousOption(5);
597+
break;
598+
case 'PageDown':
599+
newOption = mobile ? this.findPreviousOption(5) : this.findNextOption(5);
600+
break;
601+
case 'Home':
602+
/**
603+
* There is no guarantee that the first child will be an ion-picker-column-option,
604+
* so we do not use firstElementChild.
605+
*/
606+
newOption = this.el.querySelector<HTMLIonPickerColumnOptionElement>('ion-picker-column-option:first-of-type');
607+
break;
608+
case 'End':
609+
/**
610+
* There is no guarantee that the last child will be an ion-picker-column-option,
611+
* so we do not use lastElementChild.
612+
*/
613+
newOption = this.el.querySelector<HTMLIonPickerColumnOptionElement>('ion-picker-column-option:last-of-type');
614+
break;
615+
default:
616+
break;
617+
}
618+
619+
if (newOption !== null) {
620+
this.value = newOption.value;
621+
622+
// This stops any default browser behavior such as scrolling
623+
ev.preventDefault();
624+
}
625+
};
626+
627+
/**
628+
* Utility to generate the correct text for aria-valuetext.
629+
*/
630+
private getOptionValueText = (el?: HTMLIonPickerColumnOptionElement) => {
631+
return el ? el.getAttribute('aria-label') ?? el.innerText : '';
632+
};
633+
634+
/**
635+
* Render an element that overlays the column. This element is for assistive
636+
* tech to allow users to navigate the column up/down. This element should receive
637+
* focus as it listens for synthesized keyboard events as required by the
638+
* slider role: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/slider_role
639+
*/
640+
private renderAssistiveFocusable = () => {
641+
const { activeItem } = this;
642+
const valueText = this.getOptionValueText(activeItem);
643+
644+
/**
645+
* When using the picker, the valuetext provides important context that valuenow
646+
* does not. Additionally, using non-zero valuemin/valuemax values can cause
647+
* WebKit to incorrectly announce numeric valuetext values (such as a year
648+
* like "2024") as percentages: https://bugs.webkit.org/show_bug.cgi?id=273126
649+
*/
650+
return (
651+
<div
652+
ref={(el) => (this.assistiveFocusable = el)}
653+
class="assistive-focusable"
654+
role="slider"
655+
tabindex={this.disabled ? undefined : 0}
656+
aria-label={this.ariaLabel}
657+
aria-valuemin={0}
658+
aria-valuemax={0}
659+
aria-valuenow={0}
660+
aria-valuetext={valueText}
661+
aria-orientation="vertical"
662+
onKeyDown={(ev) => this.onKeyDown(ev)}
663+
></div>
664+
);
665+
};
666+
484667
render() {
485668
const { color, disabled, isActive, numericInput } = this;
486669
const mode = getIonMode(this);
@@ -494,10 +677,11 @@ export class PickerColumn implements ComponentInterface {
494677
['picker-column-disabled']: disabled,
495678
})}
496679
>
680+
{this.renderAssistiveFocusable()}
497681
<slot name="prefix"></slot>
498682
<div
683+
aria-hidden="true"
499684
class="picker-opts"
500-
tabindex={disabled ? undefined : 0}
501685
ref={(el) => {
502686
this.scrollEl = el;
503687
}}

0 commit comments

Comments
 (0)