protofunc()

Wie man Tabs wirklich wirklich zugänglich macht

Tags: accessibility, deutsch, javascript

Vor etwa einem halben Jahr veröffentlichte Dirk Ginader eine zugängliche Tab-Lösung. Dieser Artikel versucht auf einem ähnlichem Grundscript durch Hinzufügen von WAI-Aria- Rollen und Zuständen die Zugänglichkeit zu erhöhen. Daneben deckt der Artikel einen Firefox/Jaws-Bug inklusive eines brauchbaren Workarounds auf.

Warum ich nicht die Aria Tab-Semantik nutze

Einigen wird nachfolgend (negativ) auffallen, daß ich eben nicht die Tabsematik anwende, welche durch das Aria Best Practices Dokument vorgeschlagen wird, sondern eine neue zusammenstelle. Dies begründet sich aus folgenden Punkten:

1. Abwärtskompatibilität: Tabben vs. erweiterte Tastaturnutzung

Aria im allgemeinen und insbesondere das Tab-Widget folgen der One-Tab-Stop-Technik”. Tatsächlich ist diese Technik sehr cool, um die Usability in aria-fähigen Screenreader-/Browserkombinationen drastisch zu erhöhen. Sie kann jedoch in manchen Situationen die Zugänglichkeit für nicht aria-fähige Systeme reduzieren.

Dies könnte beim Tab-Widget der Fall sein. Nur der aktive Tab soll nämlich in der Tabreihenfolge sein; alle inaktiven Tabs dagegen ausserhalb der, durch den Nutzer, erreichbaren Tabreihenfolge. Das Umschalten zwischen den Tabs erfolgt durch besondere Tastaturkürzel, die gescriptet werden müssen. Während das Herausnehmen von inaktiven Tabs aus der Tabreihenfolge in aria-unfähigen Systemen funktionieren kann, funktioniert das Scripten von Tastatureingaben im Normalmodus der meisten Screenreader ohne Aria in der Regel eben nicht.

2. Abwärtskompatibilität: Fehlende Rückmeldung

Im normalen Aria-Tabwidget findet die Rückmeldung über das Switchen von Tabs ausschließlich über Aria statt. Eine Fokusverlagerung, die in aria-unfähigen Screenreader/Browserkombinationen für eine solche Rückmeldung sorgen könnte, ist dagegen nicht vorgehsehen.

3. Konflikt mit Tastaturnutzung zwischen Webseite und Browser

Das Tabwidget sollte desweiteren eine Tastaturkombination verwenden (Ctrl+PageUp/Ctrl+PageDown), welche mit der des Browsers in Konflikt geraten kann.

4. Wir machen doch nicht nur Tabwidgets

Eine extrem häufige Aufgabe ist es einen Schalter zu platzieren, welcher die Sichtbarkeit eines anderen Elements steuert. Hierbei handelt es sich dann aber noch nicht um ein Tabwidget. Aber eben auch für diese Schalter wollen wir eine zugängliche Technik entwickeln. Ein Tabwidget ist aus dieser Sicht dann nichts anderes als die Zusammenfassung solcher Buttons mit ihren Containern zu einer Gruppe.

Auch wenn mittelfristig – durch steigende Aria-Unterstützung – das hier gezeigte Aria-Markup für Tabs weniger sinnvoll erscheinen wird, so bleibt es dennoch für Accordions oder Schalter, welche irgendwelche anderen Elemente in ihrer Sichtbarkeit toggeln, sinnvoll.

Das ariafizierte Markup

<div>
	<ul>
		<li><a role="button" aria-expanded="true" tabindex="-1" id="tab-1" aria-controls="panel-1" href="#">Tab 1</a></li>
		<li><a role="button" aria-expanded="false" tabindex="0" id="tab-2" aria-controls="panel-2" href="#">Tab 2</a></li>
	</ul>
	<div role="group" aria-expanded="true" aria-labelledby="tab-1" "id="panel-1">
		<p tabindex="-1">Text für Panel 1. Startet manchmal halt ohne Überschrift (schade ist aber so)</p>
	</div>
	<div role="group" aria-expanded="false" aria-labelledby="tab-2" "id="panel-2">
		<h3 tabindex="-1">Überschrift für Panel 2</h3>
		<p>Weiterer Text für Panel 2</p>
	</div>
</div>

