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", default: "null",
description: "Identifier of the underlying input element." 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", name: "selectionMode",
type: "string", type: "string",

View File

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

View File

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

View File

@ -38,10 +38,6 @@ export interface TreeSelectProps {
* Identifier of the underlying input element. * Identifier of the underlying input element.
*/ */
inputId?: string | undefined; 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. * Defines the selection mode.
* @see TreeSelectSelectionModeType * @see TreeSelectSelectionModeType

View File

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

View File

@ -292,12 +292,6 @@ data() {
<td>null</td> <td>null</td>
<td>Identifier of the underlying input element.</td> <td>Identifier of the underlying input element.</td>
</tr> </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> <tr>
<td>selectionMode</td> <td>selectionMode</td>
<td>string</td> <td>string</td>
@ -329,7 +323,7 @@ data() {
<td>comma</td> <td>comma</td>
<td>Defines how the selected items are displayed, valid values are "comma" and "chip".</td> <td>Defines how the selected items are displayed, valid values are "comma" and "chip".</td>
</tr> </tr>
<tr> <tr>
<td>metaKeySelection</td> <td>metaKeySelection</td>
<td>boolean</td> <td>boolean</td>
<td>true</td> <td>true</td>
@ -448,7 +442,7 @@ data() {
<tr> <tr>
<td>footer</td> <td>footer</td>
<td>value: Value of the component <br /> <td>value: Value of the component <br />
options: TreeNode options</td> options: TreeNode options</td>
</tr> </tr>
<tr> <tr>
<td>empty</td> <td>empty</td>
@ -501,6 +495,100 @@ data() {
</table> </table>
</div> </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> <h5>Dependencies</h5>
<p>None.</p> <p>None.</p>
</AppDoc> </AppDoc>