Skip to content
This repository was archived by the owner on Oct 5, 2022. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion dist/js/vue-context.js

This file was deleted.

27 changes: 24 additions & 3 deletions src/js/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import fromPolyfill from 'core-js/library/fn/array/from';
import isArrayPolyfill from 'core-js/library/fn/array/is-array';
if (! Array.from) {
Array.from = object => {
'use strict';

return [].slice.call(object);
};
}

if (! Array.isArray) {
Array.isArray = arg => Object.prototype.toString.call(arg) === '[object Array]';
}

// --- Constants ---
const arrayFrom = Array.from || fromPolyfill;
Expand All @@ -8,7 +17,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 +53,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 +83,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;
};
129 changes: 119 additions & 10 deletions src/js/vue-context.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
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';

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

Expand All @@ -68,12 +78,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 +144,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 +174,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 +203,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 +217,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 +281,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;
}
}
}
Loading