UNPKG

nly-adminlte-vue

Version:
732 lines (712 loc) 22.2 kB
import Vue from "../../utils/vue"; import identity from "../../utils/identity"; import KeyCodes from "../../utils/key-codes"; import looseEqual from "../../utils/loose-equal"; import observeDom from "../../utils/observe-dom"; import stableSort from "../../utils/stable-sort"; import { arrayIncludes, concat } from "../../utils/array"; import { NlyEvent } from "../../utils/nly-event.class"; import { requestAF, selectAll } from "../../utils/dom"; import { isEvent } from "../../utils/inspect"; import { mathMax } from "../../utils/math"; import { toInteger } from "../../utils/number"; import { omit } from "../../utils/object"; import idMixin from "../../mixins/id"; import normalizeSlotMixin from "../../mixins/normalize-slot"; import { NlyLink } from "../link/link"; import { NlyNav, props as NlyNavProps } from "../nav/nav"; // -- Constants -- const navProps = omit(NlyNavProps, ["tabs", "isNavBar", "cardHeader"]); // -- Utils -- // Filter function to filter out disabled tabs const notDisabled = tab => !tab.disabled; // --- Helper components --- // @vue/component const BTabButtonHelper = /*#__PURE__*/ Vue.extend({ name: "BTabButtonHelper", inject: { nlyaTabls: { /* istanbul ignore next */ default() /* istanbul ignore next */ { return {}; } } }, props: { // Reference to the child <b-tab> instance tab: { default: null }, tabs: { type: Array, /* istanbul ignore next */ default() /* istanbul ignore next */ { return []; } }, id: { type: String, default: null }, controls: { type: String, default: null }, tabIndex: { type: Number, default: null }, posInSet: { type: Number, default: null }, setSize: { type: Number, default: null }, noKeyNav: { type: Boolean, default: false } }, methods: { focus() { if (this.$refs && this.$refs.link && this.$refs.link.focus) { this.$refs.link.focus(); } }, handleEvt(evt) { const stop = () => { evt.preventDefault(); evt.stopPropagation(); }; if (this.tab.disabled) { /* istanbul ignore next */ return; } const type = evt.type; const key = evt.keyCode; const shift = evt.shiftKey; if (type === "click") { stop(); this.$emit("click", evt); } else if (type === "keydown" && key === KeyCodes.SPACE) { // For ARIA tabs the SPACE key will also trigger a click/select // Even with keyboard navigation disabled, SPACE should "click" the button // See: https://github.com/bootstrap-vue/bootstrap-vue/issues/4323 stop(); this.$emit("click", evt); } else if (type === "keydown" && !this.noKeyNav) { // For keyboard navigation if ( key === KeyCodes.UP || key === KeyCodes.LEFT || key === KeyCodes.HOME ) { stop(); if (shift || key === KeyCodes.HOME) { this.$emit("first", evt); } else { this.$emit("prev", evt); } } else if ( key === KeyCodes.DOWN || key === KeyCodes.RIGHT || key === KeyCodes.END ) { stop(); if (shift || key === KeyCodes.END) { this.$emit("last", evt); } else { this.$emit("next", evt); } } } } }, render(h) { const link = h( NlyLink, { ref: "link", staticClass: "nav-link", class: [ { active: this.tab.localActive && !this.tab.disabled, disabled: this.tab.disabled }, this.tab.titleLinkClass, // Apply <b-tabs> `activeNavItemClass` styles when the tab is active this.tab.localActive ? this.nlyaTabls.activeNavItemClass : null ], props: { disabled: this.tab.disabled }, attrs: { ...this.tab.titleLinkAttributes, role: "tab", id: this.id, // Roving tab index when keynav enabled tabindex: this.tabIndex, "aria-selected": this.tab.localActive && !this.tab.disabled ? "true" : "false", "aria-setsize": this.setSize, "aria-posinset": this.posInSet, "aria-controls": this.controls }, on: { click: this.handleEvt, keydown: this.handleEvt } }, [this.tab.normalizeSlot("title") || this.tab.title] ); return h( "li", { staticClass: "nav-item", class: [this.tab.titleItemClass], attrs: { role: "presentation" } }, [link] ); } }); // @vue/component export const NlyTabs = /*#__PURE__*/ Vue.extend({ name: "NlyTabs", mixins: [idMixin, normalizeSlotMixin], provide() { return { nlyaTabls: this }; }, model: { prop: "value", event: "input" }, props: { ...navProps, tag: { type: String, default: "div" }, card: { type: Boolean, default: false }, end: { // Synonym for 'bottom' type: Boolean, default: false }, noFade: { type: Boolean, default: false }, noNavStyle: { type: Boolean, default: false }, noKeyNav: { type: Boolean, default: false }, lazy: { // This prop is sniffed by the <b-tab> child type: Boolean, default: false }, contentClass: { type: [String, Array, Object] // default: null }, navClass: { type: [String, Array, Object] // default: null }, navWrapperClass: { type: [String, Array, Object] // default: null }, activeNavItemClass: { // Only applied to the currently active <b-nav-item> type: [String, Array, Object] // default: null }, activeTabClass: { // Only applied to the currently active <b-tab> // This prop is sniffed by the <b-tab> child type: [String, Array, Object] // default: null }, value: { // v-model type: Number, default: null } }, data() { return { // Index of current tab currentTab: toInteger(this.value, -1), // Array of direct child <b-tab> instances, in DOM order tabs: [], // Array of child instances registered (for triggering reactive updates) registeredTabs: [], // Flag to know if we are mounted or not isMounted: false }; }, computed: { fade() { // This computed prop is sniffed by the tab child return !this.noFade; }, localNavClass() { const classes = []; if (this.card && this.vertical) { classes.push("card-header", "h-100", "border-bottom-0", "rounded-0"); } return [...classes, this.navClass]; } }, watch: { currentTab(newVal) { let index = -1; // Ensure only one tab is active at most this.tabs.forEach((tab, idx) => { if (newVal === idx && !tab.disabled) { tab.localActive = true; index = idx; } else { tab.localActive = false; } }); // Update the v-model this.$emit("input", index); }, value(newVal, oldVal) { if (newVal !== oldVal) { newVal = toInteger(newVal, -1); oldVal = toInteger(oldVal, 0); const tabs = this.tabs; if (tabs[newVal] && !tabs[newVal].disabled) { this.activateTab(tabs[newVal]); } else { // Try next or prev tabs if (newVal < oldVal) { this.previousTab(); } else { this.nextTab(); } } } }, registeredTabs() { // Each b-tab will register/unregister itself. // We use this to detect when tabs are added/removed // to trigger the update of the tabs. this.$nextTick(() => { requestAF(() => { this.updateTabs(); }); }); }, tabs(newVal, oldVal) { // If tabs added, removed, or re-ordered, we emit a `changed` event. // We use `tab._uid` instead of `tab.safeId()`, as the later is changed // in a nextTick if no explicit ID is provided, causing duplicate emits. if ( !looseEqual( newVal.map(t => t._uid), oldVal.map(t => t._uid) ) ) { // In a nextTick to ensure currentTab has been set first. this.$nextTick(() => { // We emit shallow copies of the new and old arrays of tabs, to // prevent users from potentially mutating the internal arrays. this.$emit("changed", newVal.slice(), oldVal.slice()); }); } }, isMounted(newVal) { // Trigger an update after mounted. Needed for tabs inside lazy modals. if (newVal) { requestAF(() => { this.updateTabs(); }); } // Enable or disable the observer this.setObserver(newVal); } }, created() { this.currentTab = toInteger(this.value, -1); this._nlyaObserver = null; // For SSR and to make sure only a single tab is shown on mount // We wrap this in a `$nextTick()` to ensure the child tabs have been created this.$nextTick(() => { this.updateTabs(); }); }, mounted() { // Call `updateTabs()` just in case... this.updateTabs(); this.$nextTick(() => { // Flag we are now mounted and to switch to DOM for tab probing. // As this.$slots.default appears to lie about component instances // after b-tabs is destroyed and re-instantiated. // And this.$children does not respect DOM order. this.isMounted = true; }); }, /* istanbul ignore next */ deactivated() /* istanbul ignore next */ { this.isMounted = false; }, /* istanbul ignore next */ activated() /* istanbul ignore next */ { this.currentTab = toInteger(this.value, -1); this.$nextTick(() => { this.updateTabs(); this.isMounted = true; }); }, beforeDestroy() { this.isMounted = false; }, destroyed() { // Ensure no references to child instances exist this.tabs = []; }, methods: { registerTab(tab) { if (!arrayIncludes(this.registeredTabs, tab)) { this.registeredTabs.push(tab); tab.$once("hook:destroyed", () => { this.unregisterTab(tab); }); } }, unregisterTab(tab) { this.registeredTabs = this.registeredTabs.slice().filter(t => t !== tab); }, setObserver(on) { // DOM observer is needed to detect changes in order of tabs if (on) { // Make sure no existing observer running this.setObserver(false); const self = this; /* istanbul ignore next: difficult to test mutation observer in JSDOM */ const handler = () => { // We delay the update to ensure that `tab.safeId()` has // updated with the final ID value. self.$nextTick(() => { requestAF(() => { self.updateTabs(); }); }); }; // Watch for changes to <b-tab> sub components this._nlyaObserver = observeDom(this.$refs.tabsContainer, handler, { childList: true, subtree: false, attributes: true, attributeFilter: ["id"] }); } else { if (this._nlyaObserver && this._nlyaObserver.disconnect) { this._nlyaObserver.disconnect(); } this._nlyaObserver = null; } }, getTabs() { // We use registeredTabs as the source of truth for child tab components. And we // filter out any BTab components that are extended BTab with a root child BTab. // https://github.com/bootstrap-vue/bootstrap-vue/issues/3260 const tabs = this.registeredTabs.filter( tab => tab.$children.filter(t => t._isTab).length === 0 ); // DOM Order of Tabs let order = []; if (this.isMounted && tabs.length > 0) { // We rely on the DOM when mounted to get the 'true' order of the b-tab children. // querySelectorAll(...) always returns elements in document order, regardless of // order specified in the selector. const selector = tabs.map(tab => `#${tab.safeId()}`).join(", "); order = selectAll(selector, this.$el) .map(el => el.id) .filter(identity); } // Stable sort keeps the original order if not found in the // `order` array, which will be an empty array before mount. return stableSort(tabs, (a, b) => { return order.indexOf(a.safeId()) - order.indexOf(b.safeId()); }); }, // Update list of <b-tab> children updateTabs() { // Probe tabs const tabs = this.getTabs(); // Find *last* active non-disabled tab in current tabs // We trust tab state over currentTab, in case tabs were added/removed/re-ordered let tabIndex = tabs.indexOf( tabs .slice() .reverse() .find(tab => tab.localActive && !tab.disabled) ); // Else try setting to currentTab if (tabIndex < 0) { const currentTab = this.currentTab; if (currentTab >= tabs.length) { // Handle last tab being removed, so find the last non-disabled tab tabIndex = tabs.indexOf( tabs .slice() .reverse() .find(notDisabled) ); } else if (tabs[currentTab] && !tabs[currentTab].disabled) { // Current tab is not disabled tabIndex = currentTab; } } // Else find *first* non-disabled tab in current tabs if (tabIndex < 0) { tabIndex = tabs.indexOf(tabs.find(notDisabled)); } // Set the current tab state to active tabs.forEach(tab => { // tab.localActive = idx === tabIndex && !tab.disabled tab.localActive = false; }); if (tabs[tabIndex]) { tabs[tabIndex].localActive = true; } // Update the array of tab children this.tabs = tabs; // Set the currentTab index (can be -1 if no non-disabled tabs) this.currentTab = tabIndex; }, // Find a button that controls a tab, given the tab reference // Returns the button vm instance getButtonForTab(tab) { return (this.$refs.buttons || []).find(btn => btn.tab === tab); }, // Force a button to re-render its content, given a <b-tab> instance // Called by <b-tab> on `update()` updateButton(tab) { const button = this.getButtonForTab(tab); if (button && button.$forceUpdate) { button.$forceUpdate(); } }, // Activate a tab given a <b-tab> instance // Also accessed by <b-tab> activateTab(tab) { let result = false; if (tab) { const index = this.tabs.indexOf(tab); if (!tab.disabled && index > -1 && index !== this.currentTab) { const tabEvt = new NlyEvent("activate-tab", { cancelable: true, vueTarget: this, componentId: this.safeId() }); this.$emit(tabEvt.type, index, this.currentTab, tabEvt); if (!tabEvt.defaultPrevented) { result = true; this.currentTab = index; } } } // Couldn't set tab, so ensure v-model is set to `this.currentTab` /* istanbul ignore next: should rarely happen */ if (!result && this.currentTab !== this.value) { this.$emit("input", this.currentTab); } return result; }, // Deactivate a tab given a <b-tab> instance // Accessed by <b-tab> deactivateTab(tab) { if (tab) { // Find first non-disabled tab that isn't the one being deactivated // If no tabs are available, then don't deactivate current tab return this.activateTab( this.tabs.filter(t => t !== tab).find(notDisabled) ); } /* istanbul ignore next: should never/rarely happen */ return false; }, // Focus a tab button given its <b-tab> instance focusButton(tab) { // Wrap in `$nextTick()` to ensure DOM has completed rendering/updating before focusing this.$nextTick(() => { const button = this.getButtonForTab(tab); if (button && button.focus) { button.focus(); } }); }, // Emit a click event on a specified <b-tab> component instance emitTabClick(tab, evt) { if (isEvent(evt) && tab && tab.$emit && !tab.disabled) { tab.$emit("click", evt); } }, // Click handler clickTab(tab, evt) { this.activateTab(tab); this.emitTabClick(tab, evt); }, // Move to first non-disabled tab firstTab(focus) { const tab = this.tabs.find(notDisabled); if (this.activateTab(tab) && focus) { this.focusButton(tab); this.emitTabClick(tab, focus); } }, // Move to previous non-disabled tab previousTab(focus) { const currentIndex = mathMax(this.currentTab, 0); const tab = this.tabs .slice(0, currentIndex) .reverse() .find(notDisabled); if (this.activateTab(tab) && focus) { this.focusButton(tab); this.emitTabClick(tab, focus); } }, // Move to next non-disabled tab nextTab(focus) { const currentIndex = mathMax(this.currentTab, -1); const tab = this.tabs.slice(currentIndex + 1).find(notDisabled); if (this.activateTab(tab) && focus) { this.focusButton(tab); this.emitTabClick(tab, focus); } }, // Move to last non-disabled tab lastTab(focus) { const tab = this.tabs .slice() .reverse() .find(notDisabled); if (this.activateTab(tab) && focus) { this.focusButton(tab); this.emitTabClick(tab, focus); } } }, render(h) { const tabs = this.tabs; // Currently active tab const activeTab = tabs.find(tab => tab.localActive && !tab.disabled); // Tab button to allow focusing when no active tab found (keynav only) const fallbackTab = tabs.find(tab => !tab.disabled); // For each <b-tab> found create the tab buttons const buttons = tabs.map((tab, index) => { let tabIndex = null; // Ensure at least one tab button is focusable when keynav enabled (if possible) if (!this.noKeyNav) { // Buttons are not in tab index unless active, or a fallback tab tabIndex = -1; if (activeTab === tab || (!activeTab && fallbackTab === tab)) { // Place tab button in tab sequence tabIndex = null; } } return h(BTabButtonHelper, { key: tab._uid || index, ref: "buttons", // Needed to make `this.$refs.buttons` an array refInFor: true, props: { tab: tab, tabs: tabs, id: tab.controlledBy || (tab.safeId ? tab.safeId(`_NLYA_tab_button_`) : null), controls: tab.safeId ? tab.safeId() : null, tabIndex, setSize: tabs.length, posInSet: index + 1, noKeyNav: this.noKeyNav }, on: { click: evt => { this.clickTab(tab, evt); }, first: this.firstTab, prev: this.previousTab, next: this.nextTab, last: this.lastTab } }); }); // Nav let nav = h( NlyNav, { ref: "nav", class: this.localNavClass, attrs: { role: "tablist", id: this.safeId("_NLYA_tab_controls_") }, props: { fill: this.fill, justified: this.justified, align: this.align, tabs: !this.noNavStyle && !this.pills, pills: !this.noNavStyle && this.pills, vertical: this.vertical, small: this.small, cardHeader: this.card && !this.vertical } }, [ this.normalizeSlot("tabs-start") || h(), buttons, this.normalizeSlot("tabs-end") || h() ] ); nav = h( "div", { key: "nlya-tabs-nav", class: [ { "card-header": this.card && !this.vertical && !this.end, "card-footer": this.card && !this.vertical && this.end, "col-auto": this.vertical }, this.navWrapperClass ] }, [nav] ); let empty = h(); if (!tabs || tabs.length === 0) { empty = h( "div", { key: "nlya-empty-tab", class: ["tab-pane", "active", { "card-body": this.card }] }, this.normalizeSlot("empty") ); } // Main content section const content = h( "div", { ref: "tabsContainer", key: "nly-tabs-container", staticClass: "tab-content", class: [{ col: this.vertical }, this.contentClass], attrs: { id: this.safeId("_NLY_tab_container_") } }, concat(this.normalizeSlot("default"), empty) ); // Render final output return h( this.tag, { staticClass: "tabs", class: { row: this.vertical, "no-gutters": this.vertical && this.card }, attrs: { id: this.safeId() } }, [this.end ? content : h(), [nav], this.end ? h() : content] ); } });