Fixed #2895 - Improve Accordion implementation for Accessibility

pull/2898/head
mertsincan 2022-08-29 02:15:56 +01:00
parent 67320fb0d8
commit 3bdaf4a2b9
7 changed files with 446 additions and 91 deletions

View File

@ -28,6 +28,18 @@ const AccordionProps = [
type: "string",
default: "pi-chevron-down",
description: "Icon of a expanded tab."
},
{
name: "tabindex",
type: "number",
default: "0",
description: "Index of the element in tabbing order."
},
{
name: "selectOnFocus",
type: "boolean",
default: "false",
description: "When enabled, the focused tab is activated."
}
];
@ -63,6 +75,22 @@ const AccordionEvents = [
description: "Closed tab index"
}
]
},
{
name: "tab-click",
description: "Callback to invoke when an active tab is clicked.",
arguments: [
{
name: "originalEvent",
type: "object",
description: "Original event"
},
{
name: "index",
type: "number",
description: "Index of the clicked tab"
}
]
}
];

View File

@ -5,6 +5,48 @@ const AccordionTabProps = [
default: "null",
description: "Orientation of tab headers."
},
{
name: "headerStyle",
type: "any",
default: "null",
description: "Inline style of the tab header."
},
{
name: "headerClass",
type: "any",
default: "null",
description: "Style class of the tab header."
},
{
name: "headerProps",
type: "any",
default: "null",
description: "Uses to pass all properties of the HTMLDivElement to the tab header."
},
{
name: "headerActionProps",
type: "any",
default: "null",
description: "Uses to pass all properties of the HTMLAnchorElement to the focusable anchor element inside the tab header."
},
{
name: "contentStyle",
type: "any",
default: "null",
description: "Inline style of the tab content."
},
{
name: "contentClass",
type: "any",
default: "null",
description: "Style class of the tab content."
},
{
name: "contentProps",
type: "any",
default: "null",
description: "Uses to pass all properties of the HTMLDivElement to the tab content."
},
{
name: "disabled",
type: "boolean",

View File

@ -18,6 +18,11 @@ export interface AccordionTabOpenEvent {
*/
export interface AccordionTabCloseEvent extends AccordionTabOpenEvent { }
/**
* @extends AccordionTabOpenEvent
*/
export interface AccordionClickEvent extends AccordionTabOpenEvent { }
export interface AccordionProps {
/**
* When enabled, multiple tabs can be activated at the same time.
@ -39,6 +44,14 @@ export interface AccordionProps {
* Icon of an expanded tab.
*/
collapseIcon?: string | undefined;
/**
* Index of the element in tabbing order.
*/
tabindex?: number | undefined;
/**
* When enabled, the focused tab is activated.
*/
selectOnFocus?: boolean | undefined;
}
export interface AccordionSlots {
@ -64,6 +77,11 @@ export declare type AccordionEmits = {
* @param {AccordionTabCloseEvent} event - Custom tab close event.
*/
'tab-close': (event: AccordionTabCloseEvent) => void;
/**
* Callback to invoke when an active tab is clicked.
* @param {AccordionClickEvent} event - Custom tab click event.
*/
'tab-click': (event: AccordionClickEvent) => void;
}
declare class Accordion extends ClassComponent<AccordionProps, AccordionSlots, AccordionEmits> { }

View File

@ -1,17 +1,18 @@
<template>
<div class="p-accordion p-component">
<div v-for="(tab,i) of tabs" :key="getKey(tab,i)" :class="getTabClass(i)">
<div :class="getTabHeaderClass(tab, i)">
<a role="tab" class="p-accordion-header-link" @click="onTabClick($event, tab, i)" @keydown="onTabKeydown($event, tab, i)" :tabindex="isTabDisabled(tab) ? null : '0'"
:aria-expanded="isTabActive(i)" :id="getTabAriaId(i) + '_header'" :aria-controls="getTabAriaId(i) + '_content'">
<span :class="isTabActive(i) ? getHeaderCollapseIcon() : getHeaderExpandIcon()"></span>
<div v-for="(tab,i) of tabs" :key="getKey(tab,i)" :class="getTabClass(i)" :data-index="i">
<div :style="getTabProp(tab, 'headerStyle')" :class="getTabHeaderClass(tab, i)" v-bind="getTabProp(tab, 'headerProps')">
<a :id="getTabHeaderActionId(i)" class="p-accordion-header-link p-accordion-header-action" :tabindex="getTabProp(tab, 'disabled') ? -1 : tabindex"
role="button" :aria-disabled="getTabProp(tab, 'disabled')" :aria-expanded="isTabActive(i)" :aria-controls="getTabContentId(i)"
@click="onTabClick($event, tab, i)" @keydown="onTabKeyDown($event, tab, i)" v-bind="getTabProp(tab, 'headerActionProps')">
<span :class="getTabHeaderIconClass(i)" aria-hidden="true"></span>
<span class="p-accordion-header-text" v-if="tab.props && tab.props.header">{{tab.props.header}}</span>
<component :is="tab.children.header" v-if="tab.children && tab.children.header"></component>
</a>
</div>
<transition name="p-toggleable-content">
<div class="p-toggleable-content" v-if="lazy ? isTabActive(i) : true" v-show="lazy ? true: isTabActive(i)"
role="region" :id="getTabAriaId(i) + '_content'" :aria-labelledby="getTabAriaId(i) + '_header'">
<div v-if="lazy ? isTabActive(i) : true" v-show="lazy ? true: isTabActive(i)" :id="getTabContentId(i)" :style="getTabProp(tab, 'contentStyle')" :class="getTabContentClass(tab)"
role="region" :aria-labelledby="getTabHeaderActionId(i)" v-bind="getTabProp(tab, 'contentProps')">
<div class="p-accordion-content">
<component :is="tab"></component>
</div>
@ -22,11 +23,12 @@
</template>
<script>
import {UniqueComponentId} from 'primevue/utils';
import {UniqueComponentId,DomHandler} from 'primevue/utils';
import Ripple from 'primevue/ripple';
export default {
name: 'Accordion',
emits: ['tab-close', 'tab-open', 'update:activeIndex'],
emits: ['update:activeIndex', 'tab-open', 'tab-close', 'tab-click'],
props: {
multiple: {
type: Boolean,
@ -47,10 +49,19 @@ export default {
collapseIcon: {
type: String,
default: 'pi-chevron-down'
},
tabindex: {
type: Number,
default: 0
},
selectOnFocus: {
type: Boolean,
default: false
}
},
data() {
return {
id: UniqueComponentId(),
d_activeIndex: this.activeIndex
}
},
@ -59,98 +70,186 @@ export default {
this.d_activeIndex = newValue;
}
},
mounted() {
this.id = this.$attrs.id || this.id;
},
methods: {
onTabClick(event, tab, i) {
if (!this.isTabDisabled(tab)) {
const active = this.isTabActive(i);
isAccordionTab(child) {
return child.type.name === 'AccordionTab';
},
isTabActive(index) {
return this.multiple ? (this.d_activeIndex && this.d_activeIndex.includes(index)) : this.d_activeIndex === index;
},
getTabProp(tab, name) {
return tab.props ? tab.props[name] : undefined;
},
getKey(tab, index) {
return this.getTabProp(tab, 'header') || index;
},
getTabHeaderActionId(index) {
return `${this.id}_${index}_header_action`;
},
getTabContentId(index) {
return `${this.id}_${index}_content`;
},
onTabClick(event, tab, index) {
this.changeActiveIndex(event, tab, index);
this.$emit('tab-click', { originalEvent: event, index });
},
onTabKeyDown(event, tab, index) {
switch (event.code) {
case 'ArrowDown':
this.onTabArrowDownKey(event);
break;
case 'ArrowUp':
this.onTabArrowUpKey(event);
break;
case 'Home':
this.onTabHomeKey(event);
break;
case 'End':
this.onTabEndKey(event);
break;
case 'Enter':
case 'Space':
this.onTabEnterKey(event, tab, index);
break;
default:
break;
}
},
onTabArrowDownKey(event) {
const nextHeaderAction = this.findNextHeaderAction(event.target.parentElement.parentElement);
nextHeaderAction ? this.changeFocusedTab(event, nextHeaderAction) : this.onTabHomeKey(event);
event.preventDefault();
},
onTabArrowUpKey(event) {
const prevHeaderAction = this.findPrevHeaderAction(event.target.parentElement.parentElement);
prevHeaderAction ? this.changeFocusedTab(event, prevHeaderAction) : this.onTabEndKey(event);
event.preventDefault();
},
onTabHomeKey(event) {
const firstHeaderAction = this.findFirstHeaderAction();
this.changeFocusedTab(event, firstHeaderAction);
event.preventDefault();
},
onTabEndKey(event) {
const lastHeaderAction = this.findLastHeaderAction();
this.changeFocusedTab(event, lastHeaderAction);
event.preventDefault();
},
onTabEnterKey(event, tab, index) {
this.changeActiveIndex(event, tab, index);
event.preventDefault();
},
findNextHeaderAction(tabElement, selfCheck = false) {
const nextTabElement = selfCheck ? tabElement : tabElement.nextElementSibling;
const headerElement = DomHandler.findSingle(nextTabElement, '.p-accordion-header');
return headerElement ? (DomHandler.hasClass(headerElement, 'p-disabled') ? this.findNextHeaderAction(headerElement.parentElement) : DomHandler.findSingle(headerElement, '.p-accordion-header-action')) : null;
},
findPrevHeaderAction(tabElement, selfCheck = false) {
const prevTabElement = selfCheck ? tabElement : tabElement.previousElementSibling;
const headerElement = DomHandler.findSingle(prevTabElement, '.p-accordion-header');
return headerElement ? (DomHandler.hasClass(headerElement, 'p-disabled') ? this.findPrevHeaderAction(headerElement.parentElement) : DomHandler.findSingle(headerElement, '.p-accordion-header-action')) : null;
},
findFirstHeaderAction() {
return this.findNextHeaderAction(this.$el.firstElementChild, true);
},
findLastHeaderAction() {
return this.findPrevHeaderAction(this.$el.lastElementChild, true);
},
changeActiveIndex(event, tab, index) {
if (!this.getTabProp(tab, 'disabled')) {
const active = this.isTabActive(index);
const eventName = active ? 'tab-close' : 'tab-open';
if (this.multiple) {
if (active) {
this.d_activeIndex = this.d_activeIndex.filter(index => index !== i);
this.d_activeIndex = this.d_activeIndex.filter(i => i !== index);
}
else {
if (this.d_activeIndex)
this.d_activeIndex.push(i);
this.d_activeIndex.push(index);
else
this.d_activeIndex = [i];
this.d_activeIndex = [index];
}
}
else {
this.d_activeIndex = this.d_activeIndex === i ? null : i;
this.d_activeIndex = this.d_activeIndex === index ? null : index;
}
this.$emit('update:activeIndex', this.d_activeIndex);
this.$emit(eventName, { originalEvent: event, index });
}
},
changeFocusedTab(event, element) {
if (element) {
element.focus();
this.$emit(eventName, {
originalEvent: event,
index: i
});
if (this.selectOnFocus) {
const index = parseInt(element.parentElement.parentElement.dataset.index, 10);
const tab = this.tabs[index];
this.changeActiveIndex(event, tab, index);
}
}
},
onTabKeydown(event, tab, i) {
if (event.which === 13) {
this.onTabClick(event, tab, i);
}
},
isTabActive(index) {
if (this.multiple)
return this.d_activeIndex && this.d_activeIndex.includes(index);
else
return index === this.d_activeIndex;
},
getKey(tab, i) {
return (tab.props && tab.props.header) ? tab.props.header : i;
},
isTabDisabled(tab) {
return tab.props && tab.props.disabled;
},
getTabClass(i) {
return ['p-accordion-tab', {'p-accordion-tab-active': this.isTabActive(i)}];
return ['p-accordion-tab', {
'p-accordion-tab-active': this.isTabActive(i)
}];
},
getTabHeaderClass(tab, i) {
return ['p-accordion-header', {'p-highlight': this.isTabActive(i), 'p-disabled': this.isTabDisabled(tab)}];
return ['p-accordion-header', this.getTabProp(tab, 'headerClass'), {
'p-highlight': this.isTabActive(i),
'p-disabled': this.getTabProp(tab, 'disabled')
}];
},
getTabAriaId(i) {
return this.ariaId + '_' + i;
getTabHeaderIconClass(i) {
return ['p-accordion-toggle-icon pi', this.isTabActive(i) ? this.collapseIcon : this.expandIcon];
},
getHeaderCollapseIcon() {
return ['p-accordion-toggle-icon pi', this.collapseIcon];
},
getHeaderExpandIcon() {
return ['p-accordion-toggle-icon pi', this.expandIcon];
},
isAccordionTab(child) {
return child.type.name === 'AccordionTab';
getTabContentClass(tab) {
return ['p-toggleable-content', this.getTabProp(tab, 'contentClass')];
}
},
computed: {
tabs() {
const tabs = []
this.$slots.default().forEach(child => {
if (this.isAccordionTab(child)) {
tabs.push(child);
}
else if (child.children && child.children instanceof Array) {
child.children.forEach(nestedChild => {
if (this.isAccordionTab(nestedChild)) {
tabs.push(nestedChild)
}
});
}
return this.$slots.default().reduce((tabs, child) => {
if (this.isAccordionTab(child)) {
tabs.push(child);
}
)
return tabs;
},
ariaId() {
return UniqueComponentId();
else if (child.children && child.children instanceof Array) {
child.children.forEach(nestedChild => {
if (this.isAccordionTab(nestedChild)) {
tabs.push(nestedChild);
}
});
}
return tabs;
}, []);
}
},
directives: {
'ripple': Ripple
}
}
</script>
<style>
.p-accordion-header-link {
.p-accordion-header-action {
cursor: pointer;
display: flex;
align-items: center;
@ -159,7 +258,7 @@ export default {
text-decoration: none;
}
.p-accordion-header-link:focus {
.p-accordion-header-action:focus {
z-index: 1;
}

View File

@ -1,4 +1,4 @@
import { VNode } from 'vue';
import { AnchorHTMLAttributes, HTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
export interface AccordionTabProps {
@ -6,6 +6,34 @@ export interface AccordionTabProps {
* Orientation of tab headers.
*/
header?: string | undefined;
/**
* Inline style of the tab header.
*/
headerStyle?: any;
/**
* Style class of the tab header.
*/
headerClass?: any;
/**
* Uses to pass all properties of the HTMLDivElement to the tab header.
*/
headerProps?: HTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLAnchorElement to the focusable anchor element inside the tab header.
*/
headerActionProps?: AnchorHTMLAttributes | undefined;
/**
* Inline style of the tab content.
*/
contentStyle?: any;
/**
* Style class of the tab content.
*/
contentClass?: any;
/**
* Uses to pass all properties of the HTMLDivElement to the tab content.
*/
contentProps?: HTMLAttributes | undefined;
/**
* Whether the tab is disabled.
*/

View File

@ -7,6 +7,13 @@ export default {
name: 'AccordionTab',
props: {
header: null,
headerStyle: null,
headerClass: null,
headerProps: null,
headerActionProps: null,
contentStyle: null,
contentClass: null,
contentProps: null,
disabled: Boolean
}
}

View File

@ -218,18 +218,60 @@ export default {
</tr>
</thead>
<tbody>
<tr>
<td>header</td>
<td>string</td>
<td>null</td>
<td>Orientation of tab headers.</td>
</tr>
<tr>
<td>disabled</td>
<td>boolean</td>
<td>false</td>
<td>Whether the tab is disabled.</td>
</tr>
<tr>
<td>header</td>
<td>string</td>
<td>null</td>
<td>Orientation of tab headers.</td>
</tr>
<tr>
<td>headerStyle</td>
<td>string</td>
<td>null</td>
<td>Inline style of the tab header.</td>
</tr>
<tr>
<td>headerClass</td>
<td>string</td>
<td>null</td>
<td>Style class of the tab header.</td>
</tr>
<tr>
<td>headerProps</td>
<td>object</td>
<td>null</td>
<td>Uses to pass all properties of the HTMLDivElement to the tab header.</td>
</tr>
<tr>
<td>headerActionProps</td>
<td>object</td>
<td>null</td>
<td>Uses to pass all properties of the HTMLAnchorElement to the focusable anchor element inside the tab header.</td>
</tr>
<tr>
<td>contentStyle</td>
<td>string</td>
<td>null</td>
<td>Inline style of the tab content.</td>
</tr>
<tr>
<td>contentClass</td>
<td>string</td>
<td>null</td>
<td>Style class of the tab content.</td>
</tr>
<tr>
<td>contentProps</td>
<td>object</td>
<td>null</td>
<td>Uses to pass all properties of the HTMLDivElement to the tab content.</td>
</tr>
<tr>
<td>disabled</td>
<td>boolean</td>
<td>false</td>
<td>Whether the tab is disabled.</td>
</tr>
</tbody>
</table>
</div>
@ -276,6 +318,18 @@ export default {
<td>string</td>
<td>pi-chevron-down</td>
<td>Icon of an expanded tab.</td>
</tr>
<tr>
<td>tabindex</td>
<td>number</td>
<td>0</td>
<td>Index of the element in tabbing order.</td>
</tr>
<tr>
<td>selectOnFocus</td>
<td>boolean</td>
<td>false</td>
<td>When enabled, the focused tab is activated.</td>
</tr>
</tbody>
</table>
@ -305,6 +359,13 @@ export default {
event.index: Closed tab index
</td>
<td>Callback to invoke when an active tab is collapsed by clicking on the header.</td>
</tr>
<tr>
<td>tab-click</td>
<td>event.originalEvent: Browser event <br/>
event.index: Index of the clicked tab
</td>
<td>Callback to invoke when an active tab is clicked.</td>
</tr>
</tbody>
</table>
@ -337,6 +398,78 @@ export default {
</table>
</div>
<h5>Accessibility</h5>
<h6>Screen Reader</h6>
<p>
Accordion header elements have a <i>button</i> role and use <i>aria-controls</i> to define the id of the content section along with <i>aria-expanded</i> for the visibility state. The value to read a header element defaults
to the value of the <i>header</i> property and can be customized by defining an <i>aria-label</i> or <i>aria-labelledby</i> via the <i>headerActionProps</i> property.
</p>
<p>
The content uses <i>region</i> role, defines an id that matches the <i>aria-controls</i> of the header and <i>aria-labelledby</i> referring to the id of the header.
</p>
<h6>Header Keyboard Support</h6>
<div className="doc-tablewrapper">
<table className="doc-table">
<thead>
<tr>
<th>Key</th>
<th>Function</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<i>tab</i>
</td>
<td>Moves focus to the next the focusable element in the page tab sequence.</td>
</tr>
<tr>
<td>
<i>shift</i> + <i>tab</i>
</td>
<td>Moves focus to the previous the focusable element in the page tab sequence.</td>
</tr>
<tr>
<td>
<i>enter</i>
</td>
<td>Toggles the visibility of the content.</td>
</tr>
<tr>
<td>
<i>space</i>
</td>
<td>Toggles the visibility of the content.</td>
</tr>
<tr>
<td>
<i>down arrow</i>
</td>
<td>Moves focus to the next header. If focus is on the last header, moves focus to the first header.</td>
</tr>
<tr>
<td>
<i>up arrow</i>
</td>
<td>Moves focus to the previous header. If focus is on the first header, moves focus to the last header.</td>
</tr>
<tr>
<td>
<i>home</i>
</td>
<td>Moves focus to the first header.</td>
</tr>
<tr>
<td>
<i>end</i>
</td>
<td>Moves focus to the last header.</td>
</tr>
</tbody>
</table>
</div>
<h5>Dependencies</h5>
<p>None.</p>
</AppDoc>