Accessibility for CascadeSelect

pull/2770/head
Tuğçe Küçükoğlu 2022-07-08 12:28:23 +03:00
parent 638d132c21
commit 35556009ca
4 changed files with 204 additions and 97 deletions

View File

@ -1,8 +1,8 @@
<template> <template>
<div ref="container" :class="containerClass" @click="onClick($event)"> <div ref="container" :class="containerClass" @click="onClick($event)">
<div class="p-hidden-accessible"> <div class="p-hidden-accessible">
<input ref="focusInput" type="text" :id="inputId" readonly :disabled="disabled" @focus="onFocus" @blur="onBlur" @keydown="onKeyDown" :tabindex="tabindex" <input ref="focusInput" role="combobox" type="text" :id="inputId" readonly :disabled="disabled" @focus="onFocus" @blur="onBlur" @keydown="onKeyDown" :tabindex="tabindex"
aria-haspopup="listbox" :aria-expanded="overlayVisible" :aria-labelledby="ariaLabelledBy" /> aria-haspopup="listbox" :aria-expanded="overlayVisible" :aria-labelledby="ariaLabelledBy" :aria-controls="listId" v-bind="inputProps" />
</div> </div>
<span :class="labelClass"> <span :class="labelClass">
<slot name="value" :value="modelValue" :placeholder="placeholder"> <slot name="value" :value="modelValue" :placeholder="placeholder">
@ -16,9 +16,9 @@
</div> </div>
<Portal :appendTo="appendTo"> <Portal :appendTo="appendTo">
<transition name="p-connected-overlay" @enter="onOverlayEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave"> <transition name="p-connected-overlay" @enter="onOverlayEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave">
<div :ref="overlayRef" :class="panelStyleClass" v-if="overlayVisible" @click="onOverlayClick"> <div :ref="overlayRef" :class="panelStyleClass" v-if="overlayVisible" @click="onOverlayClick" role="group">
<div class="p-cascadeselect-items-wrapper"> <div class="p-cascadeselect-items-wrapper">
<CascadeSelectSub :options="options" :selectionPath="selectionPath" <CascadeSelectSub :id="listId" role="tree" :options="options" :selectionPath="selectionPath"
:optionLabel="optionLabel" :optionValue="optionValue" :level="0" :templates="$slots" :optionLabel="optionLabel" :optionValue="optionValue" :level="0" :templates="$slots"
:optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" :optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren"
@option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect" :dirty="dirty" :root="true" /> @option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect" :dirty="dirty" :root="true" />
@ -30,7 +30,7 @@
</template> </template>
<script> <script>
import {ConnectedOverlayScrollHandler,ObjectUtils,DomHandler,ZIndexUtils} from 'primevue/utils'; import {ConnectedOverlayScrollHandler,ObjectUtils,DomHandler,ZIndexUtils,UniqueComponentId} from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus'; import OverlayEventBus from 'primevue/overlayeventbus';
import CascadeSelectSub from './CascadeSelectSub.vue'; import CascadeSelectSub from './CascadeSelectSub.vue';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
@ -71,7 +71,8 @@ export default {
loadingIcon: { loadingIcon: {
type: String, type: String,
default: 'pi pi-spinner pi-spin' default: 'pi pi-spinner pi-spin'
} },
inputProps: null
}, },
outsideClickListener: null, outsideClickListener: null,
scrollHandler: null, scrollHandler: null,
@ -260,7 +261,12 @@ export default {
this.overlay = el; this.overlay = el;
}, },
onKeyDown(event) { onKeyDown(event) {
switch(event.key) { if (this.disabled || this.loading) {
event.preventDefault();
return;
}
switch(event.code) {
case 'Down': case 'Down':
case 'ArrowDown': case 'ArrowDown':
if (this.overlayVisible) { if (this.overlayVisible) {
@ -272,11 +278,14 @@ export default {
event.preventDefault(); event.preventDefault();
break; break;
case 'Escape': case 'Space':
if (this.overlayVisible) { if (this.overlayVisible) {
this.hide(); this.hide();
event.preventDefault();
} }
else {
this.show();
}
event.preventDefault();
break; break;
case 'Tab': case 'Tab':
@ -326,6 +335,9 @@ export default {
}, },
dropdownIconClass() { dropdownIconClass() {
return ['p-cascadeselect-trigger-icon', this.loading ? this.loadingIcon : 'pi pi-chevron-down']; return ['p-cascadeselect-trigger-icon', this.loading ? this.loadingIcon : 'pi pi-chevron-down'];
},
listId() {
return UniqueComponentId() + '_list';
} }
}, },
components: { components: {

View File

@ -1,8 +1,9 @@
<template> <template>
<ul class="p-cascadeselect-panel p-cascadeselect-items" role="listbox" aria-orientation="horizontal"> <ul class="p-cascadeselect-panel p-cascadeselect-items" aria-orientation="horizontal">
<template v-for="(option,i) of options" :key="getOptionLabelToRender(option)"> <template v-for="(option,index) of options" :key="getOptionLabelToRender(option)">
<li :class="getItemClass(option)" role="none"> <li :class="getItemClass(option)" role="treeitem" :aria-label="getOptionLabelToRender(option)" :aria-selected="isOptionActive(option)" :aria-expanded="isOptionActive(option)"
<div class="p-cascadeselect-item-content" @click="onOptionClick($event, option)" tabindex="0" @keydown="onKeyDown($event, option, i)" v-ripple> :aria-setsize="options.length" :aria-posinset="index + 1" :aria-level="level + 1">
<div class="p-cascadeselect-item-content" @click="onOptionClick($event, option)" tabindex="0" @keydown="onKeyDown($event, option, index)" v-ripple>
<component :is="templates['option']" :option="option" v-if="templates['option']"/> <component :is="templates['option']" :option="option" v-if="templates['option']"/>
<template v-else> <template v-else>
<span class="p-cascadeselect-item-text">{{getOptionLabelToRender(option)}}</span> <span class="p-cascadeselect-item-text">{{getOptionLabelToRender(option)}}</span>
@ -11,7 +12,7 @@
</div> </div>
<CascadeSelectSub v-if="isOptionGroup(option) && isOptionActive(option)" class="p-cascadeselect-sublist" :selectionPath="selectionPath" :options="getOptionGroupChildren(option)" <CascadeSelectSub v-if="isOptionGroup(option) && isOptionActive(option)" class="p-cascadeselect-sublist" :selectionPath="selectionPath" :options="getOptionGroupChildren(option)"
:optionLabel="optionLabel" :optionValue="optionValue" :level="level + 1" @option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect" :optionLabel="optionLabel" :optionValue="optionValue" :level="level + 1" @option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect"
:optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" :parentActive="isOptionActive(option)" :dirty="dirty" :templates="templates"/> :optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" :parentActive="isOptionActive(option)" :dirty="dirty" :templates="templates" :aria-level="level + 2"/>
</li> </li>
</template> </template>
</ul> </ul>
@ -117,7 +118,7 @@ export default {
return this.activeOption === option; return this.activeOption === option;
}, },
onKeyDown(event, option, index) { onKeyDown(event, option, index) {
switch (event.key) { switch (event.code) {
case 'Down': case 'Down':
case 'ArrowDown': case 'ArrowDown':
var nextItem = this.$el.children[index + 1]; var nextItem = this.$el.children[index + 1];
@ -157,6 +158,7 @@ export default {
break; break;
case 'Enter': case 'Enter':
case 'Space':
this.onOptionClick(event, option); this.onOptionClick(event, option);
break; break;
} }

View File

@ -3,7 +3,7 @@
<div class="content-section introduction"> <div class="content-section introduction">
<div class="feature-intro"> <div class="feature-intro">
<h1>CascadeSelect</h1> <h1>CascadeSelect</h1>
<p>CascadeSelect displays a nested structure of options.</p> <p>CascadeSelect is a form component to select a value from a nested structure of options.</p>
</div> </div>
<AppDemoActions /> <AppDemoActions />
</div> </div>

View File

@ -25,87 +25,85 @@ import CascadeSelect from 'primevue/cascadeselect';
</code></pre> </code></pre>
<pre v-code.script><code> <pre v-code.script><code>
data() { data() &#123;
data() &#123; return &#123;
return &#123; selectedCity: null,
selectedCity: null, countries: [
countries: [ &#123;
&#123; name: 'Australia',
name: 'Australia', code: 'AU',
code: 'AU', states: [
states: [ &#123;
&#123; name: 'New South Wales',
name: 'New South Wales', cities: [
cities: [ &#123;cname: 'Sydney', code: 'A-SY'&#125;,
&#123;cname: 'Sydney', code: 'A-SY'&#125;, &#123;cname: 'Newcastle', code: 'A-NE'&#125;,
&#123;cname: 'Newcastle', code: 'A-NE'&#125;, &#123;cname: 'Wollongong', code: 'A-WO'&#125;
&#123;cname: 'Wollongong', code: 'A-WO'&#125; ]
] &#125;,
&#125;, &#123;
&#123; name: 'Queensland',
name: 'Queensland', cities: [
cities: [ &#123;cname: 'Brisbane', code: 'A-BR'&#125;,
&#123;cname: 'Brisbane', code: 'A-BR'&#125;, &#123;cname: 'Townsville', code: 'A-TO'&#125;
&#123;cname: 'Townsville', code: 'A-TO'&#125; ]
] &#125;,
&#125;,
] ]
&#125;, &#125;,
&#123; &#123;
name: 'Canada', name: 'Canada',
code: 'CA', code: 'CA',
states: [ states: [
&#123; &#123;
name: 'Quebec', name: 'Quebec',
cities: [ cities: [
&#123;cname: 'Montreal', code: 'C-MO'&#125;, &#123;cname: 'Montreal', code: 'C-MO'&#125;,
&#123;cname: 'Quebec City', code: 'C-QU'&#125; &#123;cname: 'Quebec City', code: 'C-QU'&#125;
] ]
&#125;, &#125;,
&#123; &#123;
name: 'Ontario', name: 'Ontario',
cities: [ cities: [
&#123;cname: 'Ottawa', code: 'C-OT'&#125;, &#123;cname: 'Ottawa', code: 'C-OT'&#125;,
&#123;cname: 'Toronto', code: 'C-TO'&#125; &#123;cname: 'Toronto', code: 'C-TO'&#125;
] ]
&#125;, &#125;,
] ]
&#125;, &#125;,
&#123; &#123;
name: 'United States', name: 'United States',
code: 'US', code: 'US',
states: [ states: [
&#123; &#123;
name: 'California', name: 'California',
cities: [ cities: [
&#123;cname: 'Los Angeles', code: 'US-LA'&#125;, &#123;cname: 'Los Angeles', code: 'US-LA'&#125;,
&#123;cname: 'San Diego', code: 'US-SD'&#125;, &#123;cname: 'San Diego', code: 'US-SD'&#125;,
&#123;cname: 'San Francisco', code: 'US-SF'&#125; &#123;cname: 'San Francisco', code: 'US-SF'&#125;
] ]
&#125;, &#125;,
&#123; &#123;
name: 'Florida', name: 'Florida',
cities: [ cities: [
&#123;cname: 'Jacksonville', code: 'US-JA'&#125;, &#123;cname: 'Jacksonville', code: 'US-JA'&#125;,
&#123;cname: 'Miami', code: 'US-MI'&#125;, &#123;cname: 'Miami', code: 'US-MI'&#125;,
&#123;cname: 'Tampa', code: 'US-TA'&#125;, &#123;cname: 'Tampa', code: 'US-TA'&#125;,
&#123;cname: 'Orlando', code: 'US-OR'&#125; &#123;cname: 'Orlando', code: 'US-OR'&#125;
] ]
&#125;, &#125;,
&#123; &#123;
name: 'Texas', name: 'Texas',
cities: [ cities: [
&#123;cname: 'Austin', code: 'US-AU'&#125;, &#123;cname: 'Austin', code: 'US-AU'&#125;,
&#123;cname: 'Dallas', code: 'US-DA'&#125;, &#123;cname: 'Dallas', code: 'US-DA'&#125;,
&#123;cname: 'Houston', code: 'US-HO'&#125; &#123;cname: 'Houston', code: 'US-HO'&#125;
] ]
&#125; &#125;
] ]
&#125; &#125;
] ]
&#125;
&#125; &#125;
} }
@ -360,6 +358,101 @@ 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 cascadeselect 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. 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>
<p>If filtering is enabled, <i>filterInputProps</i> can be defined to give <i>aria-*</i> props to the filter input element.</p>
<pre v-code><code>
&lt;span id="dd1"&gt;Options&lt;/span&gt;
&lt;CascadeSelect aria-labelledby="dd1" /&gt;
&lt;CascadeSelect 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 cascadeselect element.</td>
</tr>
<tr>
<td><i>space</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>
<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>Hides the popup and moves focus to the next tabbable element.</td>
</tr>
<tr>
<td><i>tab</i></td>
<td>Hides the popup and moves focus to the previous tabbable element.</td>
</tr>
<tr>
<td><i>enter</i></td>
<td>Selects the focused option and closes the popup.</td>
</tr>
<tr>
<td><i>space</i></td>
<td>Selects the focused option and closes the popup.</td>
</tr>
<tr>
<td><i>escape</i></td>
<td>Closes the popup, moves focus to the cascadeselect element.</td>
</tr>
<tr>
<td><i>down arrow</i></td>
<td>Moves focus to the next option.</td>
</tr>
<tr>
<td><i>up arrow</i></td>
<td>Moves focus to the previous option.</td>
</tr>
<tr>
<td><i>right arrow</i></td>
<td>If option is closed, opens the option otherwise moves focus to the first child option.</td>
</tr>
<tr>
<td><i>left arrow</i></td>
<td>If option is open, closes the option otherwise moves focus to the parent option.</td>
</tr>
</tbody>
</table>
</div>
</DevelopmentSection>
<h5>Dependencies</h5> <h5>Dependencies</h5>
<p>None.</p> <p>None.</p>
</AppDoc> </AppDoc>