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" ;
5064import { nanoid } from " nanoid" ;
5165import 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
6568export 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
0 commit comments