Skip to content

Commit

Permalink
Fixed #3348 - Improve PickList Implementation for Accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
tugcekucukoglu committed Nov 30, 2022
1 parent ce41b4d commit b2bc5cc
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 8 deletions.
12 changes: 12 additions & 0 deletions api-generator/components/carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ const CarouselProps = [
type: 'boolean',
default: 'true',
description: 'Whether to display indicator container.'
},
{
name: 'prevButtonProps',
type: 'object',
default: 'null',
description: 'Uses to pass all properties of the HTMLButtonElement to the previous navigation button.'
},
{
name: 'nextButtonProps',
type: 'object',
default: 'null',
description: 'Uses to pass all properties of the HTMLButtonElement to the next navigation button.'
}
];

Expand Down
10 changes: 9 additions & 1 deletion src/components/carousel/Carousel.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { VNode } from 'vue';
import { ButtonHTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';

type CarouselOrientationType = 'horizontal' | 'vertical' | undefined;
Expand Down Expand Up @@ -85,6 +85,14 @@ export interface CarouselProps {
* Default value is true.
*/
showIndicators?: boolean | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the previous navigation button.
*/
prevButtonProps?: ButtonHTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the next navigation button.
*/
nextButtonProps?: ButtonHTMLAttributes | undefined;
}

export interface CarouselSlots {
Expand Down
4 changes: 4 additions & 0 deletions src/components/carousel/Carousel.spec.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import Carousel from './Carousel.vue';

describe('Carousel.vue', () => {
it('should exist', async () => {
const wrapper = mount(Carousel, {
global: {
plugins: [PrimeVue]
},
props: {
value: [
{
Expand Down
137 changes: 130 additions & 7 deletions src/components/carousel/Carousel.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<template>
<div :id="id" :class="['p-carousel p-component', { 'p-carousel-vertical': isVertical(), 'p-carousel-horizontal': !isVertical() }]">
<div :id="id" :class="['p-carousel p-component', { 'p-carousel-vertical': isVertical(), 'p-carousel-horizontal': !isVertical() }]" role="region">
<div v-if="$slots.header" class="p-carousel-header">
<slot name="header"></slot>
</div>
<div :class="contentClasses">
<div :class="containerClasses">
<button v-if="showNavigators" v-ripple :class="['p-carousel-prev p-link', { 'p-disabled': backwardIsDisabled }]" :disabled="backwardIsDisabled" @click="navBackward" type="button">
<div :class="containerClasses" :aria-live="allowAutoplay ? 'polite' : 'off'">
<button
v-if="showNavigators"
v-ripple
type="button"
:class="['p-carousel-prev p-link', { 'p-disabled': backwardIsDisabled }]"
:disabled="backwardIsDisabled"
:aria-label="ariaPrevButtonLabel"
@click="navBackward"
v-bind="prevButtonProps"
>
<span :class="['p-carousel-prev-icon pi', { 'pi-chevron-left': !isVertical(), 'pi-chevron-up': isVertical() }]"></span>
</button>

Expand All @@ -27,6 +36,10 @@
v-for="(item, index) of value"
:key="index"
:class="['p-carousel-item', { 'p-carousel-item-active': firstIndex() <= index && lastIndex() >= index, 'p-carousel-item-start': firstIndex() === index, 'p-carousel-item-end': lastIndex() === index }]"
role="group"
:aria-hidden="firstIndex() > index || lastIndex() < index ? true : undefined"
:aria-label="ariaSlideNumber(index)"
:aria-roledescription="ariaSlideLabel"
>
<slot name="item" :data="item" :index="index"></slot>
</div>
Expand All @@ -42,13 +55,22 @@
</div>
</div>

<button v-if="showNavigators" v-ripple :class="['p-carousel-next p-link', { 'p-disabled': forwardIsDisabled }]" :disabled="forwardIsDisabled" @click="navForward" type="button">
<button
v-if="showNavigators"
v-ripple
type="button"
:class="['p-carousel-next p-link', { 'p-disabled': forwardIsDisabled }]"
:disabled="forwardIsDisabled"
:aria-label="ariaNextButtonLabel"
@click="navForward"
v-bind="nextButtonProps"
>
<span :class="['p-carousel-prev-icon pi', { 'pi-chevron-right': !isVertical(), 'pi-chevron-down': isVertical() }]"></span>
</button>
</div>
<ul v-if="totalIndicators >= 0 && showIndicators" :class="indicatorsContentClasses">
<ul v-if="totalIndicators >= 0 && showIndicators" ref="indicatorContent" :class="indicatorsContentClasses" @keydown="onIndicatorKeydown">
<li v-for="(indicator, i) of totalIndicators" :key="'p-carousel-indicator-' + i.toString()" :class="['p-carousel-indicator', { 'p-highlight': d_page === i }]">
<button class="p-link" @click="onIndicatorClick($event, i)" type="button" />
<button class="p-link" type="button" :tabindex="d_page === i ? '0' : '-1'" :aria-label="ariaPageLabel(i + 1)" :aria-current="d_page === i ? 'page' : undefined" @click="onIndicatorClick($event, i)" />
</li>
</ul>
</div>
Expand Down Expand Up @@ -106,8 +128,17 @@ export default {
showIndicators: {
type: Boolean,
default: true
},
prevButtonProps: {
type: null,
default: null
},
nextButtonProps: {
type: null,
default: null
}
},
isRemainingItemsAdded: false,
data() {
return {
id: UniqueComponentId(),
Expand All @@ -124,7 +155,6 @@ export default {
swipeThreshold: 20
};
},
isRemainingItemsAdded: false,
watch: {
page(newValue) {
this.d_page = newValue;
Expand Down Expand Up @@ -418,6 +448,84 @@ export default {
}
}
},
onIndicatorKeydown(event) {
switch (event.code) {
case 'ArrowRight':
this.onRightKey();
break;
case 'ArrowLeft':
this.onLeftKey();
break;
case 'Home':
this.onHomeKey();
event.preventDefault();
break;
case 'End':
this.onEndKey();
event.preventDefault();
break;
case 'ArrowUp':
case 'ArrowDown':
event.preventDefault();
break;
case 'Tab':
this.onTabKey();
break;
default:
break;
}
},
onRightKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex + 1 === indicators.length ? indicators.length - 1 : activeIndex + 1);
},
onLeftKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex - 1 <= 0 ? 0 : activeIndex - 1);
},
onHomeKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, 0);
},
onEndKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, indicators.length - 1);
},
onTabKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const highlightedIndex = indicators.findIndex((ind) => DomHandler.hasClass(ind, 'p-highlight'));
const activeIndicator = DomHandler.findSingle(this.$refs.indicatorContent, '.p-carousel-indicator > button[tabindex="0"]');
const activeIndex = indicators.findIndex((ind) => ind === activeIndicator.parentElement);
indicators[activeIndex].children[0].tabIndex = '-1';
indicators[highlightedIndex].children[0].tabIndex = '0';
},
findFocusedIndicatorIndex() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndicator = DomHandler.findSingle(this.$refs.indicatorContent, '.p-carousel-indicator > button[tabindex="0"]');
return indicators.findIndex((ind) => ind === activeIndicator.parentElement);
},
changedFocusedIndicator(prevInd, nextInd) {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
indicators[prevInd].children[0].tabIndex = '-1';
indicators[nextInd].children[0].tabIndex = '0';
indicators[nextInd].children[0].focus();
},
bindDocumentListeners() {
if (!this.documentResizeListener) {
this.documentResizeListener = (e) => {
Expand Down Expand Up @@ -506,6 +614,12 @@ export default {
},
lastIndex() {
return this.firstIndex() + this.d_numVisible - 1;
},
ariaSlideNumber(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slideNumber.replace(/{slideNumber}/g, value) : undefined;
},
ariaPageLabel(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.pageLabel.replace(/{page}/g, value) : undefined;
}
},
computed: {
Expand All @@ -526,6 +640,15 @@ export default {
},
indicatorsContentClasses() {
return ['p-carousel-indicators p-reset', this.indicatorsContentClass];
},
ariaSlideLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slide : undefined;
},
ariaPrevButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.prevPageLabel : undefined;
},
ariaNextButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.nextPageLabel : undefined;
}
},
directives: {
Expand Down
119 changes: 119 additions & 0 deletions src/views/carousel/CarouselDoc.vue
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ data() {
<td>true</td>
<td>Whether to display indicator container.</td>
</tr>
<tr>
<td>prevButtonProps</td>
<td>object</td>
<td>null</td>
<td>Uses to pass all properties of the HTMLButtonElement to the previous navigation button.</td>
</tr>
<tr>
<td>nextButtonProps</td>
<td>object</td>
<td>null</td>
<td>Uses to pass all properties of the HTMLButtonElement to the next navigation button.</td>
</tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -306,6 +318,113 @@ data() {
</table>
</div>

<h5>Accessibility</h5>
<h6>Screen Reader</h6>
<p>
Carousel uses <i>region</i> role and since any attribute is passed to the main container element, attributes such as <i>aria-label</i> and <i>aria-roledescription</i> can be used as well. The slides container has
<i>aria-live</i> attribute set as "polite" if carousel is not in autoplay mode, otherwise "off" would be the value in autoplay.
</p>

<p>
A slide has a <i>group</i> role with an aria-label that refers to the <i>aria.slideNumber</i> property of the <router-link to="/locale">locale</router-link> API. Similarly <i>aria.slide</i> is used as the <i>aria-roledescription</i> of
the item. Inactive slides are hidden from the readers with <i>aria-hidden</i>.
</p>

<p>
Next and Previous navigators are button elements with <i>aria-label</i> attributes referring to the <i>aria.prevPageLabel</i> and <i>aria.nextPageLabel</i> properties of the <router-link to="/locale">locale</router-link> API by default
respectively, you may still use your own aria roles and attributes as any valid attribute is passed to the button elements implicitly by using <i>nextButtonProps</i> and <i>prevButtonProps</i>.
</p>

<p>Quick navigation elements are button elements with an <i>aria-label</i> attribute referring to the <i>aria.pageLabel</i> of the <router-link to="/locale">locale</router-link> API. Current page is marked with <i>aria-current</i>.</p>

<h6>Next/Prev Keyboard Support</h6>
<div class="doc-tablewrapper">
<table class="doc-table">
<thead>
<tr>
<th>Key</th>
<th>Function</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<i>tab</i>
</td>
<td>Moves focus through interactive elements in the carousel.</td>
</tr>
<tr>
<td>
<i>enter</i>
</td>
<td>Activates navigation.</td>
</tr>
<tr>
<td>
<i>space</i>
</td>
<td>Activates navigation.</td>
</tr>
</tbody>
</table>
</div>

<h6>Quick Navigation Keyboard Support</h6>
<div class="doc-tablewrapper">
<table class="doc-table">
<thead>
<tr>
<th>Key</th>
<th>Function</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<i>tab</i>
</td>
<td>Moves focus through the active slide link.</td>
</tr>
<tr>
<td>
<i>enter</i>
</td>
<td>Activates the focused slide link.</td>
</tr>
<tr>
<td>
<i>space</i>
</td>
<td>Activates the focused slide link.</td>
</tr>
<tr>
<td>
<i>right arrow</i>
</td>
<td>Moves focus to the next slide link.</td>
</tr>
<tr>
<td>
<i>left arrow</i>
</td>
<td>Moves focus to the previous slide link.</td>
</tr>
<tr>
<td>
<i>home</i>
</td>
<td>Moves focus to the first slide link.</td>
</tr>
<tr>
<td>
<i>end</i>
</td>
<td>Moves focus to the last slide link.</td>
</tr>
</tbody>
</table>
</div>

<h5>Dependencies</h5>
<p>None.</p>
</AppDoc>
Expand Down

0 comments on commit b2bc5cc

Please sign in to comment.