Fixed #2895 - Improve Accordion implementation for Accessibility

This commit is contained in:
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", type: "string",
default: "pi-chevron-down", default: "pi-chevron-down",
description: "Icon of a expanded tab." 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" 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", default: "null",
description: "Orientation of tab headers." 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", name: "disabled",
type: "boolean", type: "boolean",

View file

@ -18,6 +18,11 @@ export interface AccordionTabOpenEvent {
*/ */
export interface AccordionTabCloseEvent extends AccordionTabOpenEvent { } export interface AccordionTabCloseEvent extends AccordionTabOpenEvent { }
/**
* @extends AccordionTabOpenEvent
*/
export interface AccordionClickEvent extends AccordionTabOpenEvent { }
export interface AccordionProps { export interface AccordionProps {
/** /**
* When enabled, multiple tabs can be activated at the same time. * When enabled, multiple tabs can be activated at the same time.
@ -39,6 +44,14 @@ export interface AccordionProps {
* Icon of an expanded tab. * Icon of an expanded tab.
*/ */
collapseIcon?: string | undefined; 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 { export interface AccordionSlots {
@ -64,6 +77,11 @@ export declare type AccordionEmits = {
* @param {AccordionTabCloseEvent} event - Custom tab close event. * @param {AccordionTabCloseEvent} event - Custom tab close event.
*/ */
'tab-close': (event: AccordionTabCloseEvent) => void; '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> { } declare class Accordion extends ClassComponent<AccordionProps, AccordionSlots, AccordionEmits> { }

View file

@ -1,17 +1,18 @@
<template> <template>
<div class="p-accordion p-component"> <div class="p-accordion p-component">
<div v-for="(tab,i) of tabs" :key="getKey(tab,i)" :class="getTabClass(i)"> <div v-for="(tab,i) of tabs" :key="getKey(tab,i)" :class="getTabClass(i)" :data-index="i">
<div :class="getTabHeaderClass(tab, i)"> <div :style="getTabProp(tab, 'headerStyle')" :class="getTabHeaderClass(tab, i)" v-bind="getTabProp(tab, 'headerProps')">
<a role="tab" class="p-accordion-header-link" @click="onTabClick($event, tab, i)" @keydown="onTabKeydown($event, tab, i)" :tabindex="isTabDisabled(tab) ? null : '0'" <a :id="getTabHeaderActionId(i)" class="p-accordion-header-link p-accordion-header-action" :tabindex="getTabProp(tab, 'disabled') ? -1 : tabindex"
:aria-expanded="isTabActive(i)" :id="getTabAriaId(i) + '_header'" :aria-controls="getTabAriaId(i) + '_content'"> role="button" :aria-disabled="getTabProp(tab, 'disabled')" :aria-expanded="isTabActive(i)" :aria-controls="getTabContentId(i)"
<span :class="isTabActive(i) ? getHeaderCollapseIcon() : getHeaderExpandIcon()"></span> @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> <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> <component :is="tab.children.header" v-if="tab.children && tab.children.header"></component>
</a> </a>
</div> </div>
<transition name="p-toggleable-content"> <transition name="p-toggleable-content">
<div class="p-toggleable-content" v-if="lazy ? isTabActive(i) : true" v-show="lazy ? true: isTabActive(i)" <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" :id="getTabAriaId(i) + '_content'" :aria-labelledby="getTabAriaId(i) + '_header'"> role="region" :aria-labelledby="getTabHeaderActionId(i)" v-bind="getTabProp(tab, 'contentProps')">
<div class="p-accordion-content"> <div class="p-accordion-content">
<component :is="tab"></component> <component :is="tab"></component>
</div> </div>
@ -22,11 +23,12 @@
</template> </template>
<script> <script>
import {UniqueComponentId} from 'primevue/utils'; import {UniqueComponentId,DomHandler} from 'primevue/utils';
import Ripple from 'primevue/ripple';
export default { export default {
name: 'Accordion', name: 'Accordion',
emits: ['tab-close', 'tab-open', 'update:activeIndex'], emits: ['update:activeIndex', 'tab-open', 'tab-close', 'tab-click'],
props: { props: {
multiple: { multiple: {
type: Boolean, type: Boolean,
@ -47,10 +49,19 @@ export default {
collapseIcon: { collapseIcon: {
type: String, type: String,
default: 'pi-chevron-down' default: 'pi-chevron-down'
},
tabindex: {
type: Number,
default: 0
},
selectOnFocus: {
type: Boolean,
default: false
} }
}, },
data() { data() {
return { return {
id: UniqueComponentId(),
d_activeIndex: this.activeIndex d_activeIndex: this.activeIndex
} }
}, },
@ -59,98 +70,186 @@ export default {
this.d_activeIndex = newValue; this.d_activeIndex = newValue;
} }
}, },
mounted() {
this.id = this.$attrs.id || this.id;
},
methods: { methods: {
onTabClick(event, tab, i) { isAccordionTab(child) {
if (!this.isTabDisabled(tab)) { return child.type.name === 'AccordionTab';
const active = this.isTabActive(i); },
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'; const eventName = active ? 'tab-close' : 'tab-open';
if (this.multiple) { if (this.multiple) {
if (active) { if (active) {
this.d_activeIndex = this.d_activeIndex.filter(index => index !== i); this.d_activeIndex = this.d_activeIndex.filter(i => i !== index);
} }
else { else {
if (this.d_activeIndex) if (this.d_activeIndex)
this.d_activeIndex.push(i); this.d_activeIndex.push(index);
else else
this.d_activeIndex = [i]; this.d_activeIndex = [index];
} }
} }
else { 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('update:activeIndex', this.d_activeIndex);
this.$emit(eventName, { originalEvent: event, index });
}
},
changeFocusedTab(event, element) {
if (element) {
element.focus();
this.$emit(eventName, { if (this.selectOnFocus) {
originalEvent: event, const index = parseInt(element.parentElement.parentElement.dataset.index, 10);
index: i 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) { 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) { 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) { getTabHeaderIconClass(i) {
return this.ariaId + '_' + i; return ['p-accordion-toggle-icon pi', this.isTabActive(i) ? this.collapseIcon : this.expandIcon];
}, },
getHeaderCollapseIcon() { getTabContentClass(tab) {
return ['p-accordion-toggle-icon pi', this.collapseIcon]; return ['p-toggleable-content', this.getTabProp(tab, 'contentClass')];
},
getHeaderExpandIcon() {
return ['p-accordion-toggle-icon pi', this.expandIcon];
},
isAccordionTab(child) {
return child.type.name === 'AccordionTab';
} }
}, },
computed: { computed: {
tabs() { tabs() {
const tabs = [] return this.$slots.default().reduce((tabs, child) => {
this.$slots.default().forEach(child => { if (this.isAccordionTab(child)) {
if (this.isAccordionTab(child)) { tabs.push(child);
tabs.push(child);
}
else if (child.children && child.children instanceof Array) {
child.children.forEach(nestedChild => {
if (this.isAccordionTab(nestedChild)) {
tabs.push(nestedChild)
}
});
}
} }
) else if (child.children && child.children instanceof Array) {
return tabs; child.children.forEach(nestedChild => {
}, if (this.isAccordionTab(nestedChild)) {
ariaId() { tabs.push(nestedChild);
return UniqueComponentId(); }
});
}
return tabs;
}, []);
} }
},
directives: {
'ripple': Ripple
} }
} }
</script> </script>
<style> <style>
.p-accordion-header-link { .p-accordion-header-action {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@ -159,7 +258,7 @@ export default {
text-decoration: none; text-decoration: none;
} }
.p-accordion-header-link:focus { .p-accordion-header-action:focus {
z-index: 1; 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'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
export interface AccordionTabProps { export interface AccordionTabProps {
@ -6,6 +6,34 @@ export interface AccordionTabProps {
* Orientation of tab headers. * Orientation of tab headers.
*/ */
header?: string | undefined; 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. * Whether the tab is disabled.
*/ */

View file

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

View file

@ -218,18 +218,60 @@ export default {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>header</td> <td>header</td>
<td>string</td> <td>string</td>
<td>null</td> <td>null</td>
<td>Orientation of tab headers.</td> <td>Orientation of tab headers.</td>
</tr> </tr>
<tr> <tr>
<td>disabled</td> <td>headerStyle</td>
<td>boolean</td> <td>string</td>
<td>false</td> <td>null</td>
<td>Whether the tab is disabled.</td> <td>Inline style of the tab header.</td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@ -276,6 +318,18 @@ export default {
<td>string</td> <td>string</td>
<td>pi-chevron-down</td> <td>pi-chevron-down</td>
<td>Icon of an expanded tab.</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> </tr>
</tbody> </tbody>
</table> </table>
@ -305,6 +359,13 @@ export default {
event.index: Closed tab index event.index: Closed tab index
</td> </td>
<td>Callback to invoke when an active tab is collapsed by clicking on the header.</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> </tr>
</tbody> </tbody>
</table> </table>
@ -337,6 +398,78 @@ export default {
</table> </table>
</div> </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> <h5>Dependencies</h5>
<p>None.</p> <p>None.</p>
</AppDoc> </AppDoc>