Mit dem tabindex-Attribut kontrollieren wir die Tabreihenfolge. Wie sich zeigt drehen wir die Logik des W3C zum tabwidget um. Alle Tabs sind in der Tabreihenfolge nur eben das aktive Tab-Element nicht. Wir geben dem Tab die Rolle Button und die Eigenschaft expanded. Die expanded-Eigenschaft an Button-Elementen wird derzeit nur von Firefox 3.5 an Screenreader weitergeleitet. Die Eigenschaft controls baut eine logische Beziehung zwischen Button und Panel auf, wird jedoch noch von keinem Screenreader richtig unterstüzt (schadet wahrscheinlich nicht). Das Fokusieren des 2. Tab würde sich durch obige Auszeichnung wie folgt anhören: Tab 2 [Schalter reduziert].

Das Tabpanel bekommt die Rolle ‘group’ sowie die Eigenschaften expanded und labelledby. Die labelledby-Eigenschaft setzt eine logische Beziehung von dem group-Element zum Button. Anders als Controls wird sie gut unterstüzt. Auch die Eigenschaft expanded ist am group-Element besser unterstützt als am button-Element. Sobald der User auf Tab 2 klickt, fokusieren wir mit einem kurzen Delay das 1. Kindelement des Tabpanels, was sich ungefähr wie folgt anhören würde: [Tab 2 offen] Überschrift für Panel 2 [Überschrift Ebene 3].

Da wir die Ariafizierung mit Javascript vornehmen, hier 2 Scripte die uns dabei helfen:

ID-Attribute erstellen/ermitteln und labelledby-Eigenschaft setzen

(function($){

	var uId = new Date().getTime();
	$.fn.getID = function(){
		var id = '';
		if(this[0]){
			var elem = $(this[0]);
			id = elem.attr('id');
			if(!id){
				id = 'ID-' + (uId++);
				elem.attr({'id': id});
			}
		}
		return id;
	};

	$.each({
		labelWith: 'aria-labelledby',
		describeWith: 'aria-describedby',
		ownsThis: 'aria-owns',
		controlsThis: 'aria-controls'
	}, function(name, prop){
		$.fn[name] = function(elem){
			return this.attr(prop, $(elem).getID());
		};
	});
})(jQuery);

Das jQuery-Plugin getID gibt den Wert des id-Attributs eines Elements zurück. Ist keine id vorhanden wird eine eindeutige id erstellt. Daneben werden hierauf aufbauend die Methoden labelWith, describeWith, ownsThis und controlsThis erstellt.

Benutzung:

// Gibt den ID-Wert von panel zurück
$(panel).getID();

// gib dem Panel die Eigenschaft labelledby mit dem id-Wert von button
$(panel).labelWith(button);

Element fokusieren

Ein Script, welches ein beliebiges Element nach einem kurzen Delay fokusiert, könnte dagegen wie folgt aussehen:

(function($){

	function addTabindex(jElm){
		if(!jElm.is('a[href], area, input, button, select, textarea')){
			jElm.css({outline: 'none'}).attr({tabindex: '-1'});
		}
		return jElm;
	}
	var focusTimer;
    $.fn.setFocus = function(time){
		if(!this[0]){return this;}
		var jElm = $(this[0]);
		clearTimeout(focusTimer);
		focusTimer = setTimeout(function(){
			try{
				addTabindex(jElm)[0].focus();
			} catch(e){}
		}, time || 0);
		return this;
	};

})(jQuery);

Benutzung des Focus-Scripts:

// 1. sichtbares Kindelement von panel nach 0 ms fokusieren
$('> :visible:first', panel).setFocus();

Sollte man das Panel oder ein anderes wrapper-Element des zu fokusierenden Elements animieren, empfiehlt es sich mit der Fokusierung bis zum Ende der animation zu warten, da beim Internet Explorer der Fokus bei manchen Animation ab und zu “verspringt”. Eine Verzögerung von weniger als 500ms (Die Standarddaauer einer jQuery-Animation beträgt 400ms) beim Fokusieren wird nicht als störend empfunden.

Wenn das ganze funktionieren würde…

… hätte ich diesen Blogeintrag nie geschrieben. Es ist etwa einen Monat her, da wies mich Timo Wirth daraufhin, daß das Script in einem seiner Projekte nicht funktioniert. Das Problem war, daß sobald das HTML eben mit Aria erweitert wurde, insbesondere die Aria-Rolle Button vergeben wurde, Jaws mit Firefox den Buffer nicht mehr beim Sichtbarkeitswechsel updaten wollte. Da aber die vorbereiteten Beispiele funktionierten, stellte sich die Frage, welcher andere Faktor an dem Scheitern verantworlich ist. Es begann die Suche nach der Nadel im Heuhaufen.

Das Problem ist kurz gesagt dermaßen banal und es ist eine Schande, wie lange die Ursachenforschung gedauert hat:

