Skip to content

Commit

Permalink
Fixed #3021 - Improve ContextMenu implementation for Accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
tugcekucukoglu committed Sep 23, 2022
1 parent fb04980 commit a6d30df
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 32 deletions.
12 changes: 12 additions & 0 deletions api-generator/components/contextmenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ const ContextMenuProps = [
type: 'boolean',
default: 'true',
description: "Whether to apply 'router-link-active-exact' class if route exactly matches the item path."
},
{
name: 'aria-label',
type: 'string',
default: 'null',
description: 'Defines a string value that labels an interactive element.'
},
{
name: 'aria-labelledby',
type: 'string',
default: 'null',
description: 'Identifier of the underlying input element.'
}
];

Expand Down
55 changes: 33 additions & 22 deletions src/components/contextmenu/ContextMenu.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
<template>
<Portal :appendTo="appendTo">
<transition name="p-contextmenu" @enter="onEnter" @leave="onLeave" @after-leave="onAfterLeave">
<div v-if="visible" :ref="containerRef" :class="containerClass" v-bind="$attrs">
<ContextMenuSub :model="model" :root="true" @leaf-click="onLeafClick" :template="$slots.item" :exact="exact" />
<transition name="p-contextmenu" @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave" @after-leave="onAfterLeave">
<div v-if="visible" :ref="containerRef" :class="containerClass" @keydown="onKeydown">
<span ref="firstHiddenFocusableElement" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="0"></span>
<ContextMenuSub ref="ul" :model="model" :root="true" @leaf-click="onLeafClick" :template="$slots.item" :exact="exact" :aria-labelledby="ariaLabelledby" :aria-label="ariaLabel" />
</div>
</transition>
</Portal>
</template>

