Accessibility for TreeSelect and Tree

pull/2798/head
Tuğçe Küçükoğlu 2022-07-18 15:27:29 +03:00
parent 35a8bf636a
commit cc4517a8f9
6 changed files with 163 additions and 66 deletions

View File

@ -41,12 +41,6 @@ const TreeSelectProps = [
default: "null",
description: "Identifier of the underlying input element."
},
{
name: "ariaLabelledBy",
type: "string",
default: "null",
description: "Establishes relationships between the component and label(s) where its value should be one or more element IDs."
},
{
name: "selectionMode",
type: "string",

View File

@ -10,9 +10,9 @@
@keydown="onFilterKeydown" v-model="filterValue" />
<span class="p-tree-filter-icon pi pi-search"></span>
</div>
<div class="p-tree-wrapper" :style="{maxHeight: scrollHeight}">
<ul class="p-tree-container" role="tree">
<TreeNode v-for="node of valueToRender" :key="node.key" :node="node" :templates="$slots"
<div class="p-tree-wrapper" role="tree" :style="{maxHeight: scrollHeight}">
<ul class="p-tree-container" role="group">
<TreeNode v-for="(node, index) of valueToRender" :key="node.key" :node="node" :templates="$slots" :level="level + 1" :index="index"
:expandedKeys="d_expandedKeys" @node-toggle="onNodeToggle" @node-click="onNodeClick"
:selectionMode="selectionMode" :selectionKeys="selectionKeys" @checkbox-change="onCheckboxChange"></TreeNode>
</ul>
@ -79,6 +79,10 @@ export default {
scrollHeight: {
type: String,
default: null
},
level: {
type: Number,
default: 0
}
},
data() {

View File

@ -1,5 +1,6 @@
<template>
<li :class="containerClass">
<li :class="containerClass" role="treeitem" :aria-label="label(node)" :aria-selected="selected" :aria-expanded="expanded"
:aria-setsize="node.children ? node.children.length : 0" :aria-posinset="index + 1" :aria-level="level">
<div :class="contentClass" tabindex="0" role="treeitem" :aria-expanded="expanded"
@click="onClick" @keydown="onKeyDown" @touchend="onTouchEnd" :style="node.style">
<button type="button" class="p-tree-toggler p-link" @click="toggle" tabindex="-1" v-ripple>
@ -17,7 +18,7 @@
</span>
</div>
<ul class="p-treenode-children" role="group" v-if="hasChildren && expanded">
<TreeNode v-for="childNode of node.children" :key="childNode.key" :node="childNode" :templates="templates"
<TreeNode v-for="childNode of node.children" :key="childNode.key" :node="childNode" :templates="templates" :level="level + 1"
:expandedKeys="expandedKeys" @node-toggle="onChildNodeToggle" @node-click="onChildNodeClick"
:selectionMode="selectionMode" :selectionKeys="selectionKeys"
@checkbox-change="propagateUp" />
@ -52,6 +53,14 @@ export default {
templates: {
type: null,
default: null
},
level: {
type: Number,
default: null
},
index: {
type: Number,
default: null
}
},
nodeTouched: false,
@ -92,9 +101,8 @@ export default {
onKeyDown(event) {
const nodeElement = event.target.parentElement;
switch (event.which) {
//down arrow
case 40:
switch (event.code) {
case 'ArrowDown':
var listElement = nodeElement.children[1];
if (listElement) {
this.focusNode(listElement.children[0]);
@ -111,12 +119,9 @@ export default {
}
}
}
event.preventDefault();
break;
//up arrow
case 38:
case 'ArrowUp':
if (nodeElement.previousElementSibling) {
this.focusNode(this.findLastVisibleDescendant(nodeElement.previousElementSibling));
}
@ -126,28 +131,24 @@ export default {
this.focusNode(parentNodeElement);
}
}
event.preventDefault();
break;
//right-left arrows
case 37:
case 39:
case 'ArrowRight':
case 'ArrowLeft':
this.$emit('node-toggle', this.node);
event.preventDefault();
break;
//enter
case 13:
case 'Enter':
case 'Space':
this.onClick(event);
event.preventDefault();
break;
default:
//no op
break;
}
event.preventDefault();
},
toggleCheckbox() {
let _selectionKeys = this.selectionKeys ? {...this.selectionKeys} : {};

View File

@ -38,10 +38,6 @@ export interface TreeSelectProps {
* Identifier of the underlying input element.
*/
inputId?: string | undefined;
/**
* Establishes relationships between the component and label(s) where its value should be one or more element IDs.
*/
ariaLabelledBy?: string | undefined;
/**
* Defines the selection mode.
* @see TreeSelectSelectionModeType

View File

@ -1,8 +1,8 @@
<template>
<div ref="container" :class="containerClass" @click="onClick">
<div class="p-hidden-accessible">
<input ref="focusInput" type="text" role="listbox" :id="inputId" readonly :disabled="disabled" @focus="onFocus" @blur="onBlur" @keydown="onKeyDown" :tabindex="tabindex"
aria-haspopup="true" :aria-expanded="overlayVisible" :aria-labelledby="ariaLabelledBy"/>
<input ref="focusInput" type="text" role="combobox" :id="inputId" readonly :disabled="disabled" :tabindex="tabindex" :aria-labelledby="ariaLabelledby" :aria-label="ariaLabel"
aria-haspopup="listbox" :aria-expanded="overlayVisible" :aria-controls="listId" @focus="onFocus($event)" @blur="onBlur($event)" @keydown="onKeyDown($event)" v-bind="inputProps" />
</div>
<div class="p-treeselect-label-container">
<div :class="labelClass">
@ -19,7 +19,7 @@
</slot>
</div>
</div>
<div class="p-treeselect-trigger">
<div class="p-treeselect-trigger" role="button" aria-haspopup="listbox" :aria-expanded="overlayVisible">
<slot name="indicator">
<span class="p-treeselect-trigger-icon pi pi-chevron-down"></span>
</slot>
@ -29,10 +29,10 @@
<div :ref="overlayRef" v-if="overlayVisible" @click="onOverlayClick" :class="panelStyleClass">
<slot name="header" :value="modelValue" :options="options"></slot>
<div class="p-treeselect-items-wrapper" :style="{'max-height': scrollHeight}">
<TSTree :value="options" :selectionMode="selectionMode" @update:selectionKeys="onSelectionChange" :selectionKeys="modelValue"
<TSTree :id="listId" :value="options" :selectionMode="selectionMode" @update:selectionKeys="onSelectionChange" :selectionKeys="modelValue"
:expandedKeys="expandedKeys" @update:expandedKeys="onNodeToggle" :metaKeySelection="metaKeySelection"
@node-expand="$emit('node-expand', $event)" @node-collapse="$emit('node-collapse', $event)"
@node-select="onNodeSelect" @node-unselect="onNodeUnselect" />
@node-select="onNodeSelect" @node-unselect="onNodeUnselect" :level="0" />
<div v-if="emptyOptions" class="p-treeselect-empty-message">
<slot name="empty">{{emptyMessageText}}</slot>
</div>
@ -45,7 +45,7 @@
</template>
<script>
import {ConnectedOverlayScrollHandler,DomHandler,ZIndexUtils} from 'primevue/utils';
import {ConnectedOverlayScrollHandler,DomHandler,ZIndexUtils,UniqueComponentId} from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import Tree from 'primevue/tree';
import Ripple from 'primevue/ripple';
@ -53,7 +53,7 @@ import Portal from 'primevue/portal';
export default {
name: 'TreeSelect',
emits: ['update:modelValue', 'before-show', 'before-hide', 'change', 'show', 'hide', 'node-select', 'node-unselect', 'node-expand', 'node-collapse'],
emits: ['update:modelValue', 'before-show', 'before-hide', 'change', 'show', 'hide', 'node-select', 'node-unselect', 'node-expand', 'node-collapse', 'focus', 'blur'],
props: {
modelValue: null,
options: Array,
@ -65,7 +65,7 @@ export default {
disabled: Boolean,
tabindex: String,
inputId: String,
ariaLabelledBy: null,
inputProps: null,
selectionMode: {
type: String,
default: 'single'
@ -89,6 +89,14 @@ export default {
metaKeySelection: {
type: Boolean,
default: true
},
'aria-labelledby': {
type: String,
default: null
},
'aria-label': {
type: String,
default: null
}
},
watch: {
@ -143,11 +151,13 @@ export default {
this.$emit('before-hide');
this.overlayVisible = false;
},
onFocus() {
onFocus(event) {
this.focused = true;
this.$emit('focus', event);
},
onBlur() {
onBlur(event) {
this.focused = false;
this.$emit('blur', event);
},
onClick(event) {
if (!this.disabled && (!this.overlay || !this.overlay.contains(event.target)) && !DomHandler.hasClass(event.target, 'p-treeselect-close')) {
@ -178,37 +188,38 @@ export default {
this.expandedKeys = keys;
},
onKeyDown(event) {
switch(event.which) {
//down
case 40:
if (!this.overlayVisible && event.altKey) {
this.show();
event.preventDefault();
switch(event.code) {
case 'Down':
case 'ArrowDown':
if (this.overlayVisible) {
if (DomHandler.findSingle(this.overlay, '.p-highlight')) {
DomHandler.findSingle(this.overlay, '.p-highlight').focus();
}
else DomHandler.findSingle(this.overlay, '.p-treenode').children[0].focus();
}
else {
this.show();
}
event.preventDefault();
break;
//space
case 32:
case 'Space':
case 'Enter':
if (!this.overlayVisible) {
this.show();
event.preventDefault();
}
break;
//enter and escape
case 13:
case 27:
case 'Escape':
case 'Tab':
if (this.overlayVisible) {
this.hide();
event.preventDefault();
}
break;
//tab
case 9:
this.hide();
break;
default:
break;
}
@ -415,6 +426,9 @@ export default {
},
emptyOptions() {
return !this.options || this.options.length === 0;
},
listId() {
return UniqueComponentId() + '_list';
}
},
components: {

View File

@ -292,12 +292,6 @@ data() {
<td>null</td>
<td>Identifier of the underlying input element.</td>
</tr>
<tr>
<td>ariaLabelledBy</td>
<td>string</td>
<td>null</td>
<td>Establishes relationships between the component and label(s) where its value should be one or more element IDs.</td>
</tr>
<tr>
<td>selectionMode</td>
<td>string</td>
@ -501,6 +495,100 @@ data() {
</table>
</div>
<h5>Accessibility</h5>
<DevelopmentSection>
<h6>Screen Reader</h6>
<p>Value to describe the component can either be provided with <i>aria-labelledby</i> or <i>aria-label</i> props. The treeselect element has a <i>combobox</i> role
in addition to <i>aria-haspopup</i> and <i>aria-expanded</i> attributes. The relation between the combobox and the popup is created with <i>aria-controls</i> that refers to the id of the popup.</p>
<p>The popup list has an id that refers to the <i>aria-controls</i> attribute of the <i>combobox</i> element and uses <i>tree</i> as the role. Each list item has a <i>treeitem</i> role along with <i>aria-label</i>, <i>aria-selected</i> and <i>aria-expanded</i> attributes.
In checkbox selection, <i>aria-checked</i> is used instead of <i>aria-selected</i>. Checkbox and toggle icons are hidden from screen readers as their parent element with <i>treeitem</i> role and attributes are used instead for readers and keyboard support.
The container element of a treenode has the <i>group</i> role. The <i>aria-setsize</i>, <i>aria-posinset</i> and <i>aria-level</i> attributes are calculated implicitly and added to each treeitem.</p>
<pre v-code><code>
&lt;span id="dd1"&gt;Options&lt;/span&gt;
&lt;TreeSelect aria-labelledby="dd1" /&gt;
&lt;TreeSelect aria-label="Options" /&gt;
</code></pre>
<h6>Closed State 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 to the treeselect element.</td>
</tr>
<tr>
<td><i>space</i></td>
<td>Opens the popup and moves visual focus to the selected treenode, if there is none then first treenode receives the focus.</td>
</tr>
<tr>
<td><i>down arrow</i></td>
<td>Opens the popup and moves visual focus to the selected option, if there is none then first option receives the focus.</td>
</tr>
</tbody>
</table>
</div>
<h6>Popup 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 to the next focusable element in the popup, if there is none then first focusable element receives the focus.</td>
</tr>
<tr>
<td><i>shift</i> + <i>tab</i></td>
<td>Moves focus to the previous focusable element in the popup, if there is none then last focusable element receives the focus.</td>
</tr>
<tr>
<td><i>enter</i></td>
<td>Selects the focused option, closes the popup if selection mode is single.</td>
</tr>
<tr>
<td><i>space</i></td>
<td>Selects the focused option, closes the popup if selection mode is single.</td>
</tr>
<tr>
<td><i>escape</i></td>
<td>Closes the popup, moves focus to the treeselect element.</td>
</tr>
<tr>
<td><i>down arrow</i></td>
<td>Moves focus to the next treenode.</td>
</tr>
<tr>
<td><i>up arrow</i></td>
<td>Moves focus to the previous treenode.</td>
</tr>
<tr>
<td><i>right arrow</i></td>
<td>If node is closed, opens the node otherwise moves focus to the first child node.</td>
</tr>
<tr>
<td><i>left arrow</i></td>
<td>If node is open, closes the node otherwise moves focus to the parent node.</td>
</tr>
</tbody>
</table>
</div>
</DevelopmentSection>
<h5>Dependencies</h5>
<p>None.</p>
</AppDoc>