Das Element, dessen Sichtbarkeit beeinflußt wird, darf unter bestimmten Bedingungen (beispielsweise Kontrollelement hat die Rolle Button) nicht die CSS-Eigenschaft overflow: hidden haben. Um sicherzustellen, daß das Panel diese CSS-Eigenschaft weder durch CSS noch durch eine Javascript-Animation erhält, bekommt es daher von mir eine CSS-Klasse. Ein dynamisch erzeugtes Stylesheet sorgt dafür, dass diese Klasse ein overvlow: visible erhält:

$(function(){
	var style = document.createElement('style'),
		styleS
	;
	style.setAttribute('type', 'text/css');
	style = $(style).prependTo('head');

	styleS = document.styleSheets[0];

	function add(sel, prop){
		if (styleS.cssRules || styleS.rules) {
			if (styleS.insertRule) {
				styleS.insertRule(sel +' {'+ prop +';}', styleS.cssRules.length);
			} else if (styleS.addRule) {
				styleS.addRule(sel, prop);
			}
		}
	}

	add('.a11y-visibility-change', 'overflow:visible !important');

	$.cssRule = {
		add: add
	};
});
//über die Klasse reden wir noch :-)
$(panel).addClass('a11y-visibility-change');

Ein div sie zu knechten

Nun stellt die CSS-Eigenschaft overflow: hidden eine recht wichtige Eigenschaft dar. Einerseits wird sie gerne genommen, um floats einzuschließen andererseits ist sie essentieller Bestandteil einiger JavaScript-Animationen, beispielsweise (slideUp, slideDown etc.). Unser Stylesheet-Problem läßt sich recht einfach lösen, sowohl Vorfahren- als auch Nachfahrenelemente dürfen wieder die CSS-Eigenschaft hidden aufweisen. Bei Animationen wie slideDown/slideUp kann das Elternelement mit einer Höhen-Animation und entsprechendem overflow: hidden verwendet werden. Zu beachten ist allerdings, daß in unserem Fall das group-Element selbst die display: none/block Eigenschaft bekommen sollte und eben nicht das Elternelement. Eine Alternative zu slideUp/slideDown könnten daher folgende Plugins bieten:

$.fn.slideParentDown = function(opts){
	opts = $.extend({}, $.fn.slideParentDown.defaults, opts);
	var fn = opts.complete;

	return this.each(function(){
		var jElm 		= $(this),
			parent		= jElm.parent().css({overflow: 'hidden', height: '0px'}),
			outerHeight = jElm.css({display: 'block'}).outerHeight({margin: true})
		;
		parent.animate({
			height: outerHeight
		}, $.extend({}, opts, {complete: function(){
			parent.css({height: '', overflow: ''});
			fn.apply(this, arguments);
		}}));
	});
};
$.fn.slideParentDown.defaults = {
	duration: 400,
	complete: function(){}
};
$.fn.slideParentUp = function(opts){
	opts = $.extend({}, $.fn.slideParentDown.defaults, opts);
	var fn = opts.complete;
	return this.each(function(){
		var jElm 		= $(this),
			parent		= jElm.parent().css({overflow: 'hidden', height: jElm.outerHeight({margin: true})})
		;
		parent.animate({
			height: '0px'
		}, $.extend({}, opts, {complete: function(){
			jElm.css({display: 'none'});
			parent.css({height: '', overflow: '', display: ''});
			fn.apply(this, arguments);
		}}));
	});
};
$.fn.slideParentDown.defaults = {
	duration: 400,
	complete: function(){}
};

Kurz gesagt, wenn man die overflow: hidden Eigenschaft braucht, nimmt man ein zusätzliches div-Element und alles wird gut.

Fazit

Testen, testen, testen!!!

Das Ergebnis ist mit Sicherheit noch nicht des Weisheits letzter Schluß, aber hört sich schon ganz nett an. Für Leute die dies einmal testen möchten hier nun abschließend eine kleine Demonstration des zusammengebauten Tab-Scripts.

Die vorgenannte Demo wurde bisher mit folgenden Browser/Screenreader-Kombinationen erfolgreich getestet:

IE8: Jaws 10, Cobra (Webformator 2.4) | Firefox. 3.5: Jaws 10, NVDA 0.6p2 | IE 7: Jaws 9/Jaws 8

Update 06.10.2009: Das Script enthielt zuvor einen kleinen Fehler, so daß es nicht im IE7 mit Jaws 8/9 funktionierten konnte. Dieser Bug ist nun gefixt.

Written August 2, 2009 by
protofunc