11import $ from 'jquery' ;
2+ import { ariaPatchKey , generateAriaId } from './aria-base.js' ;
23
3- let ariaIdCounter = 0 ;
4+ const fomanticDropdownFn = $ . fn . dropdown ;
45
5- function generateAriaId ( ) {
6- return `_aria_auto_id_${ ariaIdCounter ++ } ` ;
6+ // use our own `$().dropdown` function to patch Fomantic's dropdown module
7+ export function initAriaDropdownPatch ( ) {
8+ if ( $ . fn . dropdown === ariaDropdownFn ) throw new Error ( 'initAriaDropdownPatch could only be called once' ) ;
9+ $ . fn . dropdown = ariaDropdownFn ;
10+ ariaDropdownFn . settings = fomanticDropdownFn . settings ;
711}
812
9- function attachOneDropdownAria ( $dropdown ) {
10- if ( $dropdown . attr ( 'data-aria-attached' ) || $dropdown . hasClass ( 'custom' ) ) return ;
11- $dropdown . attr ( 'data-aria-attached' , 1 ) ;
13+ // the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and:
14+ // * it does the one-time attaching on the first call
15+ // * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes
16+ function ariaDropdownFn ( ...args ) {
17+ const ret = fomanticDropdownFn . apply ( this , args ) ;
18+
19+ // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument,
20+ // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks.
21+ const needDelegate = ( ! args . length || typeof args [ 0 ] !== 'string' ) ;
22+ for ( const el of this ) {
23+ const $dropdown = $ ( el ) ;
24+ if ( ! el [ ariaPatchKey ] ) {
25+ attachInit ( $dropdown ) ;
26+ }
27+ if ( needDelegate ) {
28+ delegateOne ( $dropdown ) ;
29+ }
30+ }
31+ return ret ;
32+ }
33+
34+ // make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
35+ // the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
36+ function updateMenuItem ( dropdown , item ) {
37+ if ( ! item . id ) item . id = generateAriaId ( ) ;
38+ item . setAttribute ( 'role' , dropdown [ ariaPatchKey ] . listItemRole ) ;
39+ item . setAttribute ( 'tabindex' , '-1' ) ;
40+ for ( const a of item . querySelectorAll ( 'a' ) ) a . setAttribute ( 'tabindex' , '-1' ) ;
41+ }
42+
43+ // make the label item and its "delete icon" has correct aria attributes
44+ function updateSelectionLabel ( $label ) {
45+ // the "label" is like this: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
46+ if ( ! $label . attr ( 'id' ) ) $label . attr ( 'id' , generateAriaId ( ) ) ;
47+ $label . attr ( 'tabindex' , '-1' ) ;
48+ $label . find ( '.delete.icon' ) . attr ( {
49+ 'aria-hidden' : 'false' ,
50+ 'aria-label' : window . config . i18n . remove_label_str . replace ( '%s' , $label . attr ( 'data-value' ) ) ,
51+ 'role' : 'button' ,
52+ } ) ;
53+ }
54+
55+ // delegate the dropdown's template functions and callback functions to add aria attributes.
56+ function delegateOne ( $dropdown ) {
57+ const dropdownCall = fomanticDropdownFn . bind ( $dropdown ) ;
58+
59+ // the "template" functions are used for dynamic creation (eg: AJAX)
60+ const dropdownTemplates = { ...dropdownCall ( 'setting' , 'templates' ) , t : performance . now ( ) } ;
61+ const dropdownTemplatesMenuOld = dropdownTemplates . menu ;
62+ dropdownTemplates . menu = function ( response , fields , preserveHTML , className ) {
63+ // when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
64+ const menuItems = dropdownTemplatesMenuOld ( response , fields , preserveHTML , className ) ;
65+ const $wrapper = $ ( '<div>' ) . append ( menuItems ) ;
66+ const $items = $wrapper . find ( '> .item' ) ;
67+ $items . each ( ( _ , item ) => updateMenuItem ( $dropdown [ 0 ] , item ) ) ;
68+ $dropdown [ 0 ] [ ariaPatchKey ] . deferredRefreshAriaActiveItem ( ) ;
69+ return $wrapper . html ( ) ;
70+ } ;
71+ dropdownCall ( 'setting' , 'templates' , dropdownTemplates ) ;
72+
73+ // the `onLabelCreate` is used to add necessary aria attributes for dynamically created selection labels
74+ const dropdownOnLabelCreateOld = dropdownCall ( 'setting' , 'onLabelCreate' ) ;
75+ dropdownCall ( 'setting' , 'onLabelCreate' , function ( value , text ) {
76+ const $label = dropdownOnLabelCreateOld . call ( this , value , text ) ;
77+ updateSelectionLabel ( $label ) ;
78+ return $label ;
79+ } ) ;
80+ }
81+
82+ // for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
83+ function attachStaticElements ( $dropdown , $focusable , $menu ) {
84+ const dropdown = $dropdown [ 0 ] ;
85+
86+ // prepare static dropdown menu list popup
87+ if ( ! $menu . attr ( 'id' ) ) $menu . attr ( 'id' , generateAriaId ( ) ) ;
88+ $menu . find ( '> .item' ) . each ( ( _ , item ) => updateMenuItem ( dropdown , item ) ) ;
89+ // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
90+ $menu . attr ( 'role' , dropdown [ ariaPatchKey ] . listPopupRole ) ;
91+
92+ // prepare selection label items
93+ $dropdown . find ( '.ui.label' ) . each ( ( _ , label ) => updateSelectionLabel ( $ ( label ) ) ) ;
94+
95+ // make the primary element (focusable) aria-friendly
96+ $focusable . attr ( {
97+ 'role' : $focusable . attr ( 'role' ) ?? dropdown [ ariaPatchKey ] . focusableRole ,
98+ 'aria-haspopup' : dropdown [ ariaPatchKey ] . listPopupRole ,
99+ 'aria-controls' : $menu . attr ( 'id' ) ,
100+ 'aria-expanded' : 'false' ,
101+ } ) ;
102+
103+ // use tooltip's content as aria-label if there is no aria-label
104+ if ( $dropdown . hasClass ( 'tooltip' ) && $dropdown . attr ( 'data-content' ) && ! $dropdown . attr ( 'aria-label' ) ) {
105+ $dropdown . attr ( 'aria-label' , $dropdown . attr ( 'data-content' ) ) ;
106+ }
107+ }
108+
109+ function attachInit ( $dropdown ) {
110+ const dropdown = $dropdown [ 0 ] ;
111+ dropdown [ ariaPatchKey ] = { } ;
112+ if ( $dropdown . hasClass ( 'custom' ) ) return ;
12113
13114 // Dropdown has 2 different focusing behaviors
14115 // * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
@@ -23,71 +124,39 @@ function attachOneDropdownAria($dropdown) {
23124 // - if the menu item is clickable (eg: <a>), then trigger the click event
24125 // - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu
25126
26- // TODO: multiple selection is not supported yet .
127+ // TODO: multiple selection is only partially supported. Check and test them one by one in the future .
27128
28129 const $textSearch = $dropdown . find ( 'input.search' ) . eq ( 0 ) ;
29130 const $focusable = $textSearch . length ? $textSearch : $dropdown ; // the primary element for focus, see comment above
30131 if ( ! $focusable . length ) return ;
31132
133+ let $menu = $dropdown . find ( '> .menu' ) ;
134+ if ( ! $menu . length ) {
135+ // some "multiple selection" dropdowns don't have a static menu element in HTML, we need to pre-create it to make it have correct aria attributes
136+ $menu = $ ( '<div class="menu"></div>' ) . appendTo ( $dropdown ) ;
137+ }
138+
32139 // There are 2 possible solutions about the role: combobox or menu.
33140 // The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
34141 // Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
35142 const isComboBox = $dropdown . find ( 'input' ) . length > 0 ;
36143
37- const focusableRole = isComboBox ? 'combobox' : 'button' ;
38- const listPopupRole = isComboBox ? 'listbox' : 'menu' ;
39- const listItemRole = isComboBox ? 'option' : 'menuitem' ;
144+ dropdown [ ariaPatchKey ] . focusableRole = isComboBox ? 'combobox' : 'button' ;
145+ dropdown [ ariaPatchKey ] . listPopupRole = isComboBox ? 'listbox' : 'menu' ;
146+ dropdown [ ariaPatchKey ] . listItemRole = isComboBox ? 'option' : 'menuitem' ;
40147
41- // make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
42- // the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
43- function prepareMenuItem ( $item ) {
44- if ( ! $item . attr ( 'id' ) ) $item . attr ( 'id' , generateAriaId ( ) ) ;
45- $item . attr ( { 'role' : listItemRole , 'tabindex' : '-1' } ) ;
46- $item . find ( 'a' ) . attr ( 'tabindex' , '-1' ) ;
47- }
48-
49- // delegate the dropdown's template function to add aria attributes.
50- // the "template" functions are used for dynamic creation (eg: AJAX)
51- const dropdownTemplates = { ...$dropdown . dropdown ( 'setting' , 'templates' ) } ;
52- const dropdownTemplatesMenuOld = dropdownTemplates . menu ;
53- dropdownTemplates . menu = function ( response , fields , preserveHTML , className ) {
54- // when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
55- const menuItems = dropdownTemplatesMenuOld ( response , fields , preserveHTML , className ) ;
56- const $wrapper = $ ( '<div>' ) . append ( menuItems ) ;
57- const $items = $wrapper . find ( '> .item' ) ;
58- $items . each ( ( _ , item ) => prepareMenuItem ( $ ( item ) ) ) ;
59- return $wrapper . html ( ) ;
60- } ;
61- $dropdown . dropdown ( 'setting' , 'templates' , dropdownTemplates ) ;
62-
63- // use tooltip's content as aria-label if there is no aria-label
64- if ( $dropdown . hasClass ( 'tooltip' ) && $dropdown . attr ( 'data-content' ) && ! $dropdown . attr ( 'aria-label' ) ) {
65- $dropdown . attr ( 'aria-label' , $dropdown . attr ( 'data-content' ) ) ;
66- }
67-
68- // prepare dropdown menu list popup
69- const $menu = $dropdown . find ( '> .menu' ) ;
70- if ( ! $menu . attr ( 'id' ) ) $menu . attr ( 'id' , generateAriaId ( ) ) ;
71- $menu . find ( '> .item' ) . each ( ( _ , item ) => {
72- prepareMenuItem ( $ ( item ) ) ;
73- } ) ;
74- // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
75- $menu . attr ( 'role' , listPopupRole ) ;
76-
77- // make the primary element (focusable) aria-friendly
78- $focusable . attr ( {
79- 'role' : $focusable . attr ( 'role' ) ?? focusableRole ,
80- 'aria-haspopup' : listPopupRole ,
81- 'aria-controls' : $menu . attr ( 'id' ) ,
82- 'aria-expanded' : 'false' ,
83- } ) ;
148+ attachDomEvents ( $dropdown , $focusable , $menu ) ;
149+ attachStaticElements ( $dropdown , $focusable , $menu ) ;
150+ }
84151
152+ function attachDomEvents ( $dropdown , $focusable , $menu ) {
153+ const dropdown = $dropdown [ 0 ] ;
85154 // when showing, it has class: ".animating.in"
86155 // when hiding, it has class: ".visible.animating.out"
87156 const isMenuVisible = ( ) => ( $menu . hasClass ( 'visible' ) && ! $menu . hasClass ( 'out' ) ) || $menu . hasClass ( 'in' ) ;
88157
89158 // update aria attributes according to current active/selected item
90- const refreshAria = ( ) => {
159+ const refreshAriaActiveItem = ( ) => {
91160 const menuVisible = isMenuVisible ( ) ;
92161 $focusable . attr ( 'aria-expanded' , menuVisible ? 'true' : 'false' ) ;
93162
@@ -97,7 +166,7 @@ function attachOneDropdownAria($dropdown) {
97166 // if the popup is visible and has an active/selected item, use its id as aria-activedescendant
98167 if ( menuVisible ) {
99168 $focusable . attr ( 'aria-activedescendant' , $active . attr ( 'id' ) ) ;
100- } else if ( ! isComboBox ) {
169+ } else if ( dropdown [ ariaPatchKey ] . listPopupRole === 'menu' ) {
101170 // for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
102171 $focusable . removeAttr ( 'aria-activedescendant' ) ;
103172 $active . removeClass ( 'active' ) . removeClass ( 'selected' ) ;
@@ -107,7 +176,8 @@ function attachOneDropdownAria($dropdown) {
107176 $dropdown . on ( 'keydown' , ( e ) => {
108177 // here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
109178 if ( e . key === 'Enter' ) {
110- let $item = $dropdown . dropdown ( 'get item' , $dropdown . dropdown ( 'get value' ) ) ;
179+ const dropdownCall = fomanticDropdownFn . bind ( $dropdown ) ;
180+ let $item = dropdownCall ( 'get item' , dropdownCall ( 'get value' ) ) ;
111181 if ( ! $item ) $item = $menu . find ( '> .item.selected' ) ; // when dropdown filters items by input, there is no "value", so query the "selected" item
112182 // if the selected item is clickable, then trigger the click event.
113183 // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
@@ -119,35 +189,36 @@ function attachOneDropdownAria($dropdown) {
119189 // do not return any value, jQuery has return-value related behaviors.
120190 // when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
121191 // without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
122- const deferredRefreshAria = ( delay = 0 ) => { setTimeout ( refreshAria , delay ) } ;
123- $dropdown . on ( 'keyup' , ( e ) => { if ( e . key . startsWith ( 'Arrow' ) ) deferredRefreshAria ( ) ; } ) ;
192+ const deferredRefreshAriaActiveItem = ( delay = 0 ) => { setTimeout ( refreshAriaActiveItem , delay ) } ;
193+ dropdown [ ariaPatchKey ] . deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem ;
194+ $dropdown . on ( 'keyup' , ( e ) => { if ( e . key . startsWith ( 'Arrow' ) ) deferredRefreshAriaActiveItem ( ) ; } ) ;
124195
125196 // if the dropdown has been opened by focus, do not trigger the next click event again.
126197 // otherwise the dropdown will be closed immediately, especially on Android with TalkBack
127198 // * desktop event sequence: mousedown -> focus -> mouseup -> click
128199 // * mobile event sequence: focus -> mousedown -> mouseup -> click
129200 // Fomantic may stop propagation of blur event, use capture to make sure we can still get the event
130201 let ignoreClickPreEvents = 0 , ignoreClickPreVisible = 0 ;
131- $ dropdown[ 0 ] . addEventListener ( 'mousedown' , ( ) => {
202+ dropdown . addEventListener ( 'mousedown' , ( ) => {
132203 ignoreClickPreVisible += isMenuVisible ( ) ? 1 : 0 ;
133204 ignoreClickPreEvents ++ ;
134205 } , true ) ;
135- $ dropdown[ 0 ] . addEventListener ( 'focus' , ( ) => {
206+ dropdown . addEventListener ( 'focus' , ( ) => {
136207 ignoreClickPreVisible += isMenuVisible ( ) ? 1 : 0 ;
137208 ignoreClickPreEvents ++ ;
138- deferredRefreshAria ( ) ;
209+ deferredRefreshAriaActiveItem ( ) ;
139210 } , true ) ;
140- $ dropdown[ 0 ] . addEventListener ( 'blur' , ( ) => {
211+ dropdown . addEventListener ( 'blur' , ( ) => {
141212 ignoreClickPreVisible = ignoreClickPreEvents = 0 ;
142- deferredRefreshAria ( 100 ) ;
213+ deferredRefreshAriaActiveItem ( 100 ) ;
143214 } , true ) ;
144- $ dropdown[ 0 ] . addEventListener ( 'mouseup' , ( ) => {
215+ dropdown . addEventListener ( 'mouseup' , ( ) => {
145216 setTimeout ( ( ) => {
146217 ignoreClickPreVisible = ignoreClickPreEvents = 0 ;
147- deferredRefreshAria ( 100 ) ;
218+ deferredRefreshAriaActiveItem ( 100 ) ;
148219 } , 0 ) ;
149220 } , true ) ;
150- $ dropdown[ 0 ] . addEventListener ( 'click' , ( e ) => {
221+ dropdown . addEventListener ( 'click' , ( e ) => {
151222 if ( isMenuVisible ( ) &&
152223 ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
153224 ignoreClickPreEvents === 2 // the click event is related to mousedown+focus
@@ -157,24 +228,3 @@ function attachOneDropdownAria($dropdown) {
157228 ignoreClickPreEvents = ignoreClickPreVisible = 0 ;
158229 } , true ) ;
159230}
160-
161- export function attachDropdownAria ( $dropdowns ) {
162- $dropdowns . each ( ( _ , e ) => attachOneDropdownAria ( $ ( e ) ) ) ;
163- }
164-
165- export function attachCheckboxAria ( $checkboxes ) {
166- $checkboxes . checkbox ( ) ;
167-
168- // Fomantic UI checkbox needs to be something like: <div class="ui checkbox"><label /><input /></div>
169- // It doesn't work well with <label><input />...</label>
170- // To make it work with aria, the "id"/"for" attributes are necessary, so add them automatically if missing.
171- // In the future, refactor to use native checkbox directly, then this patch could be removed.
172- for ( const el of $checkboxes ) {
173- const label = el . querySelector ( 'label' ) ;
174- const input = el . querySelector ( 'input' ) ;
175- if ( ! label || ! input || input . getAttribute ( 'id' ) ) continue ;
176- const id = generateAriaId ( ) ;
177- input . setAttribute ( 'id' , id ) ;
178- label . setAttribute ( 'for' , id ) ;
179- }
180- }
0 commit comments