mirror of
https://github.com/tenrok/vue-context.git
synced 2026-06-21 01:00:31 +03:00
Accessibility (#22)
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
import VueContext from './vue-context';
|
||||
|
||||
export { VueContext };
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VueContext } from './vue-context';
|
||||
@@ -0,0 +1,6 @@
|
||||
export const normalizeSlot = (name, scope = {}, $scopedSlots = {}, $slots = {}) => {
|
||||
// Note: in Vue 2.6.x, all named slots are also scoped slots
|
||||
const slot = $scopedSlots[name] || $slots[name];
|
||||
|
||||
return typeof slot === 'function' ? slot(scope) : slot;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import fromPolyfill from 'core-js/library/fn/array/from';
|
||||
import isArrayPolyfill from 'core-js/library/fn/array/is-array';
|
||||
|
||||
// --- Constants ---
|
||||
const arrayFrom = Array.from || fromPolyfill;
|
||||
|
||||
export const isArray = Array.isArray || isArrayPolyfill;
|
||||
|
||||
export const keyCodes = {
|
||||
ESC: 27,
|
||||
UP: 38,
|
||||
DOWN: 40
|
||||
};
|
||||
|
||||
// --- Dom Utils ---
|
||||
|
||||
// Returns true if the parent element contains the child element
|
||||
const contains = (parent, child) => {
|
||||
if (! parent || typeof parent.contains !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent.contains(child);
|
||||
};
|
||||
|
||||
// Attach an event listener to an element
|
||||
export const eventOn = (el, eventName, handler) => {
|
||||
if (el && el.addEventListener) {
|
||||
el.addEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove an event listener from an element
|
||||
export const eventOff = (el, eventName, handler) => {
|
||||
if (el && el.removeEventListener) {
|
||||
el.removeEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter visible elements
|
||||
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);
|
||||
|
||||
// Determine if an element is an HTML element
|
||||
const isElement = el => Boolean(el && el.nodeType === Node.ELEMENT_NODE);
|
||||
|
||||
// Determine if an HTML element is visible - Faster than CSS check
|
||||
const isVisible = el => {
|
||||
if (! isElement(el) || ! contains(document.body, el)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.style.display === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bcr = getBCR(el);
|
||||
|
||||
return Boolean(bcr && bcr.height > 0 && bcr.width > 0);
|
||||
};
|
||||
|
||||
// Select all elements matching a selector. Returns `[]` if none found
|
||||
export const selectAll = (selector, root) =>
|
||||
arrayFrom((isElement(root) ? root : document).querySelectorAll(selector));
|
||||
|
||||
// Set an attribute on an element
|
||||
export const setAttr = (el, attr, value) => {
|
||||
if (attr && isElement(el)) {
|
||||
el.setAttribute(attr, value);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import { directive as onClickaway } from 'vue-clickaway/index';
|
||||
import { eventOff, eventOn, filterVisible, isArray, keyCodes, selectAll, setAttr } from './utils';
|
||||
import { normalizeSlot } from './normalize-slot';
|
||||
import '../sass/vue-context.scss';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
onClickaway
|
||||
},
|
||||
|
||||
props: {
|
||||
closeOnClick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
lazy: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
itemSelector: {
|
||||
type: [String, Array],
|
||||
default: () => ['.v-context-item', '.v-context > li > a']
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
default: 'menu'
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'ul'
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
style() {
|
||||
return this.show
|
||||
? { top: `${this.top}px`, left: `${this.left}px` }
|
||||
: null;
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
top: null,
|
||||
left: null,
|
||||
show: false,
|
||||
data: null,
|
||||
localItemSelector: this.itemSelector
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
if (isArray(this.localItemSelector)) {
|
||||
this.localItemSelector = this.localItemSelector
|
||||
.map(selector => `${selector}:not(.disabled):not([disabled])`)
|
||||
.join(', ');
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if (this.closeOnScroll) {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
addScrollEventListener() {
|
||||
eventOn(window, 'scroll', this.close);
|
||||
},
|
||||
|
||||
close(emit = true) {
|
||||
if (! this.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetData();
|
||||
|
||||
if (this.closeOnScroll) {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
|
||||
if (emit) {
|
||||
this.$emit('close');
|
||||
}
|
||||
},
|
||||
|
||||
focusItem(index, items) {
|
||||
const el = items.find((el, idx) => idx === index);
|
||||
if (el && el.focus) {
|
||||
el.focus();
|
||||
}
|
||||
},
|
||||
|
||||
focusNext(event, up) {
|
||||
if (! this.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.$nextTick(() => {
|
||||
const items = this.getItems();
|
||||
if (items.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = items.indexOf(event.target);
|
||||
if (up && index > 0) {
|
||||
index--;
|
||||
} else if (! up && index < items.length - 1) {
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index < 0) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
this.focusItem(index, items);
|
||||
});
|
||||
},
|
||||
|
||||
getItems() {
|
||||
return filterVisible(selectAll(this.localItemSelector, this.$el));
|
||||
},
|
||||
|
||||
onClick() {
|
||||
this.close(false);
|
||||
},
|
||||
|
||||
onKeydown(event) {
|
||||
const key = event.keyCode;
|
||||
|
||||
if (key === keyCodes.ESC) {
|
||||
// Close on esc
|
||||
this.close();
|
||||
} else if (key === keyCodes.DOWN) {
|
||||
// Down arrow
|
||||
this.focusNext(event, false);
|
||||
} else if (key === keyCodes.UP) {
|
||||
// Up arrow
|
||||
this.focusNext(event, true);
|
||||
}
|
||||
},
|
||||
|
||||
open(event, data) {
|
||||
this.data = data;
|
||||
this.show = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.positionMenu(event.clientY, event.clientX);
|
||||
this.$el.focus();
|
||||
this.setItemRoles();
|
||||
|
||||
if (this.closeOnScroll) {
|
||||
this.addScrollEventListener();
|
||||
}
|
||||
|
||||
this.$emit('open', event, this.data, this.top, this.left);
|
||||
});
|
||||
},
|
||||
|
||||
positionMenu(top, left) {
|
||||
const largestHeight = window.innerHeight - this.$el.offsetHeight - 25;
|
||||
const largestWidth = window.innerWidth - this.$el.offsetWidth - 25;
|
||||
|
||||
if (top > largestHeight) {
|
||||
top = largestHeight;
|
||||
}
|
||||
|
||||
if (left > largestWidth) {
|
||||
left = largestWidth;
|
||||
}
|
||||
|
||||
this.top = top;
|
||||
this.left = left;
|
||||
},
|
||||
|
||||
removeScrollEventListener() {
|
||||
eventOff(window, 'scroll', this.close);
|
||||
},
|
||||
|
||||
resetData() {
|
||||
this.top = null;
|
||||
this.left = null;
|
||||
this.data = null;
|
||||
this.show = false;
|
||||
},
|
||||
|
||||
setItemRoles() {
|
||||
// Add role="menuitem" and tabindex="-1" to all items
|
||||
selectAll(this.localItemSelector, this.$el)
|
||||
.forEach(el => {
|
||||
setAttr(el, 'role', 'menuitem');
|
||||
setAttr(el, 'tabindex', '-1');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
closeOnScroll(newValue, oldValue) {
|
||||
if (newValue === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue && this.show) {
|
||||
this.addScrollEventListener();
|
||||
} else {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
render(h) {
|
||||
if (this.lazy && ! this.show) {
|
||||
return h(false);
|
||||
}
|
||||
|
||||
// Only register the events we need
|
||||
const on = {
|
||||
// `!` modifier for capture
|
||||
'!contextmenu': e => {
|
||||
e.preventDefault();
|
||||
},
|
||||
keydown: this.onKeydown // up, down, esc
|
||||
};
|
||||
|
||||
if (this.closeOnClick) {
|
||||
on.click = this.onClick;
|
||||
}
|
||||
|
||||
// Only register the directives we need
|
||||
const directives = [
|
||||
{
|
||||
name: 'on-clickaway',
|
||||
value: this.close,
|
||||
rawName: 'v-on-clickaway'
|
||||
}
|
||||
];
|
||||
|
||||
if (! this.lazy) {
|
||||
directives.push({
|
||||
name: 'show',
|
||||
value: this.show,
|
||||
rawName: 'v-show',
|
||||
expression: 'show'
|
||||
});
|
||||
}
|
||||
|
||||
return h(
|
||||
this.tag,
|
||||
{
|
||||
staticClass: 'v-context',
|
||||
style: this.style,
|
||||
attrs: {
|
||||
tabindex: '-1',
|
||||
role: this.role,
|
||||
'aria-hidden': this.lazy ? null : String(! this.show)
|
||||
},
|
||||
on,
|
||||
directives
|
||||
},
|
||||
[normalizeSlot('default', { data: this.data }, this.$scopedSlots, this.$slots)]
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
$menu-bg: #fff;
|
||||
$menu-border: rgba(0, 0, 0, 0.15);
|
||||
$item-color: #212529;
|
||||
$item-hover-bg: #f8f9fa;
|
||||
$item-hover-color: #212529;
|
||||
@@ -0,0 +1,39 @@
|
||||
@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;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: .50rem 1.5rem;
|
||||
font-weight: 400;
|
||||
color: $item-color;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: none;
|
||||
color: $item-hover-color;
|
||||
background-color: $item-hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<div class="v-context"
|
||||
v-show="show"
|
||||
:style="style"
|
||||
tabindex="-1"
|
||||
v-on-clickaway="close"
|
||||
@click="onClick"
|
||||
@contextmenu.capture.prevent
|
||||
>
|
||||
<slot :data="data"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
export default {
|
||||
mixins: [clickaway],
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Close the menu on click.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
closeOnClick: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the menu automatically on window scroll.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Generate the CSS styles for positioning the context menu.
|
||||
*
|
||||
* @returns {object|null}
|
||||
*/
|
||||
style () {
|
||||
return this.show
|
||||
? { top: `${this.top}px`, left: `${this.left}px` }
|
||||
: null;
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
top: null,
|
||||
left: null,
|
||||
show: false,
|
||||
data: null
|
||||
};
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
if (this.closeOnScroll) {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Add scroll event listener to close context menu.
|
||||
*/
|
||||
addScrollEventListener () {
|
||||
window.addEventListener('scroll', this.close);
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the context menu.
|
||||
*
|
||||
* @param {boolean|Event} emit Used to prevent event being emitted twice from when menu is clicked and closed
|
||||
*/
|
||||
close (emit = true) {
|
||||
if (! this.show) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.top = null;
|
||||
this.left = null;
|
||||
this.data = null;
|
||||
this.show = false;
|
||||
|
||||
if (this.closeOnScroll) {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
|
||||
if (emit) {
|
||||
this.$emit('close');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the menu if `closeOnClick` is set to true.
|
||||
*/
|
||||
onClick () {
|
||||
if (this.closeOnClick) {
|
||||
this.close(false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the context menu.
|
||||
*
|
||||
* @param {MouseEvent} event
|
||||
* @param {array|object|string} data User provided data for the menu
|
||||
*/
|
||||
open (event, data) {
|
||||
this.data = data;
|
||||
this.show = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.positionMenu(event.clientY, event.clientX);
|
||||
this.$el.focus();
|
||||
|
||||
if (this.closeOnScroll) {
|
||||
this.addScrollEventListener();
|
||||
}
|
||||
|
||||
this.$emit('open', event, this.data, this.top, this.left);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the context menu top and left positions.
|
||||
*
|
||||
* @param {number} top
|
||||
* @param {number} left
|
||||
*/
|
||||
positionMenu (top, left) {
|
||||
const largestHeight = window.innerHeight - this.$el.offsetHeight - 25;
|
||||
const largestWidth = window.innerWidth - this.$el.offsetWidth - 25;
|
||||
|
||||
if (top > largestHeight) {
|
||||
top = largestHeight;
|
||||
}
|
||||
|
||||
if (left > largestWidth) {
|
||||
left = largestWidth;
|
||||
}
|
||||
|
||||
this.top = top;
|
||||
this.left = left;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the scroll event listener to close the context menu.
|
||||
*/
|
||||
removeScrollEventListener () {
|
||||
window.removeEventListener('scroll', this.close);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Add or remove the scroll event listener when the prop value changes.
|
||||
*
|
||||
* @param {boolean} closeOnScroll
|
||||
* @param {boolean} oldValue
|
||||
*/
|
||||
closeOnScroll (closeOnScroll, oldValue) {
|
||||
if (closeOnScroll === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeOnScroll && this.show) {
|
||||
this.addScrollEventListener();
|
||||
} else {
|
||||
this.removeScrollEventListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$blue600: #1e88e5;
|
||||
$gray74: #bdbdbd;
|
||||
$gray93: #ededed;
|
||||
$gray98: #fafafa;
|
||||
|
||||
.v-context {
|
||||
background: $gray98;
|
||||
border: 1px solid $gray74;
|
||||
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: 0;
|
||||
position: fixed;
|
||||
width: 250px;
|
||||
z-index: 99999;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 10px 35px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $blue600;
|
||||
color: $gray98;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user