@@ -31,6 +31,15 @@ export class PickerColumn implements ComponentInterface {
31
31
private isColumnVisible = false ;
32
32
private parentEl ?: HTMLIonPickerElement | null ;
33
33
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
+ }
34
43
35
44
@State ( ) isActive = false ;
36
45
@@ -206,6 +215,10 @@ export class PickerColumn implements ComponentInterface {
206
215
}
207
216
}
208
217
218
+ connectedCallback ( ) {
219
+ this . ariaLabel = this . el . getAttribute ( 'aria-label' ) ?? 'Select a value' ;
220
+ }
221
+
209
222
private centerPickerItemInView = ( target : HTMLElement , smooth = true , canExitInputMode = true ) => {
210
223
const { isColumnVisible, scrollEl } = this ;
211
224
@@ -222,6 +235,7 @@ export class PickerColumn implements ComponentInterface {
222
235
* of these can cause a scroll to occur.
223
236
*/
224
237
this . canExitInputMode = canExitInputMode ;
238
+ this . updateValueTextOnScroll = false ;
225
239
scrollEl . scroll ( {
226
240
top,
227
241
left : 0 ,
@@ -396,8 +410,24 @@ export class PickerColumn implements ComponentInterface {
396
410
activeEl = newActiveElement ;
397
411
this . setPickerItemActiveState ( newActiveElement , true ) ;
398
412
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
+
399
428
timeout = setTimeout ( ( ) => {
400
429
this . isScrolling = false ;
430
+ this . updateValueTextOnScroll = true ;
401
431
enableHaptics && hapticSelectionEnd ( ) ;
402
432
403
433
/**
@@ -481,6 +511,159 @@ export class PickerColumn implements ComponentInterface {
481
511
} ) ;
482
512
}
483
513
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
+
484
667
render ( ) {
485
668
const { color, disabled, isActive, numericInput } = this ;
486
669
const mode = getIonMode ( this ) ;
@@ -494,10 +677,11 @@ export class PickerColumn implements ComponentInterface {
494
677
[ 'picker-column-disabled' ] : disabled ,
495
678
} ) }
496
679
>
680
+ { this . renderAssistiveFocusable ( ) }
497
681
< slot name = "prefix" > </ slot >
498
682
< div
683
+ aria-hidden = "true"
499
684
class = "picker-opts"
500
- tabindex = { disabled ? undefined : 0 }
501
685
ref = { ( el ) => {
502
686
this . scrollEl = el ;
503
687
} }
0 commit comments