Skip to content

Commit 64826a2

Browse files
committed
adding support for keyboard accessibility
minor fixes add lint-staged
1 parent 34765b7 commit 64826a2

File tree

9 files changed

+485
-147
lines changed

9 files changed

+485
-147
lines changed

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@
2222
"build": "vite build",
2323
"rollup": "rimraf ./dist && rollup -c",
2424
"lint": "eslint src/**/*.vue",
25-
"lint:css": "stylelint src/**/*.vue"
25+
"lint:css": "stylelint src/**/*.scss"
2626
},
2727
"husky": {
2828
"hooks": {
29-
"pre-commit": "yarn lint:css",
30-
"pre-push": "yarn lint"
29+
"pre-commit": "lint-staged"
3130
}
3231
},
32+
"lint-staged": {
33+
"src/**/*.scss": ["stylelint src/**/*.scss", "git add"],
34+
"src/**/*.vue": ["eslint src/**/*.vue", "git add"]
35+
},
3336
"dependencies": {
3437
"nanoid": "^3.1.12",
3538
"vue": "^3.0.0",
@@ -47,6 +50,7 @@
4750
"eslint": "^7.10.0",
4851
"eslint-plugin-vue": "^7.0.0-beta.4",
4952
"husky": "^4.3.0",
53+
"lint-staged": "^10.4.0",
5054
"rollup": "^2.28.1",
5155
"rollup-plugin-buble": "^0.19.8",
5256
"rollup-plugin-commonjs": "^10.1.0",

src/components/Menu.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ $shadow: rgba(0, 0, 0, 0.2) 2px 2px 10px 2px;
99
height: 100%;
1010
justify-content: flex-start;
1111
width: 100%;
12+
outline: 0;
1213
}
1314

1415
.sub-menu-wrapper {
15-
animation: show 0.2s ease-in;
16+
animation: show 0.1s ease-in;
1617
border-radius: 0.5rem;
1718
box-shadow: $shadow;
1819
left: 102%;
@@ -76,6 +77,10 @@ $shadow: rgba(0, 0, 0, 0.2) 2px 2px 10px 2px;
7677
color: $black;
7778
}
7879