<script>
import Portal from 'primevue/portal';
import { DomHandler, ZIndexUtils } from 'primevue/utils';
import ContextMenuSub from './ContextMenuSub.vue';
import Portal from 'primevue/portal';
export default {
name: 'ContextMenu',
inheritAttrs: false,
props: {
model: {
type: Array,
Expand All @@ -40,6 +40,14 @@ export default {
exact: {
type: Boolean,
default: true
},
'aria-labelledby': {
type: String,
default: null
},
'aria-label': {
type: String,
default: null
}
},
target: null,
Expand Down Expand Up @@ -71,16 +79,6 @@ export default {
}
},
methods: {
itemClick(event) {
const item = event.item;
if (item.command) {
item.command(event);
event.originalEvent.preventDefault();
}
this.hide();
},
toggle(event) {
if (this.visible) this.hide();
else this.show(event);
Expand All @@ -101,6 +99,15 @@ export default {
hide() {
this.visible = false;
},
onKeydown(event) {
if (event.code === 'ArrowDown') {
const firstListItem = DomHandler.findSingle(this.container, 'li.p-menuitem');
this.$refs.ul.navigateToFirstItem(firstListItem);
}
event.preventDefault();
},
onEnter(el) {
this.position();
this.bindOutsideClickListener();
Expand All @@ -110,6 +117,9 @@ export default {
ZIndexUtils.set('menu', el, this.baseZIndex + this.$primevue.config.zIndex.menu);
}
},
onAfterEnter() {
DomHandler.focus(this.$refs.firstHiddenFocusableElement);
},
onLeave() {
this.unbindOutsideClickListener();
this.unbindResizeListener();
Expand Down Expand Up @@ -204,13 +214,7 @@ export default {
},
computed: {
containerClass() {
return [
'p-contextmenu p-component',
{
'p-input-filled': this.$primevue.config.inputStyle === 'filled',
'p-ripple-disabled': this.$primevue.config.ripple === false
}
];
return ['p-contextmenu p-component', { 'p-focus': this.visible, 'p-input-filled': this.$primevue.config.inputStyle === 'filled', 'p-ripple-disabled': this.$primevue.config.ripple === false }];
}
},
components: {
Expand All @@ -231,6 +235,13 @@ export default {
list-style: none;
}
.p-contextmenu.p-focus {
outline: 0 none;
outline-offset: 0;
box-shadow: 0 0 0 0.2rem #bfdbfe;
border-color: #3b82f6;
}
.p-contextmenu .p-submenu-list {
position: absolute;
min-width: 100%;
Expand Down
200 changes: 190 additions & 10 deletions src/components/contextmenu/ContextMenuSub.vue
Original file line number Diff line number Diff line change
@@ -1,34 +1,62 @@
<template>
<transition name="p-contextmenusub" @enter="onEnter">
<ul v-if="root ? true : parentActive" ref="container" :class="containerClass" role="menu">
<ul v-if="root ? true : parentActive" ref="container" :class="containerClass" role="menubar" :tabindex="-1" aria-orientation="vertical">
<template v-for="(item, i) of model" :key="label(item) + i.toString()">
<li v-if="visible(item) && !item.separator" role="none" :class="getItemClass(item)" :style="item.style" @mouseenter="onItemMouseEnter($event, item)">
<li v-if="visible(item) && !item.separator" role="presentation" :class="getItemClass(item)" :style="item.style" @mouseenter="onItemMouseEnter($event, item)">
<template v-if="!template">
<router-link v-if="item.to && !disabled(item)" v-slot="{ navigate, href, isActive, isExactActive }" :to="item.to" custom>
<a v-ripple :href="href" @click="onItemClick($event, item, navigate)" :class="linkClass(item, { isActive, isExactActive })" role="menuitem">
<a
v-ripple
role="menuitem"
:href="href"
:class="linkClass(item, { isActive, isExactActive })"
:aria-haspopup="item.items != null"
:aria-expanded="item.items && item === activeItem"
:aria-controls="getMenuAction(i)"
:aria-label="label(item)"
:aria-disabled="disabled(item)"
:tabindex="-1"
@click="onItemClick($event, item, navigate)"
@keydown="onItemKeydown($event, item)"
>
<span v-if="item.icon" :class="['p-menuitem-icon', item.icon]"></span>
<span class="p-menuitem-text">{{ label(item) }}</span>
</a>
</router-link>
<a
v-else
v-ripple
role="menuitem"
:href="item.url"
:class="linkClass(item)"
:target="item.target"
@click="onItemClick($event, item)"
:aria-label="label(item)"
:aria-haspopup="item.items != null"
:aria-expanded="item === activeItem"
role="menuitem"
:tabindex="disabled(item) ? null : '0'"
:aria-expanded="item.items && item === activeItem"
:aria-controls="getMenuAction(i)"
:aria-disabled="disabled(item)"
:tabindex="-1"
@click="onItemClick($event, item)"
@keydown="onItemKeydown($event, item)"
>
<span v-if="item.icon" :class="['p-menuitem-icon', item.icon]"></span>
<span class="p-menuitem-text">{{ label(item) }}</span>
<span v-if="item.items" class="p-submenu-icon pi pi-angle-right"></span>
</a>
</template>
<component v-else :is="template" :item="item"></component>
<ContextMenuSub v-if="visible(item) && item.items" :key="label(item) + '_sub_'" :model="item.items" :template="template" @leaf-click="onLeafClick" :parentActive="item === activeItem" :exact="exact" />
<ContextMenuSub
v-if="visible(item) && item.items"
:key="label(item) + '_sub_'"
:id="getMenuAction(i)"
:model="item.items"
:template="template"
:parentActive="item === activeItem"
:exact="exact"
role="menu"
@keydown-item="onChildItemKeyDown"
@leaf-click="onLeafClick"
/>
</li>
<li v-if="visible(item) && item.separator" :key="'separator' + i.toString()" :class="['p-menu-separator', item.class]" :style="item.style" role="separator"></li>
</template>
Expand All @@ -37,12 +65,12 @@
</template>

<script>
import { DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple';
import { DomHandler, UniqueComponentId } from 'primevue/utils';
export default {
name: 'ContextMenuSub',
emits: ['leaf-click'],
emits: ['leaf-click', 'keydown-item'],
props: {
model: {
type: Array,
Expand Down Expand Up @@ -113,11 +141,157 @@ export default {
if (item.to && navigate) {
navigate(event);
}
if (item.url && event.currentTarget) {
event.currentTarget.click();
}
},
onItemKeydown(event, item) {
const listItem = event.target.parentElement;
switch (event.code) {
case 'ArrowDown': {
this.navigateToNextItem(listItem);
event.preventDefault();
event.stopPropagation();
break;
}
case 'ArrowUp': {
this.navigateToPrevItem(listItem);
event.preventDefault();
break;
}
case 'ArrowRight': {
this.expandSubmenu(event, item);
event.preventDefault();
break;
}
case 'ArrowLeft': {
//no op
}
case 'Home': {
this.navigateToFirstItem(listItem);
event.preventDefault();
break;
}
case 'End': {
this.navigateToLastItem(listItem);
event.preventDefault();
break;
}
case 'Enter':
case 'Space': {
this.onItemClick(event, item);
event.preventDefault();
break;
}
case 'Tab': {
//no op
}
case 'Escape': {
this.onLeafClick();
event.preventDefault();
break;
}
default:
break;
}
this.$emit('keydown-item', {
originalEvent: event,
element: listItem
});
},
onChildItemKeyDown(event) {
if (event.originalEvent.code === 'ArrowLeft') {
this.collapseSubmenu(event.element);
} else if (event.originalEvent.code === 'Tab') {
this.onLeafClick();
}
},
expandSubmenu(event, item) {
this.activeItem = item;
this.onItemMouseEnter(event, item);
setTimeout(() => {
const nextLink = DomHandler.findSingle(event.target.nextElementSibling, 'a.p-menuitem-link:not(.p-disabled)');
if (nextLink) {
nextLink.tabIndex = '0';
nextLink.focus();
}
}, 0);
},
collapseSubmenu(listItem) {
this.activeItem = null;
listItem.children[0].tabIndex = '-1';
listItem.parentElement.previousElementSibling.focus();
},
onLeafClick() {
this.activeItem = null;
this.$emit('leaf-click');
},
navigateToNextItem(listItem) {
const nextItem = this.findNextItem(listItem);
nextItem && this.setFocusToMenuitem(listItem.children[0], nextItem.children[0]);
if (nextItem) {
listItem.children[0].tabIndex = '-1';
nextItem.children[0].tabIndex = '0';
nextItem.children[0].focus();
}
},
navigateToPrevItem(listItem) {
const prevItem = this.findPrevItem(listItem);
prevItem && this.setFocusToMenuitem(listItem.children[0], prevItem.children[0]);
},
navigateToFirstItem(listItem) {
const firstItem = this.findFirstItem(listItem);
firstItem && this.setFocusToMenuitem(listItem.children[0], firstItem.children[0]);
},
navigateToLastItem(listItem) {
const lastItem = this.findLastItem(listItem);
lastItem && this.setFocusToMenuitem(listItem.children[0], lastItem.children[0]);
},
findNextItem(listItem) {
const nextItem = listItem.nextElementSibling;
return nextItem ? (DomHandler.hasClass(nextItem.children[0], 'p-disabled') || !DomHandler.hasClass(nextItem, 'p-menuitem') ? this.findNextItem(nextItem) : nextItem) : null;
},
findPrevItem(listItem) {
const prevItem = listItem.previousElementSibling;
return prevItem ? (DomHandler.hasClass(prevItem.children[0], 'p-disabled') || !DomHandler.hasClass(prevItem, 'p-menuitem') ? this.findPrevItem(prevItem) : prevItem) : null;
},
findFirstItem(listItem) {
const firstSibling = DomHandler.findSingle(listItem.parentElement, 'li.p-menuitem');
return firstSibling ? (DomHandler.hasClass(firstSibling.children[0], 'p-disabled') || !DomHandler.hasClass(firstSibling, 'p-menuitem') ? this.findPrevItem(firstSibling) : firstSibling) : null;
},
findLastItem(listItem) {
const lastSibling = DomHandler.find(listItem.parentElement, 'li.p-menuitem')[DomHandler.find(listItem.parentElement, 'li.p-menuitem').length - 1];
return lastSibling ? (DomHandler.hasClass(lastSibling.children[0], 'p-disabled') || !DomHandler.hasClass(lastSibling, 'p-menuitem') ? this.findPrevItem(lastSibling) : lastSibling) : null;
},
setFocusToMenuitem(target, focusableItem) {
target.tabIndex = '-1';
focusableItem.tabIndex = '0';
focusableItem.focus();
},
onEnter() {
this.position();
},
Expand Down Expand Up @@ -163,11 +337,17 @@ export default {
},
label(item) {
return typeof item.label === 'function' ? item.label() : item.label;
},
getMenuAction(index) {
return `${this.id}_${index}_menu_action`;
}
},
computed: {
containerClass() {
return { 'p-submenu-list': !this.root };
},
id() {
return UniqueComponentId();
}
},
directives: {
Expand Down
Loading

0 comments on commit a6d30df

Please sign in to comment.