nly-adminlte-vue
Version:
nly adminlte3 components
483 lines (471 loc) • 13.6 kB
JavaScript
import Popper from "popper.js";
import KeyCodes from "../utils/key-codes";
import { NlyEvent } from "../utils/nly-event.class";
import {
closest,
contains,
isVisible,
requestAF,
selectAll
} from "../utils/dom";
import { isNull } from "../utils/inspect";
import { HTMLElement } from "../utils/safe-types";
import { warn } from "../utils/warn";
import clickOutMixin from "./click-out";
import focusInMixin from "./focus-in";
import idMixin from "./id";
// Return an array of visible items
const filterVisibles = els => (els || []).filter(isVisible);
// Root dropdown event names
const ROOT_DROPDOWN_PREFIX = "nlya::dropdown::";
const ROOT_DROPDOWN_SHOWN = `${ROOT_DROPDOWN_PREFIX}shown`;
const ROOT_DROPDOWN_HIDDEN = `${ROOT_DROPDOWN_PREFIX}hidden`;
// Dropdown item CSS selectors
const Selector = {
FORM_CHILD: ".dropdown form",
ITEM_SELECTOR: [".dropdown-item", ".nly-dropdown-form"]
.map(selector => `${selector}:not(.disabled):not([disabled])`)
.join(", ")
};
// Popper attachment positions
const AttachmentMap = {
// Dropup left align
TOP: "top-start",
// Dropup right align
TOPEND: "top-end",
// Dropdown left align
BOTTOM: "bottom-start",
// Dropdown right align
BOTTOMEND: "bottom-end",
// Dropright left align
RIGHT: "right-start",
// Dropright right align
RIGHTEND: "right-end",
// Dropleft left align
LEFT: "left-start",
// Dropleft right align
LEFTEND: "left-end"
};
export const commonProps = {
dropup: {
// place on top if possible
type: Boolean,
default: false
},
dropright: {
// place right if possible
type: Boolean,
default: false
},
dropleft: {
// place left if possible
type: Boolean,
default: false
},
right: {
// Right align menu (default is left align)
type: Boolean,
default: false
},
offset: {
// Number of pixels to offset menu, or a CSS unit value (i.e. 1px, 1rem, etc)
type: [Number, String],
default: 0
},
noFlip: {
// Disable auto-flipping of menu from bottom<=>top
type: Boolean,
default: false
},
popperOpts: {
// type: Object,
default: () => {}
},
boundary: {
// String: `scrollParent`, `window` or `viewport`
// HTMLElement: HTML Element reference
type: [String, HTMLElement],
default: "scrollParent"
}
};
// @vue/component
export default {
mixins: [idMixin, clickOutMixin, focusInMixin],
provide() {
return { nlyaDropdown: this };
},
inject: {
nlyaNavbar: { default: null }
},
props: {
disabled: {
type: Boolean,
default: false
},
...commonProps
},
data() {
return {
visible: false,
visibleChangePrevented: false
};
},
computed: {
inNavbar() {
return !isNull(this.nlyaNavbar);
},
toggler() {
const toggle = this.$refs.toggle;
return toggle ? toggle.$el || toggle : null;
},
directionClass() {
if (this.dropup) {
return "dropup";
} else if (this.dropright) {
return "dropright";
} else if (this.dropleft) {
return "dropleft";
}
return "";
}
},
watch: {
visible(newValue, oldValue) {
if (this.visibleChangePrevented) {
this.visibleChangePrevented = false;
return;
}
if (newValue !== oldValue) {
const evtName = newValue ? "show" : "hide";
const nlyEvt = new NlyEvent(evtName, {
cancelable: true,
vueTarget: this,
target: this.$refs.menu,
relatedTarget: null,
componentId: this.safeId ? this.safeId() : this.id || null
});
this.emitEvent(nlyEvt);
if (nlyEvt.defaultPrevented) {
// Reset value and exit if canceled
this.visibleChangePrevented = true;
this.visible = oldValue;
// Just in case a child element triggered `this.hide(true)`
this.$off("hidden", this.focusToggler);
return;
}
if (evtName === "show") {
this.showMenu();
} else {
this.hideMenu();
}
}
},
disabled(newValue, oldValue) {
if (newValue !== oldValue && newValue && this.visible) {
// Hide dropdown if disabled changes to true
this.visible = false;
}
}
},
created() {
// Create non-reactive property
this.$_popper = null;
},
/* istanbul ignore next */
deactivated() /* istanbul ignore next: not easy to test */ {
// In case we are inside a `<keep-alive>`
this.visible = false;
this.whileOpenListen(false);
this.destroyPopper();
},
beforeDestroy() {
this.visible = false;
this.whileOpenListen(false);
this.destroyPopper();
},
methods: {
// Event emitter
emitEvent(nlyEvt) {
const { type } = nlyEvt;
this.$emit(type, nlyEvt);
this.$root.$emit(`${ROOT_DROPDOWN_PREFIX}${type}`, nlyEvt);
},
showMenu() {
if (this.disabled) {
/* istanbul ignore next */
return;
}
if (!this.inNavbar) {
if (typeof Popper === "undefined") {
/* istanbul ignore next */
warn(
"Popper.js not found. Falling back to CSS positioning",
"NlyDropdown"
);
} else {
// For dropup with alignment we use the parent element as popper container
let el =
(this.dropup && this.right) || this.split
? this.$el
: this.$refs.toggle;
// Make sure we have a reference to an element, not a component!
el = el.$el || el;
// Instantiate Popper.js
this.createPopper(el);
}
}
// Ensure other menus are closed
this.$root.$emit(ROOT_DROPDOWN_SHOWN, this);
// Enable listeners
this.whileOpenListen(true);
// Wrap in `$nextTick()` to ensure menu is fully rendered/shown
this.$nextTick(() => {
// Focus on the menu container on show
this.focusMenu();
// Emit the shown event
this.$emit("shown");
});
},
hideMenu() {
this.whileOpenListen(false);
this.$root.$emit(ROOT_DROPDOWN_HIDDEN, this);
this.$emit("hidden");
this.destroyPopper();
},
createPopper(element) {
this.destroyPopper();
this.$_popper = new Popper(
element,
this.$refs.menu,
this.getPopperConfig()
);
},
destroyPopper() {
// Ensure popper event listeners are removed cleanly
if (this.$_popper) {
this.$_popper.destroy();
}
this.$_popper = null;
},
updatePopper() /* istanbul ignore next: not easy to test */ {
// Instructs popper to re-computes the dropdown position
// usefull if the content changes size
try {
this.$_popper.scheduleUpdate();
// eslint-disable-next-line no-empty
} catch {}
},
getPopperConfig() {
let placement = AttachmentMap.BOTTOM;
if (this.dropup) {
placement = this.right ? AttachmentMap.TOPEND : AttachmentMap.TOP;
} else if (this.dropright) {
placement = AttachmentMap.RIGHT;
} else if (this.dropleft) {
placement = AttachmentMap.LEFT;
} else if (this.right) {
placement = AttachmentMap.BOTTOMEND;
}
const popperConfig = {
placement,
modifiers: {
offset: { offset: this.offset || 0 },
flip: { enabled: !this.noFlip }
}
};
if (this.boundary) {
popperConfig.modifiers.preventOverflow = {
boundariesElement: this.boundary
};
}
return { ...popperConfig, ...(this.popperOpts || {}) };
},
// Turn listeners on/off while open
whileOpenListen(isOpen) {
// Hide the dropdown when clicked outside
this.listenForClickOut = isOpen;
// Hide the dropdown when it loses focus
this.listenForFocusIn = isOpen;
// Hide the dropdown when another dropdown is opened
const method = isOpen ? "$on" : "$off";
this.$root[method](ROOT_DROPDOWN_SHOWN, this.rootCloseListener);
},
rootCloseListener(vm) {
if (vm !== this) {
this.visible = false;
}
},
show() {
// Public method to show dropdown
if (this.disabled) {
return;
}
// Wrap in a `requestAF()` to allow any previous
// click handling to occur first
requestAF(() => {
this.visible = true;
});
},
hide(refocus = false) {
// Public method to hide dropdown
if (this.disabled) {
/* istanbul ignore next */
return;
}
this.visible = false;
if (refocus) {
// Child element is closing the dropdown on click
this.$once("hidden", this.focusToggler);
}
},
// Called only by a button that toggles the menu
toggle(evt) {
evt = evt || {};
// Early exit when not a click event or ENTER, SPACE or DOWN were pressed
const { type, keyCode } = evt;
if (
type !== "click" &&
!(
type === "keydown" &&
[KeyCodes.ENTER, KeyCodes.SPACE, KeyCodes.DOWN].indexOf(keyCode) !==
-1
)
) {
/* istanbul ignore next */
return;
}
/* istanbul ignore next */
if (this.disabled) {
this.visible = false;
return;
}
this.$emit("toggle", evt);
evt.preventDefault();
evt.stopPropagation();
// Toggle visibility
if (this.visible) {
this.hide(true);
} else {
this.show();
}
},
// Mousedown handler for the toggle
/* istanbul ignore next */
onMousedown(evt) /* istanbul ignore next */ {
// We prevent the 'mousedown' event for the toggle to stop the
// 'focusin' event from being fired
// The event would otherwise be picked up by the global 'focusin'
// listener and there is no cross-browser solution to detect it
// relates to the toggle click
// The 'click' event will still be fired and we handle closing
// other dropdowns there too
// See https://github.com/bootstrap-vue/bootstrap-vue/issues/4328
evt.preventDefault();
},
// Called from dropdown menu context
onKeydown(evt) {
const { keyCode } = evt;
if (keyCode === KeyCodes.ESC) {
// Close on ESC
this.onEsc(evt);
} else if (keyCode === KeyCodes.DOWN) {
// Down Arrow
this.focusNext(evt, false);
} else if (keyCode === KeyCodes.UP) {
// Up Arrow
this.focusNext(evt, true);
}
},
// If user presses ESC, close the menu
onEsc(evt) {
if (this.visible) {
this.visible = false;
evt.preventDefault();
evt.stopPropagation();
// Return focus to original trigger button
this.$once("hidden", this.focusToggler);
}
},
// Called only in split button mode, for the split button
onSplitClick(evt) {
/* istanbul ignore next */
if (this.disabled) {
this.visible = false;
return;
}
this.$emit("click", evt);
},
// Shared hide handler between click-out and focus-in events
hideHandler(evt) {
const { target } = evt;
if (
this.visible &&
!contains(this.$refs.menu, target) &&
!contains(this.toggler, target)
) {
this.hide();
}
},
// Document click-out listener
clickOutHandler(evt) {
this.hideHandler(evt);
},
// Document focus-in listener
focusInHandler(evt) {
this.hideHandler(evt);
},
// Keyboard nav
focusNext(evt, up) {
// Ignore key up/down on form elements
const { target } = evt;
if (!this.visible || (evt && closest(Selector.FORM_CHILD, target))) {
/* istanbul ignore next: should never happen */
return;
}
evt.preventDefault();
evt.stopPropagation();
this.$nextTick(() => {
const items = this.getItems();
if (items.length < 1) {
/* istanbul ignore next: should never happen */
return;
}
let index = items.indexOf(target);
if (up && index > 0) {
index--;
} else if (!up && index < items.length - 1) {
index++;
}
if (index < 0) {
/* istanbul ignore next: should never happen */
index = 0;
}
this.focusItem(index, items);
});
},
focusItem(idx, items) {
const el = items.find((el, i) => i === idx);
if (el && el.focus) {
el.focus();
}
},
getItems() {
// Get all items
return filterVisibles(selectAll(Selector.ITEM_SELECTOR, this.$refs.menu));
},
focusMenu() {
try {
this.$refs.menu.focus();
// eslint-disable-next-line no-empty
} catch {}
},
focusToggler() {
this.$nextTick(() => {
const toggler = this.toggler;
if (toggler && toggler.focus) {
toggler.focus();
}
});
}
}
};