Skip to content

Commit 2ecf719

Browse files
authored
feat(modal): a11y support, tabbing in modal, close icon focus/keypress
This PR adds accessibility (a11y) support to the modal module add proper aria attributes make sure tabbing only takes place inside the modal and does not tab to fields hidden by the dimmer make a close button a button and tabbable as well I also separated the closeIcon from the previous close selector which was supposed to be able to also close the modal on each action button when the selector is enhanced. Reason for separation of closeIcon is the new ability to trigger the closeicon by space or enter which is needed as accessibility requirement and was not possible before. Attention The aria attributes are only applied when modal is called dynamically via JS attributes (means without an already prepared modal DOM structure). When using existing Modal DOM nodes, adding proper aria attributes/labels is still the job of the developer as Modal is not adjusting existing DOM node attributes (for example an id has to be created for aria-labelledby). However, the tabbing feature works independently of existing aria labels and was a general bug anyway.
1 parent 9088caa commit 2ecf719

File tree

2 files changed

+90
-25
lines changed

2 files changed

+90
-25
lines changed

src/definitions/modules/modal.js

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ $.fn.modal = function(parameters) {
6666

6767
$module = $(this),
6868
$context = $(settings.context),
69-
$close = $module.find(selector.close),
69+
$closeIcon = $module.find(selector.closeIcon),
70+
$inputs,
7071

7172
$allModals,
7273
$otherModals,
@@ -92,6 +93,7 @@ $.fn.modal = function(parameters) {
9293
module = {
9394

9495
initialize: function() {
96+
module.create.id();
9597
if(!$module.hasClass('modal')) {
9698
module.create.modal();
9799
if(!$.isFunction(settings.onHidden)) {
@@ -116,12 +118,13 @@ $.fn.modal = function(parameters) {
116118
$actions.empty();
117119
}
118120
settings.actions.forEach(function (el) {
119-
var icon = el[fields.icon] ? '<i class="' + module.helpers.deQuote(el[fields.icon]) + ' icon"></i>' : '',
121+
var icon = el[fields.icon] ? '<i '+(el[fields.text] ? 'aria-hidden="true"' : '')+' class="' + module.helpers.deQuote(el[fields.icon]) + ' icon"></i>' : '',
120122
text = module.helpers.escape(el[fields.text] || '', settings.preserveHTML),
121123
cls = module.helpers.deQuote(el[fields.class] || ''),
122124
click = el[fields.click] && $.isFunction(el[fields.click]) ? el[fields.click] : function () {};
123125
$actions.append($('<button/>', {
124126
html: icon + text,
127+
'aria-label': $('<div>'+(el[fields.text] || el[fields.icon] || '')+'</div>').text(),
125128
class: className.button + ' ' + cls,
126129
click: function () {
127130
if (click.call(element, $module) === false) {
@@ -135,7 +138,6 @@ $.fn.modal = function(parameters) {
135138
module.cache = {};
136139
module.verbose('Initializing dimmer', $context);
137140

138-
module.create.id();
139141
module.create.dimmer();
140142

141143
if ( settings.allowMultiple ) {
@@ -145,11 +147,9 @@ $.fn.modal = function(parameters) {
145147
$module.addClass('top aligned');
146148
}
147149
module.refreshModals();
148-
150+
module.refreshInputs();
149151
module.bind.events();
150-
if(settings.observeChanges) {
151-
module.observeChanges();
152-
}
152+
module.observeChanges();
153153
module.instantiate();
154154
if(settings.autoShow){
155155
module.show();
@@ -166,16 +166,20 @@ $.fn.modal = function(parameters) {
166166

167167
create: {
168168
modal: function() {
169-
$module = $('<div/>', {class: className.modal});
169+
$module = $('<div/>', {class: className.modal, role: 'dialog', 'aria-modal': true});
170170
if (settings.closeIcon) {
171-
$close = $('<i/>', {class: className.close})
172-
$module.append($close);
171+
$closeIcon = $('<i/>', {class: className.close, role: 'button', tabindex: 0, 'aria-label': settings.text.close})
172+
$module.append($closeIcon);
173173
}
174174
if (settings.title !== '') {
175-
$('<div/>', {class: className.title}).appendTo($module);
175+
var titleId = '_' + module.get.id() + 'title';
176+
$module.attr('aria-labelledby', titleId);
177+
$('<div/>', {class: className.title, id: titleId}).appendTo($module);
176178
}
177179
if (settings.content !== '') {
178-
$('<div/>', {class: className.content}).appendTo($module);
180+
var descId = '_' + module.get.id() + 'desc';
181+
$module.attr('aria-describedby', descId);
182+
$('<div/>', {class: className.content, id: descId}).appendTo($module);
179183
}
180184
if (module.has.configActions()) {
181185
$('<div/>', {class: className.actions}).appendTo($module);
@@ -228,15 +232,21 @@ $.fn.modal = function(parameters) {
228232
;
229233
$window.off(elementEventNamespace);
230234
$dimmer.off(elementEventNamespace);
231-
$close.off(eventNamespace);
235+
$closeIcon.off(elementEventNamespace);
236+
if($inputs) {
237+
$inputs.off(elementEventNamespace);
238+
}
232239
$context.dimmer('destroy');
233240
},
234241

235242
observeChanges: function() {
236243
if('MutationObserver' in window) {
237244
observer = new MutationObserver(function(mutations) {
238-
module.debug('DOM tree modified, refreshing');
239-
module.refresh();
245+
if(settings.observeChanges) {
246+
module.debug('DOM tree modified, refreshing');
247+
module.refresh();
248+
}
249+
module.refreshInputs();
240250
});
241251
observer.observe(element, {
242252
childList : true,
@@ -261,6 +271,23 @@ $.fn.modal = function(parameters) {
261271
$allModals = $otherModals.add($module);
262272
},
263273

274+
refreshInputs: function(){
275+
if($inputs){
276+
$inputs
277+
.off('keydown' + elementEventNamespace)
278+
;
279+
}
280+
$inputs = $module.find('[tabindex], :input').filter(':visible').filter(function() {
281+
return $(this).closest('.disabled').length === 0;
282+
});
283+
$inputs.first()
284+
.on('keydown' + elementEventNamespace, module.event.inputKeyDown.first)
285+
;
286+
$inputs.last()
287+
.on('keydown' + elementEventNamespace, module.event.inputKeyDown.last)
288+
;
289+
},
290+
264291
attachEvents: function(selector, event) {
265292
var
266293
$toggle = $(selector)
@@ -289,6 +316,9 @@ $.fn.modal = function(parameters) {
289316
.on('click' + eventNamespace, selector.approve, module.event.approve)
290317
.on('click' + eventNamespace, selector.deny, module.event.deny)
291318
;
319+
$closeIcon
320+
.on('keyup' + elementEventNamespace, module.event.closeKeyUp)
321+
;
292322
$window
293323
.on('resize' + elementEventNamespace, module.event.resize)
294324
;
@@ -307,7 +337,7 @@ $.fn.modal = function(parameters) {
307337

308338
get: {
309339
id: function() {
310-
return (Math.random().toString(16) + '000000000').substr(2, 8);
340+
return id;
311341
},
312342
element: function() {
313343
return $module;
@@ -346,10 +376,38 @@ $.fn.modal = function(parameters) {
346376
close: function() {
347377
module.hide();
348378
},
379+
closeKeyUp: function(event){
380+
var
381+
keyCode = event.which
382+
;
383+
if ((keyCode === settings.keys.enter || keyCode === settings.keys.space) && $module.hasClass(className.front)) {
384+
module.hide();
385+
}
386+
},
387+
inputKeyDown: {
388+
first: function(event) {
389+
var
390+
keyCode = event.which
391+
;
392+
if (keyCode === settings.keys.tab && event.shiftKey) {
393+
$inputs.last().focus();
394+
event.preventDefault();
395+
}
396+
},
397+
last: function(event) {
398+
var
399+
keyCode = event.which
400+
;
401+
if (keyCode === settings.keys.tab && !event.shiftKey) {
402+
$inputs.first().focus();
403+
event.preventDefault();
404+
}
405+
}
406+
},
349407
mousedown: function(event) {
350408
var
351409
$target = $(event.target),
352-
isRtl = module.is.rtl();
410+
isRtl = module.is.rtl()
353411
;
354412
initialMouseDownInModal = ($target.closest(selector.modal).length > 0);
355413
if(initialMouseDownInModal) {
@@ -397,10 +455,9 @@ $.fn.modal = function(parameters) {
397455
},
398456
keyboard: function(event) {
399457
var
400-
keyCode = event.which,
401-
escapeKey = 27
458+
keyCode = event.which
402459
;
403-
if(keyCode == escapeKey) {
460+
if(keyCode === settings.keys.escape) {
404461
if(settings.closable) {
405462
module.debug('Escape key pressed hiding modal');
406463
if ( $module.hasClass(className.front) ) {
@@ -900,13 +957,10 @@ $.fn.modal = function(parameters) {
900957
set: {
901958
autofocus: function() {
902959
var
903-
$inputs = $module.find('[tabindex], :input').filter(':visible').filter(function() {
904-
return $(this).closest('.disabled').length === 0;
905-
}),
906960
$autofocus = $inputs.filter('[autofocus]'),
907961
$input = ($autofocus.length > 0)
908962
? $autofocus.first()
909-
: $inputs.first()
963+
: ($inputs.length > 1 ? $inputs.filter(':not(i.close)') : $inputs).first()
910964
;
911965
if($input.length > 0) {
912966
$input.focus();
@@ -1323,11 +1377,19 @@ $.fn.modal.settings = {
13231377
// called after deny selector match
13241378
onDeny : function(){ return true; },
13251379

1380+
keys : {
1381+
space : 32,
1382+
enter : 13,
1383+
escape : 27,
1384+
tab : 9,
1385+
},
1386+
13261387
selector : {
13271388
title : '> .header',
13281389
content : '> .content',
13291390
actions : '> .actions',
13301391
close : '> .close',
1392+
closeIcon: '> .close',
13311393
approve : '.actions .positive, .actions .approve, .actions .ok',
13321394
deny : '.actions .negative, .actions .deny, .actions .cancel',
13331395
modal : '.ui.modal',
@@ -1363,7 +1425,8 @@ $.fn.modal.settings = {
13631425
},
13641426
text: {
13651427
ok : 'Ok',
1366-
cancel: 'Cancel'
1428+
cancel: 'Cancel',
1429+
close : 'Close'
13671430
}
13681431
};
13691432

src/definitions/modules/modal.less

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@
8080
height: @closeHitbox;
8181
padding: @closePadding;
8282
}
83+
.ui.modal > .close:focus,
8384
.ui.modal > .close:hover {
8485
opacity: 1;
86+
outline: none;
8587
}
8688

8789
/*--------------

0 commit comments

Comments
 (0)