Skip to content
This repository has been archived by the owner on Oct 5, 2022. It is now read-only.

Commit

Permalink
Add nested context menus
Browse files Browse the repository at this point in the history
Nested context menus are created by adding the class v-context__sub to a menu element. Inside the v-context__sub a new container (eg. a UL element) with the class v-context must be created. The v-context container will be opened if the v-context__sub is hovered or the menu element is expanded by keyboard right click.
  • Loading branch information
wol-soft committed Oct 8, 2019
1 parent 3d34638 commit b2d17e5
Show file tree
Hide file tree
Showing 6 changed files with 250 additions and 53 deletions.
2 changes: 1 addition & 1 deletion dist/js/vue-context.js

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion src/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export const isArray = Array.isArray || isArrayPolyfill;

export const keyCodes = {
ESC: 27,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40
};

Expand Down Expand Up @@ -42,7 +44,7 @@ export const filterVisible = elements => (elements || []).filter(isVisible);

// Return the Bounding Client Rect of an element
// Returns `null` if not an element
const getBCR = el => (isElement(el) ? el.getBoundingClientRect() : null);
export const getBCR = el => (isElement(el) ? el.getBoundingClientRect() : null);

// Determine if an element is an HTML element
const isElement = el => Boolean(el && el.nodeType === Node.ELEMENT_NODE);
Expand Down Expand Up @@ -72,3 +74,13 @@ export const setAttr = (el, attr, value) => {
el.setAttribute(attr, value);
}
};

export const parentElementByClassName = (element, className) => {
let parentElement = element.parentElement;

while (parentElement !== null && !parentElement.classList.contains(className)) {
parentElement = parentElement.parentElement;
}

return parentElement;
};
128 changes: 119 additions & 9 deletions src/js/vue-context.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { directive as onClickaway } from 'vue-clickaway/index';
import { eventOff, eventOn, filterVisible, isArray, keyCodes, selectAll, setAttr } from './utils';
import {
eventOff,
eventOn,
filterVisible,
isArray,
keyCodes,
selectAll,
setAttr,
getBCR,
parentElementByClassName
} from './utils';
import { normalizeSlot } from './normalize-slot';
import '../sass/vue-context.scss';

Expand Down Expand Up @@ -49,7 +59,8 @@ export default {
left: null,
show: false,
data: null,
localItemSelector: ''
localItemSelector: '',
activeSubMenu: null,
};
},

Expand All @@ -68,12 +79,27 @@ export default {
eventOn(window, 'scroll', this.close);
},

addHoverEventListener(element) {
element.querySelectorAll('.v-context__sub').forEach(
(subMenuNode) => {
eventOn(subMenuNode, 'mouseenter', this.openSubMenu);
eventOn(subMenuNode, 'mouseleave', this.closeSubMenu);
}
);
},

