1
1
import $ from 'jquery' ;
2
+ import { ariaPatchKey , generateAriaId } from './aria-base.js' ;
2
3
3
- let ariaIdCounter = 0 ;
4
+ const fomanticDropdownFn = $ . fn . dropdown ;
4
5
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 ;
7
11
}
8
12
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 ;
12
113
13
114
// Dropdown has 2 different focusing behaviors
14
115
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
@@ -23,71 +124,39 @@ function attachOneDropdownAria($dropdown) {
23
124
// - if the menu item is clickable (eg: <a>), then trigger the click event
24
125
// - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu
25
126
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 .
27
128
28
129
const $textSearch = $dropdown . find ( 'input.search' ) . eq ( 0 ) ;
29
130
const $focusable = $textSearch . length ? $textSearch : $dropdown ; // the primary element for focus, see comment above
30
131
if ( ! $focusable . length ) return ;
31
132
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
+
32
139
// There are 2 possible solutions about the role: combobox or menu.
33
140
// The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
34
141
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
35
142
const isComboBox = $dropdown . find ( 'input' ) . length > 0 ;
36
143
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' ;
40
147
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
+ }
84
151
152
+ function attachDomEvents ( $dropdown , $focusable , $menu ) {
153
+ const dropdown = $dropdown [ 0 ] ;
85
154
// when showing, it has class: ".animating.in"
86
155
// when hiding, it has class: ".visible.animating.out"
87
156
const isMenuVisible = ( ) => ( $menu . hasClass ( 'visible' ) && ! $menu . hasClass ( 'out' ) ) || $menu . hasClass ( 'in' ) ;
88
157
89
158
// update aria attributes according to current active/selected item
90
- const refreshAria = ( ) => {
159
+ const refreshAriaActiveItem = ( ) => {
91
160
const menuVisible = isMenuVisible ( ) ;
92
161
$focusable . attr ( 'aria-expanded' , menuVisible ? 'true' : 'false' ) ;
93
162
@@ -97,7 +166,7 @@ function attachOneDropdownAria($dropdown) {
97
166
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
98
167
if ( menuVisible ) {
99
168
$focusable . attr ( 'aria-activedescendant' , $active . attr ( 'id' ) ) ;
100
- } else if ( ! isComboBox ) {
169
+ } else if ( dropdown [ ariaPatchKey ] . listPopupRole === 'menu' ) {
101
170
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
102
171
$focusable . removeAttr ( 'aria-activedescendant' ) ;
103
172
$active . removeClass ( 'active' ) . removeClass ( 'selected' ) ;
@@ -107,7 +176,8 @@ function attachOneDropdownAria($dropdown) {
107
176
$dropdown . on ( 'keydown' , ( e ) => {
108
177
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
109
178
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' ) ) ;
111
181
if ( ! $item ) $item = $menu . find ( '> .item.selected' ) ; // when dropdown filters items by input, there is no "value", so query the "selected" item
112
182
// if the selected item is clickable, then trigger the click event.
113
183
// 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) {
119
189
// do not return any value, jQuery has return-value related behaviors.
120
190
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
121
191
// 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 ( ) ; } ) ;
124
195
125
196
// if the dropdown has been opened by focus, do not trigger the next click event again.
126
197
// otherwise the dropdown will be closed immediately, especially on Android with TalkBack
127
198
// * desktop event sequence: mousedown -> focus -> mouseup -> click
128
199
// * mobile event sequence: focus -> mousedown -> mouseup -> click
129
200
// Fomantic may stop propagation of blur event, use capture to make sure we can still get the event
130
201
let ignoreClickPreEvents = 0 , ignoreClickPreVisible = 0 ;
131
- $ dropdown[ 0 ] . addEventListener ( 'mousedown' , ( ) => {
202
+ dropdown . addEventListener ( 'mousedown' , ( ) => {
132
203
ignoreClickPreVisible += isMenuVisible ( ) ? 1 : 0 ;
133
204
ignoreClickPreEvents ++ ;
134
205
} , true ) ;
135
- $ dropdown[ 0 ] . addEventListener ( 'focus' , ( ) => {
206
+ dropdown . addEventListener ( 'focus' , ( ) => {
136
207
ignoreClickPreVisible += isMenuVisible ( ) ? 1 : 0 ;
137
208
ignoreClickPreEvents ++ ;
138
- deferredRefreshAria ( ) ;
209
+ deferredRefreshAriaActiveItem ( ) ;
139
210
} , true ) ;
140
- $ dropdown[ 0 ] . addEventListener ( 'blur' , ( ) => {
211
+ dropdown . addEventListener ( 'blur' , ( ) => {
141
212
ignoreClickPreVisible = ignoreClickPreEvents = 0 ;
142
- deferredRefreshAria ( 100 ) ;
213
+ deferredRefreshAriaActiveItem ( 100 ) ;
143
214
} , true ) ;
144
- $ dropdown[ 0 ] . addEventListener ( 'mouseup' , ( ) => {
215
+ dropdown . addEventListener ( 'mouseup' , ( ) => {
145
216
setTimeout ( ( ) => {
146
217
ignoreClickPreVisible = ignoreClickPreEvents = 0 ;
147
- deferredRefreshAria ( 100 ) ;
218
+ deferredRefreshAriaActiveItem ( 100 ) ;
148
219
} , 0 ) ;
149
220
} , true ) ;
150
- $ dropdown[ 0 ] . addEventListener ( 'click' , ( e ) => {
221
+ dropdown . addEventListener ( 'click' , ( e ) => {
151
222
if ( isMenuVisible ( ) &&
152
223
ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
153
224
ignoreClickPreEvents === 2 // the click event is related to mousedown+focus
@@ -157,24 +228,3 @@ function attachOneDropdownAria($dropdown) {
157
228
ignoreClickPreEvents = ignoreClickPreVisible = 0 ;
158
229
} , true ) ;
159
230
}
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