80+
&.highlight {
81+
border: 1px solid red;
82+
}
83+
7984
&.flip {
8085
.name {
8186
margin-left: auto;

src/components/Menu.vue

Lines changed: 161 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,48 @@
11
<template>
22
<div
3+
ref="menuRef"
34
class="menu-wrapper"
5+
tabindex="0"
6+
@keyup="handleKeyUp"
47
>
58
<ul
69
class="menu-list"
710
:style="getTheme"
811
>
912
<li
10-
v-for="item of menuItems"
11-
:key="item.id"
12-
:class="[{ selected: item.selected, flip }, 'menu-list-item']"
13+
v-for="({ id, selected, name, subMenu, showSubMenu },
14+
index) of menuItems"
15+
:key="id"
16+
:class="[
17+
{ selected: selected, flip, 'sub-menu': subMenu },
18+
'menu-list-item',
19+
]"
1320
:style="getTheme"
14-
@mousedown="
15-
handleMenuItemClick($event, item.id, item.name, item.subMenu)
16-
"
21+
@mousedown="handleMenuItemClick($event, id, name, subMenu, index)"
1722
>
18-
<span class="name">{{ item.name }}</span>
1923
<span
20-
v-if="item.subMenu"
21-
class="chev-icon"
24+
class="name"
25+
@click="$event.stopPropagation()"
26+
>{{ name }}</span>
27+
<span
28+
v-if="subMenu"
29+
class="chev-icon"
30+
@click="$event.stopPropagation()"
2231
>
2332
<ChevRightIcon />
2433
</span>
2534
<span
26-
v-if="item.subMenu && item.showSubMenu"
35+
v-if="subMenu && showSubMenu"
2736
class="sub-menu-wrapper"
2837
:style="getTheme"
2938
>
3039
<component
3140
:is="SubMenuComponent"
32-
:data="item.subMenu.items"
41+
:data="subMenu.items"
3342
:on-selection="onSelection"
3443
:theme="theme"
44+
:on-close="handleSubmenuClose"
45+
:flip="flip"
3546
/>
3647
</span>
3748
</li>
@@ -46,21 +57,13 @@ import {
4657
ref,
4758
resolveComponent,
4859
computed,
60+
onMounted,
61+
watch,
62+
nextTick,
4963
} from "vue";
5064
import { nanoid } from "nanoid";
5165
import ChevRightIcon from "./icons/ChevRightIcon.vue";
52-
53-
export type Menu = {
54-
items: MenuItem[];
55-
};
56-
57-
export type MenuItem = {
58-
name: string;
59-
subMenu?: Menu;
60-
id?: string;
61-
showSubMenu?: boolean;
62-
selected?: boolean;
63-
};
66+
import { MenuItem } from "./types";
6467
6568
export default defineComponent({
6669
name: "Menu",
@@ -80,6 +83,11 @@ export default defineComponent({
8083
type: Function as PropType<(name: string, parent?: string) => void>,
8184
default: null,
8285
},
86+
onClose: {
87+
type: Function as PropType<(keyCodeUsed?: number) => void>,
88+
default: null,
89+
required: true,
90+
},
8391
theme: {
8492
type: Object as PropType<{
8593
primary: string;
@@ -97,6 +105,10 @@ export default defineComponent({
97105
},
98106
},
99107
setup(props) {
108+
// tracks the index of the selected menu item
109+
const activeIndex = ref(-1);
110+
111+
// gene unique ids for the menu items
100112
const menuItems = ref<MenuItem[]>(
101113
props.data.map((item) =>
102114
Object.assign({}, item, {
@@ -106,47 +118,161 @@ export default defineComponent({
106118
)
107119
);
108120
121+
// reference to the menu itself
122+
const menuRef = ref<HTMLElement>();
123+
124+
// resolve this component for usage innested menus
109125
const SubMenuComponent = resolveComponent("Menu");
110126
127+
const selectMenuItem = (
128+
id: string,
129+
name: string,
130+
subMenu?: boolean,
131+
selectFirstItem?: boolean
132+
) => {
133+
if (!subMenu) {
134+
props.onSelection && props.onSelection(name);
135+
} else {
136+
expandMenu(id, selectFirstItem);
137+
}
138+
};
139+
140+
// expands the submenu
141+
const expandMenu = (id: string, selectFirstItem?: boolean) => {
142+
menuItems.value = menuItems.value.map((item) =>
143+
Object.assign({}, item, {
144+
showSubMenu: item.id === id,
145+
subMenu:
146+
selectFirstItem && item.id === id
147+
? {
148+
items: item.subMenu?.items.map((x, index) =>
149+
Object.assign({}, x, {
150+
selected: index === 0,
151+
})
152+
),
153+
}
154+
: item.subMenu,
155+
})
156+
);
157+
};
158+
111159
const handleMenuItemClick = (
112160
event: MouseEvent,
113161
id: string,
114162
name: string,
115-
subMenu: boolean
163+
subMenu: boolean,
164+
index: number
116165
) => {
117166
event.stopPropagation();
118167
event.preventDefault();
119168
120-
menuItems.value = menuItems.value.map((item) => {
121-
const active = item.id === id && item.subMenu && !item.selected;
122-
return Object.assign({}, item, {
123-
showSubMenu: active,
124-
selected: active,
125-
});
126-
});
169+
activeIndex.value = index;
127170
128-
if (!subMenu) {
129-
props.onSelection && props.onSelection(name);
130-
}
171+
selectMenuItem(id, name, subMenu, false);
131172
};
132173
174+
// gets theme colors
133175
const getTheme = computed(() => ({
134176
"--background": props.theme.primary,
135177
"--menu-background": props.theme.menuBgColor,
136178
"--menu-text-color": props.theme.textColor,
137179
"--text-selected-color": props.theme.textSelectedColor,
138180
}));
139181
182+
// life cycle mount
183+
onMounted(() => {
184+
// focus the menu on mount
185+
menuRef.value?.focus();
186+
187+
// reset the activeindex to 0, if first item is already selected.
188+
// this is mostly the case while navigating via keyboard
189+
nextTick(() => {
190+
const isFirstItemSelected = props.data[0].selected;
191+
if (isFirstItemSelected) {
192+
activeIndex.value = 0;
193+
}
194+
});
195+
});
196+
197+
// keyboard nav handler
198+
const handleKeyUp = (event: KeyboardEvent) => {
199+
event.preventDefault();
200+
event.stopPropagation();
201+
const actvIndex = activeIndex.value;
202+
203+
// get the active item
204+
const item = menuItems.value[actvIndex > -1 ? actvIndex : 0];
205+
const keyCode = event.keyCode;
206+
207+
// handle down arrow
208+
if (keyCode === 40) {
209+
if (actvIndex < props.data.length - 1) {
210+
activeIndex.value += 1;
211+
}
212+
// handle up arrow
213+
} else if (keyCode === 38) {
214+
if (actvIndex > 0) {
215+
activeIndex.value -= 1;
216+
}
217+
// handle left arrow
218+
} else if (keyCode === 37) {
219+
if (!props.flip) {
220+
props.onClose(keyCode);
221+
} else {
222+
item && item.id && item.subMenu && expandMenu(item.id, true);
223+
}
224+
// handle enter
225+
} else if (keyCode === 13) {
226+
if (item && item.subMenu && item.id) {
227+
expandMenu(item.id, true);
228+
} else if (item.id) {
229+
selectMenuItem(item.id, item.name, !!item.subMenu);
230+
}
231+
// handle right arrow
232+
} else if (keyCode === 39) {
233+
if (!props.flip) {
234+
if (item && item.id && item.subMenu) {
235+
expandMenu(item.id, true);
236+
}
237+
} else {
238+
props.onClose(keyCode);
239+
}
240+
} else if (keyCode === 27) {
241+
props.onClose();
242+
}
243+
};
244+
245+
const handleSubmenuClose = () => {
246+
menuItems.value = menuItems.value.map((item) => {
247+
return Object.assign({}, item, {
248+
showSubMenu: false,
249+
});
250+
});
251+
menuRef.value?.focus();
252+
};
253+
254+
watch(activeIndex, (val) => {
255+
menuItems.value = menuItems.value.map((item, index) => {
256+
return Object.assign({}, item, {
257+
selected: index === val,
258+
});
259+
});
260+
});
261+
140262
return {
141263
menuItems,
142264
handleMenuItemClick,
143265
SubMenuComponent,
144266
getTheme,
267+
menuRef,
268+
handleKeyUp,
269+
activeIndex,
270+
handleSubmenuClose,
145271
};
146272
},
147273
});
148274
</script>
149275

150276

151277
<style lang="scss" scoped src="./Menu.scss">
152-
</style>
278+
</style>handleBlur

src/components/index.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ $cubic: cubic-bezier(0.25, 0.46, 0.45, 0.94);
22

33
.menu-head-wrapper {
44
position: fixed;
5+
// z-index: 9999;
56

67
&:not(.dragActive) {
78
transition: left 0.2s $cubic, right 0.2s $cubic, top 0.2s $cubic, bottom 0.2s $cubic;
@@ -25,7 +26,7 @@ $cubic: cubic-bezier(0.25, 0.46, 0.45, 0.94);
2526
}
2627
}
2728

28-
.icon {
29+
.menu-head-icon {
2930
align-items: center;
3031
color: #fff;
3132
display: flex;

0 commit comments

Comments
 (0)