@ -21,12 +21,11 @@ function ariaDropdownFn(...args) {
// it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks.
const needDelegate = ( ! args . length || typeof args [ 0 ] !== 'string' ) ;
for ( const el of this ) {
const $dropdown = $ ( el ) ;
if ( ! el [ ariaPatchKey ] ) {
attachInit ( $dropdown ) ;
attachInit ( el ) ;
}
if ( needDelegate ) {
delegateOne ( $dropdown ) ;
delegateOne ( $ ( el ) ) ;
}
}
return ret ;
@ -40,17 +39,23 @@ function updateMenuItem(dropdown, item) {
item . setAttribute ( 'tabindex' , '-1' ) ;
for ( const el of item . querySelectorAll ( 'a, input, button' ) ) el . setAttribute ( 'tabindex' , '-1' ) ;
}
// make the label item and its "delete icon" has correct aria attributes
function updateSelectionLabel ( $label ) {
/ * *
* make the label item and its "delete icon" have correct aria attributes
* @ param { HTMLElement } label
* /
function updateSelectionLabel ( label ) {
// the "label" is like this: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
if ( ! $label . attr ( 'id' ) ) $label . attr ( 'id' , generateAriaId ( ) ) ;
$label . attr ( 'tabindex' , '-1' ) ;
$label . find ( '.delete.icon' ) . attr ( {
'aria-hidden' : 'false' ,
'aria-label' : window . config . i18n . remove _label _str . replace ( '%s' , $label . attr ( 'data-value' ) ) ,
'role' : 'button' ,
} ) ;
if ( ! label . id ) {
label . id = generateAriaId ( ) ;
}
label . tabIndex = - 1 ;
const deleteIcon = label . querySelector ( '.delete.icon' ) ;
if ( deleteIcon ) {
deleteIcon . setAttribute ( 'aria-hidden' , 'false' ) ;
deleteIcon . setAttribute ( 'aria-label' , window . config . i18n . remove _label _str . replace ( '%s' , label . getAttribute ( 'data-value' ) ) ) ;
deleteIcon . setAttribute ( 'role' , 'button' ) ;
}
}
// delegate the dropdown's template functions and callback functions to add aria attributes.
@ -86,43 +91,44 @@ function delegateOne($dropdown) {
const dropdownOnLabelCreateOld = dropdownCall ( 'setting' , 'onLabelCreate' ) ;
dropdownCall ( 'setting' , 'onLabelCreate' , function ( value , text ) {
const $label = dropdownOnLabelCreateOld . call ( this , value , text ) ;
updateSelectionLabel ( $label ) ;
updateSelectionLabel ( $label [ 0 ] ) ;
return $label ;
} ) ;
}
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
function attachStaticElements ( $dropdown , $focusable , $menu ) {
const dropdown = $dropdown [ 0 ] ;
function attachStaticElements ( dropdown , focusable , menu ) {
// prepare static dropdown menu list popup
if ( ! $menu . attr ( 'id' ) ) $menu . attr ( 'id' , generateAriaId ( ) ) ;
$menu . find ( '> .item' ) . each ( ( _ , item ) => updateMenuItem ( dropdown , item ) ) ;
if ( ! menu . id ) {
menu . id = generateAriaId ( ) ;
}
$ ( menu ) . find ( '> .item' ) . each ( ( _ , item ) => updateMenuItem ( dropdown , item ) ) ;
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
$menu . attr ( 'role' , dropdown [ ariaPatchKey ] . listPopupRole ) ;
menu . setAttribute ( 'role' , dropdown [ ariaPatchKey ] . listPopupRole ) ;
// prepare selection label items
$dropdown . find ( '.ui.label' ) . each ( ( _ , label ) => updateSelectionLabel ( $ ( label ) ) ) ;
for ( const label of dropdown . querySelectorAll ( '.ui.label' ) ) {
updateSelectionLabel ( label ) ;
}
// make the primary element (focusable) aria-friendly
$focusable . attr ( {
'role' : $focusable . attr ( 'role' ) ? ? dropdown [ ariaPatchKey ] . focusableRole ,
'aria-haspopup' : dropdown [ ariaPatchKey ] . listPopupRole ,
'aria-controls' : $menu . attr ( 'id' ) ,
'aria-expanded' : 'false' ,
} ) ;
focusable . setAttribute ( 'role' , focusable . getAttribute ( 'role' ) ? ? dropdown [ ariaPatchKey ] . focusableRole ) ;
focusable . setAttribute ( 'aria-haspopup' , dropdown [ ariaPatchKey ] . listPopupRole ) ;
focusable . setAttribute ( 'aria-controls' , menu . id ) ;
focusable . setAttribute ( 'aria-expanded' , 'false' ) ;
// use tooltip's content as aria-label if there is no aria-label
const tooltipContent = $ dropdown. attr ( 'data-tooltip-content' ) ;
if ( tooltipContent && ! $ dropdown. attr ( 'aria-label' ) ) {
$ dropdown. attr ( 'aria-label' , tooltipContent ) ;
const tooltipContent = dropdown . getAttribute ( 'data-tooltip-content' ) ;
if ( tooltipContent && ! dropdown . getAttribute ( 'aria-label' ) ) {
dropdown . setAttribute ( 'aria-label' , tooltipContent ) ;
}
}
function attachInit ( $dropdown ) {
const dropdown = $dropdown [ 0 ] ;
function attachInit ( dropdown ) {
dropdown [ ariaPatchKey ] = { } ;
if ( $ dropdown. hasClas s( 'custom' ) ) return ;
if ( dropdown . classList . contain s( 'custom' ) ) return ;
// Dropdown has 2 different focusing behaviors
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
@ -139,64 +145,66 @@ function attachInit($dropdown) {
// TODO: multiple selection is only partially supported. Check and test them one by one in the future.
const $ textSearch = $ dropdown. find ( 'input.search' ) . eq ( 0 ) ;
const $ focusable = $textSearch . length ? $textSearch : $ dropdown; // the primary element for focus, see comment above
if ( ! $ focusable. length ) return ;
const textSearch = dropdown . querySelector ( 'input.search' ) ;
const focusable = textSearch || dropdown ; // the primary element for focus, see comment above
if ( ! focusable ) return ;
// as a combobox, the input should not have autocomplete by default
if ( $ textSearch. length && ! $ textSearch. attr ( 'autocomplete' ) ) {
$ textSearch. attr ( 'autocomplete' , 'off' ) ;
if ( textSearch && ! textSearch . getAttribute ( 'autocomplete' ) ) {
textSearch . setAttribute ( 'autocomplete' , 'off' ) ;
}
let $ menu = $dropdown . find ( '> .menu' ) ;
if ( ! $ menu. length ) {
let menu = $ ( dropdown ) . find ( '> .menu' ) [ 0 ] ;
if ( ! menu ) {
// 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
$menu = $ ( '<div class="menu"></div>' ) . appendTo ( $dropdown ) ;
menu = document . createElement ( 'div' ) ;
menu . classList . add ( 'menu' ) ;
dropdown . append ( menu ) ;
}
// There are 2 possible solutions about the role: combobox or menu.
// The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
const isComboBox = $ dropdown. find ( 'input' ) . length > 0 ;
const isComboBox = dropdown . querySelectorAll ( 'input' ) . length > 0 ;
dropdown [ ariaPatchKey ] . focusableRole = isComboBox ? 'combobox' : 'menu' ;
dropdown [ ariaPatchKey ] . listPopupRole = isComboBox ? 'listbox' : '' ;
dropdown [ ariaPatchKey ] . listItemRole = isComboBox ? 'option' : 'menuitem' ;
attachDomEvents ( $ dropdown, $ focusable, $ menu) ;
attachStaticElements ( $ dropdown, $ focusable, $ menu) ;
attachDomEvents ( dropdown , focusable , menu ) ;
attachStaticElements ( dropdown , focusable , menu ) ;
}
function attachDomEvents ( $dropdown , $focusable , $menu ) {
const dropdown = $dropdown [ 0 ] ;
function attachDomEvents ( dropdown , focusable , menu ) {
// when showing, it has class: ".animating.in"
// when hiding, it has class: ".visible.animating.out"
const isMenuVisible = ( ) => ( $ menu. hasClas s( 'visible' ) && ! $ menu. hasClas s( 'out' ) ) || $ menu. hasClas s( 'in' ) ;
const isMenuVisible = ( ) => ( menu . classList . contain s( 'visible' ) && ! menu . classList . contain s( 'out' ) ) || menu . classList . contain s( 'in' ) ;
// update aria attributes according to current active/selected item
const refreshAriaActiveItem = ( ) => {
const menuVisible = isMenuVisible ( ) ;
$ focusable. attr ( 'aria-expanded' , menuVisible ? 'true' : 'false' ) ;
focusable . setAttribute ( 'aria-expanded' , menuVisible ? 'true' : 'false' ) ;
// if there is an active item, use it (the user is navigating between items)
// otherwise use the "selected" for combobox (for the last selected item)
const $active = $menu . find ( '> .item.active, > .item.selected' ) ;
const active = $ ( menu ) . find ( '> .item.active, > .item.selected' ) [ 0 ] ;
if ( ! active ) return ;
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
if ( menuVisible ) {
$ focusable. attr ( 'aria-activedescendant' , $ active. attr ( ' id' ) ) ;
focusable . setAttribute ( 'aria-activedescendant' , active . id ) ;
} else if ( dropdown [ ariaPatchKey ] . listPopupRole === 'menu' ) {
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
$ focusable. removeAttr ( 'aria-activedescendant' ) ;
$ active. removeClass ( 'active' ) . removeClass ( 'selected' ) ;
focusable . removeAttribute ( 'aria-activedescendant' ) ;
active . classList . remove ( 'active' , 'selected' ) ;
}
} ;
$ dropdown. on ( 'keydown' , ( e ) => {
dropdown . addEventListener ( 'keydown' , ( e ) => {
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
if ( e . key === 'Enter' ) {
const dropdownCall = fomanticDropdownFn . bind ( $dropdown ) ;
const dropdownCall = fomanticDropdownFn . bind ( $ ( dropdown ) ) ;
let $item = dropdownCall ( 'get item' , dropdownCall ( 'get value' ) ) ;
if ( ! $item ) $item = $menu . find ( '> .item.selected' ) ; // when dropdown filters items by input, there is no "value", so query the "selected" item
if ( ! $item ) $item = $ ( menu ) . find ( '> .item.selected' ) ; // when dropdown filters items by input, there is no "value", so query the "selected" item
// if the selected item is clickable, then trigger the click event.
// we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
if ( $item && ( $item [ 0 ] . matches ( 'a' ) || $item . hasClass ( 'js-aria-clickable' ) ) ) $item [ 0 ] . click ( ) ;
@ -209,7 +217,7 @@ function attachDomEvents($dropdown, $focusable, $menu) {
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
const deferredRefreshAriaActiveItem = ( delay = 0 ) => { setTimeout ( refreshAriaActiveItem , delay ) } ;
dropdown [ ariaPatchKey ] . deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem ;
$ dropdown. on ( 'keyup' , ( e ) => { if ( e . key . startsWith ( 'Arrow' ) ) deferredRefreshAriaActiveItem ( ) ; } ) ;
dropdown . addEventListener ( 'keyup' , ( e ) => { if ( e . key . startsWith ( 'Arrow' ) ) deferredRefreshAriaActiveItem ( ) ; } ) ;
// if the dropdown has been opened by focus, do not trigger the next click event again.
// otherwise the dropdown will be closed immediately, especially on Android with TalkBack