close() {
if (! this.show) {
return;
}

// make sure all sub menus are closed
while (this.activeSubMenu !== null) {
parentElementByClassName(this.activeSubMenu, 'v-context__sub').dispatchEvent(new Event('mouseleave'));
}

this.resetData();
this.removeHoverEventListener(this.$el);

if (this.closeOnScroll) {
this.removeScrollEventListener();
Expand Down Expand Up @@ -119,7 +145,8 @@ export default {
},

getItems() {
return filterVisible(selectAll(this.localItemSelector, this.$el));
// if a sub menu is active only return the elements of the sub menu to keep the scope
return filterVisible(selectAll(this.localItemSelector, this.activeSubMenu || this.$el));
},

mapItemSelector(itemSelector) {
Expand Down Expand Up @@ -148,6 +175,27 @@ export default {
} else if (key === keyCodes.UP) {
// Up arrow
this.focusNext(event, true);
} else if (key === keyCodes.RIGHT) {
// check if a parent element which is associated with a sub menu can be found.
const menuContainer = parentElementByClassName(event.target, 'v-context__sub');

// try to open a sub menu if the sub menu isn't the current sub menu
if (menuContainer && menuContainer.getElementsByClassName('v-context')[0] !== this.activeSubMenu) {
menuContainer.dispatchEvent(new Event('mouseenter'));
this.focusNext(event, false);
}
} else if (key === keyCodes.LEFT) {
if (!this.activeSubMenu) {
return;
}

const parentMenu = parentElementByClassName(this.activeSubMenu, 'v-context__sub');
parentMenu.dispatchEvent(new Event('mouseleave'));

const items = this.getItems(),
index = items.indexOf(parentMenu.getElementsByTagName('a')[0]);

this.focusItem(index, items);
}
},

Expand All @@ -156,9 +204,11 @@ export default {
this.show = true;

this.$nextTick(() => {
this.positionMenu(event.clientY, event.clientX);
[this.top, this.left] = this.positionMenu(event.clientY, event.clientX, this.$el);

this.$el.focus();
this.setItemRoles();
this.addHoverEventListener(this.$el);

if (this.closeOnScroll) {
this.addScrollEventListener();
Expand All @@ -168,9 +218,61 @@ export default {
});
},

positionMenu(top, left) {
const largestHeight = window.innerHeight - this.$el.offsetHeight - 25;
const largestWidth = window.innerWidth - this.$el.offsetWidth - 25;
openSubMenu (event) {
const subMenuElement = this.getSubMenuElementByEvent(event),
parentMenu = parentElementByClassName(subMenuElement.parentElement, 'v-context'),
bcr = getBCR(event.target);

// check if another sub menu is open. In this case make sure no other as well as no nested sub menu is open
if (this.activeSubMenu !== parentMenu) {
while (this.activeSubMenu !== null
&& this.activeSubMenu !== parentMenu
&& this.activeSubMenu !== subMenuElement
) {
parentElementByClassName(this.activeSubMenu, 'v-context__sub')
.dispatchEvent(new Event('mouseleave'));
}
}

// first set the display and afterwards execute position calculation for correct element offsets
subMenuElement.style.display = 'block';

let [elementTop, elementLeft] = this.positionMenu(bcr.top, bcr.right - 10, subMenuElement);

subMenuElement.style.left = `${elementLeft}px`;
subMenuElement.style.top = `${elementTop}px`;

this.activeSubMenu = subMenuElement;
},

closeSubMenu (event) {
const subMenuElement = this.getSubMenuElementByEvent(event),
parentMenu = parentElementByClassName(subMenuElement, 'v-context');

// if a sub menu is closed and it's not the currently active sub menu (eg. a lowe layered sub menu closed
// by a mouseleave event) close all nested sub menus
if (this.activeSubMenu !== subMenuElement) {
while (this.activeSubMenu !== null && this.activeSubMenu !== subMenuElement) {
parentElementByClassName(this.activeSubMenu, 'v-context__sub')
.dispatchEvent(new Event('mouseleave'));
}
}

subMenuElement.style.display = 'none';

// check if a parent menu exists and the parent menu is a sub menu to keep track of the correct sub menu
this.activeSubMenu = parentMenu && parentElementByClassName(parentMenu, 'v-context__sub')
? parentMenu
: null;
},

getSubMenuElementByEvent (event) {
return event.target.getElementsByTagName('ul')[0];
},

positionMenu(top, left, element) {
const largestHeight = window.innerHeight - element.offsetHeight - 25;
const largestWidth = window.innerWidth - element.offsetWidth - 25;

if (top > largestHeight) {
top = largestHeight;
Expand All @@ -180,14 +282,22 @@ export default {
left = largestWidth;
}

this.top = top;
this.left = left;
return [top, left];
},

removeScrollEventListener() {
eventOff(window, 'scroll', this.close);
},

removeHoverEventListener(element) {
element.querySelectorAll('.v-context__sub').forEach(
(subMenuNode) => {
eventOff(subMenuNode, 'mouseenter', this.openSubMenu);
eventOff(subMenuNode, 'mouseleave', this.closeSubMenu);
}
);
},

resetData() {
this.top = null;
this.left = null;
Expand Down
86 changes: 52 additions & 34 deletions src/sass/vue-context.scss
Original file line number Diff line number Diff line change
@@ -1,47 +1,65 @@
@import "config";

.v-context {
background-color: $menu-bg;
background-clip: padding-box;
border-radius: .25rem;
border: 1px solid $menu-border;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
display: block;
margin: 0;
padding: 10px 0;
min-width: 10rem;
z-index: 1500;
position: fixed;
list-style: none;
box-sizing: border-box;

> li {

&, & ul {
background-color: $menu-bg;
background-clip: padding-box;
border-radius: .25rem;
border: 1px solid $menu-border;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 1px 5px 0 rgba(0, 0, 0, 0.12);
display: block;
margin: 0;
padding: 10px 0;
min-width: 10rem;
z-index: 1500;
position: fixed;
list-style: none;
box-sizing: border-box;
max-height: calc(100% - 50px);
overflow-y: auto;

> li {
margin: 0;
position: relative;

> a {
display: block;
padding: .5rem 1.5rem;
font-weight: 400;
color: $item-color;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border: 0;

&:hover,
&:focus {
> a {
display: block;
padding: .5rem 1.5rem;
font-weight: 400;
color: $item-color;
text-decoration: none;
color: $item-hover-color;
background-color: $item-hover-bg;
}
white-space: nowrap;
background-color: transparent;
border: 0;

&:focus {
outline: 0;
&:hover,
&:focus {
text-decoration: none;
color: $item-hover-color;
background-color: $item-hover-bg;
}

&:focus {
outline: 0;
}
}
}

&:focus {
outline: 0;
}
}

&:focus {
outline: 0;
&__sub {
> a:after {
content: "\2bc8";
float: right;
padding-left: 1rem;
}

> ul {
display: none;
}
}
}
59 changes: 58 additions & 1 deletion test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,69 @@
Do something
</a>
</li>
<li>
<li class="v-context__sub">
<a href="#" class="v-context-item"
@click.prevent="onClick('item 2')"
>
Do something else
</a>
<ul class="v-context">
<li tabindex="0">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub item 1')"
>
Submenu
</a>
</li>
<li class="v-context__sub">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub item 2')"
>
Submenu next
</a>

<ul class="v-context">
<li tabindex="0">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub sub item 1')"
>
We need to go deeper
</a>
</li>
<li tabindex="0">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub sub item 2')"
>
double nested Submenu
</a>
</li>
</ul>
</li>
<li>
<a href="#" class="v-context-item"
@click.prevent="onClick('sub item 3')"
>
Submenu next
</a>
</li>
<li class="v-context__sub">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub item 4')"
>
second nested Submenu
</a>

<ul class="v-context">
<li tabindex="0">
<a href="#" class="v-context-item"
@click.prevent="onClick('sub sub item 3')"
>
sub sub
</a>
</li>
</ul>
</li>
</ul>
</li>
<li>
<a href="#" class="v-context-item"
Expand Down
Loading

0 comments on commit b2d17e5

Please sign in to comment.