Skip to content

Commit

Permalink
[carousel] ARIA attributes and keyboard control (#380)
Browse files Browse the repository at this point in the history
* Create README.md

* Create meta.json

* Create index.json

* [docs] Added button-toolbar

* ESLint

* [scrollspy] Better options handling

Better handling of modifiers
A few code optimizations
Removed excess usage comments

* ESLint

* [button-toolbar] focus timing

* [carousel] ARIA attributes and keyboard navigation

* [carousel] ESLint

* [carousel] ESLint

* [carousel] ESLint

* [carousel] ESLint

* [carousel] ESLint

* [carousel] ESLint

* [carousel] ESLint

* [carousel] removed tabindex on root element

* [carousel-slide] Added ARIA role

* [carousel-slide] ESLint

* [carousel] ARIA and keyboard control

* [carousel] ESLint

* [carousel] Keyboard handlers for next and prev

When links have role="button", screen readers expect them to work like buttons, so they need to have handlers for enter and spake keys (along with the regular click handler)

* [carousel] minor text fix
  • Loading branch information
tmorehouse authored and pi0 committed May 11, 2017
1 parent b4e6aa3 commit 1ca0706
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 11 deletions.
11 changes: 8 additions & 3 deletions lib/components/button-toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
}
},
methods: {
setItemFocus(item) {
this.$nextTick(() => {
item.focus();
});
},
focusNext(e, prev) {
if (!this.keyNav) {
return;
Expand All @@ -64,7 +69,7 @@
if (index < 0) {
index = 0;
}
items[index].focus();
this.setItemFocus(items[index]);
},
focusFirst(e) {
if (!this.keyNav) {
Expand All @@ -74,7 +79,7 @@
e.stopPropagation();
const items = this.getItems();
if (items.length > 0) {
items[0].focus();
this.setItemFocus(items[0]);
}
},
focusLast(e) {
Expand All @@ -85,7 +90,7 @@
e.stopPropagation();
const items = this.getItems();
if (items.length > 0) {
items[items.length - 1].focus();
this.setItemFocus([items.length - 1]);
}
},
getItems() {
Expand Down
9 changes: 8 additions & 1 deletion lib/components/carousel-slide.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<template>
<div class="carousel-item" :style="{background,height}">
<div class="carousel-item"
role="listitem"
:id="id || null"
:style="{background,height}"
>
<img class="d-block img-fluid" v-if="img" :src="img" :alt="imgAlt">
<div :class="contentClasses">
<h3 v-if="caption" v-html="caption"></h3>
Expand All @@ -12,6 +16,9 @@
<script>
export default {
props: {
id: {
type: String
},
img: {
type: String
},
Expand Down
159 changes: 152 additions & 7 deletions lib/components/carousel.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,82 @@
<template>
<div class="carousel slide" @mouseenter="pause" @mouseleave="start" :style="{background,height}">
<div class="carousel slide"
role="region"
:id="id || null"
:style="{background,height}"
:aria-busy="isSliding ? 'true' : 'false'"
@mouseenter="pause"
@mouseleave="start"
@focusin="pause"
@focusout="restart($event)"
@keydown.left="prev"
@keydown.right="next"
>
<!-- Indicators -->
<ol class="carousel-indicators" v-show="indicators">
<ol role="group"
class="carousel-indicators"
v-show="indicators"
:aria-hidden="indicators ? 'false' : 'true'"
:aria-label="indicators && labelIndicators ? labelIndicators : null"
:aria-owns="indictors && id ? (id + '__BV_inner_') : null"
:aria-activedescendant="slides[index].id || null"
:tabindex="indicators ? '0' : '-1'"
@focusin.self="focusActiveIndicator"
@keydown.left.stop.prevent="focusPrevIndicator"
@keydown.up.stop.prevent="focusPrevIndicator"
@keydown.right.stop.prevent="focusNextIndicator"
@keydown.down.stop.prevent="focusNextIndicator"
>
<li v-for="n in slides.length"
role="button"
tabindex="-1"
ref="indcators"
:id="id ? (id + '__BV_indicator_' + n + '_') : null"
:class="{active:n-1 === index}"
:aria-current="n-1 === index ? 'true' : 'false'"
:aria-posinset="n"
:aria-setsize="slides.length"
:aria-label="labelGotoSlide + ' ' + n"
:aria-describedby="slides[n-1].id || null"
:aria-controls="id ? (id + '__BV_inner_') : null"
@click="index=n-1"
@keydown.enter.stop.prevent="index=n-1"
@keydown.space.stop.prevent="index=n-1"
></li>
</ol>

<!-- Wrapper for slides -->
<div class="carousel-inner" role="listbox">
<div class="carousel-inner"
role="list"
:id="id ? (id + '__BV_inner_') : null"
>
<slot></slot>
</div>

<!-- Controls -->
<template v-if="controls">
<a class="carousel-control-prev" href="#" role="button" data-slide="prev" @click.stop.prevent="prev">
<a class="carousel-control-prev"
href="#"
role="button"
data-slide="prev"
:aria-controls="id ? (id + '__BV_inner_') : null"
@click.stop.prevent="prev"
@keydown.enter.stop.prevent="prev"
@keydown.space.stop.prevent="prev"
>
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
<span class="sr-only">{{labelPrev}}</span>
</a>
<a class="carousel-control-next" href="#" role="button" data-slide="next" @click.stop.prevent="next">
<a class="carousel-control-next"
href="#"
role="button"
data-slide="next"
:aria-controls="id ? (id + '__BV_inner_') : null"
@click.stop.prevent="next"
@keydown.enter.stop.prevent="next"
@keydown.space.stop.prevent="next"
>
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
<span class="sr-only">{{labelNext}}</span>
</a>
</template>
</div>
Expand Down Expand Up @@ -50,6 +105,25 @@
};
},
props: {
id: {
type: String
},
labelPrev: {
type: String,
default: 'Previous Slide'
},
labelNext: {
type: String,
default: 'Next Slide'
},
labelGotoSlide: {
type: String,
default: 'Goto Slide'
},
labelIndicators: {
type: String,
default: 'Select a slide to display'
},
interval: {
type: Number,
default: 5000
Expand Down Expand Up @@ -94,16 +168,61 @@
return;
}
clearInterval(this._intervalId);
this._intervalId = null;
// Make current slide focusable for screen readers
this.slides[this.index].tabIndex = 0;
},
// Start auto rotate slides
start() {
if (this.interval === 0 || typeof this.interval === 'undefined') {
return;
}
this.slides.forEach(slide => {
slide.tabIndex = -1;
});
this._intervalId = setInterval(() => {
this.next();
}, this.interval);
},
// Re-Start auto rotate slides when focus leaves the carousel
restart(e) {
if (!e.relatedTarget || !this.$el.contains(e.relatedTarget)) {
this.start();
}
},
// Focus first indicator
focusActiveIndicator() {
if (this.indicators & this.$refs.indicators.length > 0) {
this.$nextTick(() => {
this.$refs.indicators[this.index].focus();
});
}
},
// Focus prev indicator
focusPrevIndicator() {
if (this.indicators & this.$refs.indicators.length > 0) {
const idx = this.$refs.indicators.indexOf(el => Boolean(el === document.activeElement));
if (idx > 0) {
this.$nextTick(() => {
this.$refs.indicators[idx - 1].focus();
});
}
}
},
focusNextIndicator() {
if (this.indicators & this.$refs.indicators.length > 0) {
const idx = this.$refs.indicators.indexOf(el => Boolean(el === document.activeElement));
if (idx > 0 && idx < this.$refs.indicators - 1) {
this.$nextTick(() => {
this.$refs.indicators[idx + 1].focus();
});
}
}
}
},
mounted() {
Expand All @@ -112,8 +231,19 @@
// Set first slide as active
this.slides[0].classList.add('active');
this.slides.forEach((slide, idx) => {
const n = idx + 1;
slide.setAttribute('aria-current', idx === 0 ? 'true' : 'false');
slide.setAttribute('aria-posinset', String(n));
slide.setAttribute('aria-setsize', String(this.slides.length));
slide.tabIndex = -1;
if (this.id) {
slide.setAttribute('aria-controlledby', this.id + '__BV_indicator_' + n + '_');
}
});
// Auto rotate slides
this._intervalId = null;
this.start();
},
watch: {
Expand Down Expand Up @@ -156,7 +286,22 @@
this.$emit('slide', val);
currentSlide.classList.remove('active');
currentSlide.setAttribute('aria-current', 'false');
currentSlide.setAttribute('aria-hidden', 'true');
currentSlide.tabIndex = -1;
nextSlide.classList.add('active');
nextSlide.setAttribute('aria-current', 'true');
currentSlide.setAttribute('aria-hidden', 'false');
currentSlide.tabIndex = -1;
if (!this._intervalId) {
// Focus the slide for screen readers if not in play mode
currentSlide.tabIndex = 0;
this.$nextTick(() => {
currentSlide.focus();
});
}
currentSlide.classList.remove(direction.current);
nextSlide.classList.remove(direction.next, direction.overlay);
Expand Down

0 comments on commit 1ca0706

Please sign in to comment.