diff --git a/src/components/tabs/tab.js b/src/components/tabs/tab.js index e21df1ca004..1a527432004 100644 --- a/src/components/tabs/tab.js +++ b/src/components/tabs/tab.js @@ -54,11 +54,14 @@ export const BTab = /*#__PURE__*/ Vue.extend({ props, data() { return { - localActive: this[MODEL_PROP_NAME_ACTIVE] && !this.disabled, - show: false + localActive: this[MODEL_PROP_NAME_ACTIVE] && !this.disabled } }, computed: { + // For parent sniffing of child + _isTab() { + return true + }, tabClasses() { const { localActive: active, disabled } = this @@ -80,17 +83,9 @@ export const BTab = /*#__PURE__*/ Vue.extend({ }, computedLazy() { return this.bvTabs.lazy || this.lazy - }, - // For parent sniffing of child - _isTab() { - return true } }, watch: { - localActive(newValue) { - // Make `active` prop work with `.sync` modifier - this.$emit(MODEL_EVENT_NAME_ACTIVE, newValue) - }, [MODEL_PROP_NAME_ACTIVE](newValue, oldValue) { if (newValue !== oldValue) { if (newValue) { @@ -114,13 +109,15 @@ export const BTab = /*#__PURE__*/ Vue.extend({ firstTab() } } + }, + localActive(newValue) { + // Make `active` prop work with `.sync` modifier + this.$emit(MODEL_EVENT_NAME_ACTIVE, newValue) } }, mounted() { - // Inform b-tabs of our presence + // Inform `` of our presence this.registerTab() - // Initially show on mount if active and not disabled - this.show = this.localActive }, updated() { // Force the tab button content to update (since slots are not reactive) @@ -130,8 +127,8 @@ export const BTab = /*#__PURE__*/ Vue.extend({ updateButton(this) } }, - destroyed() { - // inform b-tabs of our departure + beforeDestroy() { + // Inform `` of our departure this.unregisterTab() }, methods: { diff --git a/src/components/tabs/tab.spec.js b/src/components/tabs/tab.spec.js index b5a75a00671..c47a9fbe16d 100644 --- a/src/components/tabs/tab.spec.js +++ b/src/components/tabs/tab.spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { waitNT, waitRAF } from '../../../tests/utils' +import { waitNT } from '../../../tests/utils' import { BTab } from './tab' describe('tab', () => { @@ -31,7 +31,6 @@ describe('tab', () => { expect(wrapper.vm._isTab).toBe(true) expect(wrapper.vm.localActive).toBe(false) - expect(wrapper.vm.show).toBe(false) wrapper.destroy() }) @@ -86,7 +85,6 @@ describe('tab', () => { await wrapper.setData({ localActive: true }) await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.classes()).toContain('active') expect(wrapper.classes()).not.toContain('disabled') @@ -94,7 +92,6 @@ describe('tab', () => { await wrapper.setData({ localActive: false }) await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.classes()).not.toContain('active') expect(wrapper.classes()).not.toContain('disabled') diff --git a/src/components/tabs/tabs.js b/src/components/tabs/tabs.js index d04947e5e3b..cc7faafdc34 100644 --- a/src/components/tabs/tabs.js +++ b/src/components/tabs/tabs.js @@ -1,13 +1,14 @@ import { COMPONENT_UID_KEY, Vue } from '../../vue' import { NAME_TABS, NAME_TAB_BUTTON_HELPER } from '../../constants/components' +import { IS_BROWSER } from '../../constants/env' import { + EVENT_NAME_ACTIVATE_TAB, EVENT_NAME_CHANGED, EVENT_NAME_CLICK, EVENT_NAME_FIRST, EVENT_NAME_LAST, EVENT_NAME_NEXT, - EVENT_NAME_PREV, - HOOK_EVENT_NAME_DESTROYED + EVENT_NAME_PREV } from '../../constants/events' import { CODE_DOWN, @@ -19,7 +20,6 @@ import { CODE_UP } from '../../constants/key-codes' import { - PROP_TYPE_ARRAY, PROP_TYPE_ARRAY_OBJECT_STRING, PROP_TYPE_BOOLEAN, PROP_TYPE_NUMBER, @@ -33,7 +33,7 @@ import { } from '../../constants/slots' import { arrayIncludes } from '../../utils/array' import { BvEvent } from '../../utils/bv-event.class' -import { attemptFocus, requestAF, selectAll } from '../../utils/dom' +import { attemptFocus, selectAll } from '../../utils/dom' import { stopEvent } from '../../utils/events' import { identity } from '../../utils/identity' import { isEvent } from '../../utils/inspect' @@ -82,8 +82,7 @@ const BVTabButton = /*#__PURE__*/ Vue.extend({ setSize: makeProp(PROP_TYPE_NUMBER), // Reference to the child instance tab: makeProp(), - tabIndex: makeProp(PROP_TYPE_NUMBER), - tabs: makeProp(PROP_TYPE_ARRAY, []) + tabIndex: makeProp(PROP_TYPE_NUMBER) }, methods: { focus() { @@ -227,12 +226,10 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ return { // Index of current tab currentTab: toInteger(this[MODEL_PROP_NAME], -1), - // Array of direct child instances, in DOM order + // Array of direct child `` 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 + registeredTabs: [] } }, computed: { @@ -249,27 +246,14 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ } }, watch: { - currentTab(newValue) { - let index = -1 - // Ensure only one tab is active at most - this.tabs.forEach((tab, idx) => { - if (newValue === idx && !tab.disabled) { - tab.localActive = true - index = idx - } else { - tab.localActive = false - } - }) - // Update the v-model - this.$emit(MODEL_EVENT_NAME, index) - }, [MODEL_PROP_NAME](newValue, oldValue) { if (newValue !== oldValue) { newValue = toInteger(newValue, -1) oldValue = toInteger(oldValue, 0) - const tabs = this.tabs - if (tabs[newValue] && !tabs[newValue].disabled) { - this.activateTab(tabs[newValue]) + + const $tab = this.tabs[newValue] + if ($tab && !$tab.disabled) { + this.activateTab($tab) } else { // Try next or prev tabs if (newValue < oldValue) { @@ -280,15 +264,21 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ } } }, - 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() - }) + currentTab(newValue) { + let index = -1 + + // Ensure only one tab is active at most + this.tabs.forEach(($tab, i) => { + if (i === newValue && !$tab.disabled) { + $tab.localActive = true + index = i + } else { + $tab.localActive = false + } }) + + // Update the v-model + this.$emit(MODEL_EVENT_NAME, index) }, tabs(newValue, oldValue) { // If tabs added, removed, or re-ordered, we emit a `changed` event @@ -308,86 +298,44 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ }) } }, - isMounted(newValue) { - // Trigger an update after mounted - // Needed for tabs inside lazy modals - if (newValue) { - requestAF(() => { - this.updateTabs() - }) - } - // Enable or disable the observer - this.setObserver(newValue) + registeredTabs() { + this.updateTabs() } }, created() { // Create private non-reactive props this.$_observer = null - this.currentTab = toInteger(this[MODEL_PROP_NAME], -1) - // 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 `$slots.default` appears to lie about component instances - // after b-tabs is destroyed and re-instantiated - // And `$children` does not respect DOM order - this.isMounted = true - }) - }, - /* istanbul ignore next */ - deactivated() { - this.isMounted = false - }, - /* istanbul ignore next */ - activated() { - this.currentTab = toInteger(this[MODEL_PROP_NAME], -1) - this.$nextTick(() => { - this.updateTabs() - this.isMounted = true - }) + this.setObserver(true) }, beforeDestroy() { - this.isMounted = false - }, - destroyed() { + this.setObserver(false) // Ensure no references to child instances exist this.tabs = [] }, methods: { - registerTab(tab) { - if (!arrayIncludes(this.registeredTabs, tab)) { - this.registeredTabs.push(tab) - tab.$once(HOOK_EVENT_NAME_DESTROYED, () => { - this.unregisterTab(tab) - }) + registerTab($tab) { + if (!arrayIncludes(this.registeredTabs, $tab)) { + this.registeredTabs.push($tab) } }, - unregisterTab(tab) { - this.registeredTabs = this.registeredTabs.slice().filter(t => t !== tab) + unregisterTab($tab) { + this.registeredTabs = this.registeredTabs.slice().filter($t => $t !== $tab) }, // DOM observer is needed to detect changes in order of tabs - setObserver(on) { + setObserver(on = true) { this.$_observer && this.$_observer.disconnect() this.$_observer = null + if (on) { - 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() - }) + this.$nextTick(() => { + this.updateTabs() }) } + // Watch for changes to `` sub components this.$_observer = observeDom(this.$refs.content, handler, { childList: true, @@ -398,133 +346,114 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ } }, getTabs() { - // We use `registeredTabs` as the source of truth for child tab components - // We also filter out any `` components that are extended - // `` with a root child `` - // See: https://github.com/bootstrap-vue/bootstrap-vue/issues/3260 - const tabs = this.registeredTabs.filter( - tab => tab.$children.filter(t => t._isTab).length === 0 + 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) { + if (IS_BROWSER && $tabs.length > 0) { // We rely on the DOM when mounted to get the 'true' order of the `` children // `querySelectorAll()` always returns elements in document order, regardless of // order specified in the selector - const selector = tabs.map(tab => `#${tab.safeId()}`).join(', ') + const selector = $tabs.map($tab => `#${$tab.safeId()}`).join(', ') order = selectAll(selector, this.$el) - .map(el => el.id) + .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) => order.indexOf(a.safeId()) - order.indexOf(b.safeId())) + return stableSort($tabs, (a, b) => order.indexOf(a.safeId()) - order.indexOf(b.safeId())) }, - // Update list of `` 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) - ) + const $tabs = this.getTabs() - // Else try setting to `currentTab` - if (tabIndex < 0) { - const { currentTab } = this - 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 - } - } + // Normalize `currentTab` + let { currentTab } = this + const $tab = $tabs[currentTab] + if (!$tab || $tab.disabled) { + currentTab = $tabs.indexOf( + $tabs + .slice() + .reverse() + .find($tab => $tab.localActive && !$tab.disabled) + ) - // Else find *first* non-disabled tab in current tabs - if (tabIndex < 0) { - tabIndex = tabs.indexOf(tabs.find(notDisabled)) + if (currentTab === -1) { + currentTab = $tabs.indexOf($tabs.find(notDisabled)) + } } - // Set the current tab state to active - tabs.forEach(tab => { - // tab.localActive = idx === tabIndex && !tab.disabled - tab.localActive = false + // Ensure only one tab is active at a time + $tabs.forEach(($tab, index) => { + $tab.localActive = index === currentTab }) - 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 + this.tabs = $tabs + this.currentTab = currentTab }, // 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) + getButtonForTab($tab) { + return (this.$refs.buttons || []).find($btn => $btn.tab === $tab) }, // Force a button to re-render its content, given a `` instance // Called by `` on `update()` - updateButton(tab) { - const button = this.getButtonForTab(tab) - if (button && button.$forceUpdate) { - button.$forceUpdate() + updateButton($tab) { + const $button = this.getButtonForTab($tab) + if ($button && $button.$forceUpdate) { + $button.$forceUpdate() } }, // Activate a tab given a `` instance // Also accessed by `` - activateTab(tab) { + activateTab($tab) { + const { currentTab, tabs: $tabs } = this let result = false - if (tab) { - const index = this.tabs.indexOf(tab) - if (!tab.disabled && index > -1 && index !== this.currentTab) { - const tabEvt = new BvEvent('activate-tab', { + + if ($tab) { + const index = $tabs.indexOf($tab) + if (index !== currentTab && index > -1 && !$tab.disabled) { + const tabEvent = new BvEvent(EVENT_NAME_ACTIVATE_TAB, { cancelable: true, vueTarget: this, componentId: this.safeId() }) - this.$emit(tabEvt.type, index, this.currentTab, tabEvt) - if (!tabEvt.defaultPrevented) { - result = true + + this.$emit(tabEvent.type, index, currentTab, tabEvent) + + if (!tabEvent.defaultPrevented) { this.currentTab = index + result = true } } } - // Couldn't set tab, so ensure v-model is set to `currentTab` + + // Couldn't set tab, so ensure v-model is up to date /* istanbul ignore next: should rarely happen */ - if (!result && this.currentTab !== this[MODEL_PROP_NAME]) { - this.$emit(MODEL_EVENT_NAME, this.currentTab) + if (!result && this[MODEL_PROP_NAME] !== currentTab) { + this.$emit(MODEL_EVENT_NAME, currentTab) } + return result }, // Deactivate a tab given a `` instance // Accessed by `` - deactivateTab(tab) { - if (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)) + 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 `` instance - focusButton(tab) { - // Wrap in `$nextTick()` to ensure DOM has completed rendering/updating before focusing + focusButton($tab) { + // Wrap in `$nextTick()` to ensure DOM has completed rendering this.$nextTick(() => { - attemptFocus(this.getButtonForTab(tab)) + attemptFocus(this.getButtonForTab($tab)) }) }, // Emit a click event on a specified `` component instance @@ -534,68 +463,86 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ } }, // Click handler - clickTab(tab, event) { - this.activateTab(tab) - this.emitTabClick(tab, event) + clickTab($tab, event) { + this.activateTab($tab) + this.emitTabClick($tab, event) }, // 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) + 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 + const $tab = this.tabs .slice(0, currentIndex) .reverse() .find(notDisabled) - if (this.activateTab(tab) && focus) { - this.focusButton(tab) - this.emitTabClick(tab, focus) + 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) + 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 + const $tab = this.tabs .slice() .reverse() .find(notDisabled) - if (this.activateTab(tab) && focus) { - this.focusButton(tab) - this.emitTabClick(tab, focus) + if (this.activateTab($tab) && focus) { + this.focusButton($tab) + this.emitTabClick($tab, focus) } } }, render(h) { - const { tabs, noKeyNav, card, vertical, end, firstTab, previousTab, nextTab, lastTab } = this + const { + align, + card, + end, + fill, + firstTab, + justified, + lastTab, + nextTab, + noKeyNav, + noNavStyle, + pills, + previousTab, + small, + tabs: $tabs, + vertical + } = this // Currently active tab - const activeTab = tabs.find(tab => tab.localActive && !tab.disabled) + 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) + const $fallbackTab = $tabs.find($tab => !$tab.disabled) // For each `` found create the tab buttons - const $buttons = tabs.map((tab, index) => { + const $buttons = $tabs.map(($tab, index) => { + const { safeId } = $tab + // Ensure at least one tab button is focusable when keynav enabled (if possible) let tabIndex = null if (!noKeyNav) { // Buttons are not in tab index unless active, or a fallback tab tabIndex = -1 - if (activeTab === tab || (!activeTab && fallbackTab === tab)) { + if ($tab === $activeTab || (!$activeTab && $tab === $fallbackTab)) { // Place tab button in tab sequence tabIndex = null } @@ -603,25 +550,24 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ return h(BVTabButton, { props: { - tab, - tabs, - id: tab.controlledBy || (tab.safeId ? tab.safeId(`_BV_tab_button_`) : null), - controls: tab.safeId ? tab.safeId() : null, - tabIndex, - setSize: tabs.length, + controls: safeId ? safeId() : null, + id: $tab.controlledBy || (safeId ? safeId(`_BV_tab_button_`) : null), + noKeyNav, posInSet: index + 1, - noKeyNav + setSize: $tabs.length, + tab: $tab, + tabIndex }, on: { [EVENT_NAME_CLICK]: event => { - this.clickTab(tab, event) + this.clickTab($tab, event) }, [EVENT_NAME_FIRST]: firstTab, [EVENT_NAME_PREV]: previousTab, [EVENT_NAME_NEXT]: nextTab, [EVENT_NAME_LAST]: lastTab }, - key: tab[COMPONENT_UID_KEY] || index, + key: $tab[COMPONENT_UID_KEY] || index, ref: 'buttons', // Needed to make `this.$refs.buttons` an array refInFor: true @@ -637,13 +583,13 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ id: this.safeId('_BV_tab_controls_') }, props: { - fill: this.fill, - justified: this.justified, - align: this.align, - tabs: !this.noNavStyle && !this.pills, - pills: !this.noNavStyle && this.pills, + fill, + justified, + align, + tabs: !noNavStyle && !pills, + pills: !noNavStyle && pills, vertical, - small: this.small, + small, cardHeader: card && !vertical }, ref: 'nav' @@ -671,8 +617,10 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ [$nav] ) + const $children = this.normalizeSlot() || [] + let $empty = h() - if (!tabs || tabs.length === 0) { + if ($children.length === 0) { $empty = h( 'div', { @@ -683,7 +631,6 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ ) } - // Main content section const $content = h( 'div', { @@ -693,7 +640,7 @@ export const BTabs = /*#__PURE__*/ Vue.extend({ key: 'bv-content', ref: 'content' }, - [this.normalizeSlot() || $empty] + [$children, $empty] ) // Render final output diff --git a/src/components/tabs/tabs.spec.js b/src/components/tabs/tabs.spec.js index 4cb4777e5d2..7e4983e264a 100644 --- a/src/components/tabs/tabs.spec.js +++ b/src/components/tabs/tabs.spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils' -import { waitNT, waitRAF } from '../../../tests/utils' +import { waitNT } from '../../../tests/utils' import { BLink } from '../link/link' import { BTab } from './tab' import { BTabs } from './tabs' @@ -11,7 +11,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.element.tagName).toBe('DIV') expect(wrapper.classes()).toContain('tabs') @@ -40,7 +39,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.element.tagName).toBe('DIV') expect(wrapper.classes()).toContain('tabs') @@ -62,7 +60,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.element.tagName).toBe('DIV') expect(wrapper.classes()).toContain('tabs') @@ -91,7 +88,6 @@ describe('tabs', () => { }) await waitNT(wrapper.vm) - await waitRAF() expect(wrapper.vm.currentTab).toBe(tabIndex) expect(wrapper.vm.tabs.length).toBe(3) @@ -114,7 +110,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() @@ -146,7 +141,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() @@ -178,7 +172,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -225,7 +218,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -238,7 +230,6 @@ describe('tabs', () => { // Set 2nd BTab to be active await tabs.setProps({ value: 1 }) - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tabs.emitted('input').length).toBe(1) // Should emit index of 1 (2nd tab) @@ -246,7 +237,6 @@ describe('tabs', () => { // Set 3rd BTab to be active await tabs.setProps({ value: 2 }) - await waitRAF() expect(tabs.vm.currentTab).toBe(2) expect(tabs.emitted('input').length).toBe(2) // Should emit index of 2 (3rd tab) @@ -269,7 +259,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -281,7 +270,6 @@ describe('tabs', () => { // Try to set 2nd (disabled) BTab to be active await tabs.setProps({ value: 1 }) - await waitRAF() // Will try activate next non-disabled tab instead (3rd tab, index 2) expect(tabs.vm.currentTab).toBe(2) expect(tabs.emitted('input').length).toBe(1) @@ -290,10 +278,8 @@ describe('tabs', () => { // Needed for test since value not bound to actual v-model on App await tabs.setProps({ value: 2 }) - await waitRAF() // Try and set 2nd BTab to be active await tabs.setProps({ value: 1 }) - await waitRAF() // Will find the previous non-disabled tab (1st tab, index 0) expect(tabs.vm.currentTab).toBe(0) expect(tabs.emitted('input').length).toBe(2) @@ -325,7 +311,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -338,7 +323,6 @@ describe('tabs', () => { // Set 2nd BTab to be active await tabs.setProps({ value: 1 }) - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tabs.emitted('input')).toBeDefined() expect(tabs.emitted('input').length).toBe(1) @@ -352,7 +336,6 @@ describe('tabs', () => { // Attempt to set 3rd BTab to be active await tabs.setProps({ value: 2 }) - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tabs.emitted('input')).toBeDefined() expect(tabs.emitted('input').length).toBe(2) @@ -381,7 +364,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -405,7 +387,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(1) .trigger('click') - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(true) @@ -418,7 +399,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(2) .trigger('click') - await waitRAF() expect(tabs.vm.currentTab).toBe(2) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(false) @@ -431,7 +411,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(0) .trigger('keydown.space') - await waitRAF() expect(tabs.vm.currentTab).toBe(0) expect(tab1.vm.localActive).toBe(true) expect(tab2.vm.localActive).toBe(false) @@ -455,7 +434,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -479,7 +457,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(1) .trigger('keydown.space') - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(true) @@ -492,7 +469,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(2) .trigger('keydown.space') - await waitRAF() expect(tabs.vm.currentTab).toBe(2) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(false) @@ -505,7 +481,6 @@ describe('tabs', () => { .findAll('.nav-link') .at(0) .trigger('keydown.space') - await waitRAF() expect(tabs.vm.currentTab).toBe(0) expect(tab1.vm.localActive).toBe(true) expect(tab2.vm.localActive).toBe(false) @@ -529,7 +504,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -552,7 +526,6 @@ describe('tabs', () => { .findAllComponents(BLink) .at(0) .trigger('keydown.right') - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(true) @@ -563,7 +536,6 @@ describe('tabs', () => { .findAllComponents(BLink) .at(1) .trigger('keydown.end') - await waitRAF() expect(tabs.vm.currentTab).toBe(2) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(false) @@ -574,7 +546,6 @@ describe('tabs', () => { .findAllComponents(BLink) .at(2) .trigger('keydown.left') - await waitRAF() expect(tabs.vm.currentTab).toBe(1) expect(tab1.vm.localActive).toBe(false) expect(tab2.vm.localActive).toBe(true) @@ -585,7 +556,6 @@ describe('tabs', () => { .findAllComponents(BLink) .at(1) .trigger('keydown.home') - await waitRAF() expect(tabs.vm.currentTab).toBe(0) expect(tab1.vm.localActive).toBe(true) expect(tab2.vm.localActive).toBe(false) @@ -608,7 +578,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -625,7 +594,6 @@ describe('tabs', () => { // Disable 3rd tab await tab3.setProps({ disabled: true }) - await waitRAF() // Expect 1st tab to be active expect(tabs.vm.currentTab).toBe(0) @@ -636,7 +604,6 @@ describe('tabs', () => { // Enable 3rd tab and Disable 1st tab await tab3.setProps({ disabled: false }) await tab1.setProps({ disabled: true }) - await waitRAF() // Expect 2nd tab to be active expect(tabs.vm.currentTab).toBe(1) @@ -659,7 +626,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(1) @@ -675,7 +641,6 @@ describe('tabs', () => { tabVm.$slots.title = [tabVm.$createElement('span', 'foobar')] tabVm.$forceUpdate() await waitNT(wrapper.vm) - await waitRAF() // Expect tab button content to be `foobar` expect(wrapper.find('.nav-link').text()).toBe('foobar') @@ -698,7 +663,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -714,7 +678,6 @@ describe('tabs', () => { // Set 2nd tab to be active tabs.setProps({ value: 1 }) await waitNT(wrapper.vm) - await waitRAF() expect(tabs.vm.currentTab).toBe(1) // Expect 2nd tabs nav item to have "active-nav-item-class" applied expect(getNavItemByTab(tabs.vm.tabs[1]).classes(activeNavItemClass)).toBe(true) @@ -739,7 +702,6 @@ describe('tabs', () => { expect(wrapper).toBeDefined() await waitNT(wrapper.vm) - await waitRAF() const tabs = wrapper.findComponent(BTabs) expect(tabs).toBeDefined() expect(tabs.findAllComponents(BTab).length).toBe(3) @@ -752,7 +714,6 @@ describe('tabs', () => { // Set 2nd tab to be active await tabs.setProps({ value: 1 }) - await waitRAF() expect(tabs.vm.currentTab).toBe(1) // Expect 2nd tab to have "active-tab-class" applied expect(tabs.vm.tabs[1].$el.classList.contains(activeTabClass)).toBe(true) diff --git a/src/constants/events.js b/src/constants/events.js index ebfdaf352e0..1cb034142dc 100644 --- a/src/constants/events.js +++ b/src/constants/events.js @@ -1,3 +1,4 @@ +export const EVENT_NAME_ACTIVATE_TAB = 'activate-tab' export const EVENT_NAME_BLUR = 'blur' export const EVENT_NAME_CANCEL = 'cancel' export const EVENT_NAME_CHANGE = 'change' diff --git a/src/constants/safe-types.js b/src/constants/safe-types.js index 39b088ba9f6..f7daf6ccecf 100644 --- a/src/constants/safe-types.js +++ b/src/constants/safe-types.js @@ -1,7 +1,7 @@ import { HAS_WINDOW_SUPPORT, WINDOW } from './env' /* istanbul ignore next */ -const Element = HAS_WINDOW_SUPPORT ? WINDOW.Element : class Element extends Object {} +export const Element = HAS_WINDOW_SUPPORT ? WINDOW.Element : class Element extends Object {} /* istanbul ignore next */ export const HTMLElement = HAS_WINDOW_SUPPORT diff --git a/src/utils/dom.js b/src/utils/dom.js index fdd3ed67157..d9d230f788c 100644 --- a/src/utils/dom.js +++ b/src/utils/dom.js @@ -1,4 +1,5 @@ -import { HAS_WINDOW_SUPPORT, HAS_DOCUMENT_SUPPORT } from '../constants/env' +import { DOCUMENT, WINDOW } from '../constants/env' +import { Element } from '../constants/safe-types' import { from as arrayFrom } from './array' import { isFunction, isNull } from './inspect' import { toFloat } from './number' @@ -6,6 +7,8 @@ import { toString } from './string' // --- Constants --- +const ELEMENT_PROTO = Element.prototype + const TABABLE_SELECTOR = [ 'button', '[href]:not(.disabled)', @@ -18,21 +21,17 @@ const TABABLE_SELECTOR = [ .map(s => `${s}:not(:disabled):not([disabled])`) .join(', ') -const w = HAS_WINDOW_SUPPORT ? window : {} -const d = HAS_DOCUMENT_SUPPORT ? document : {} -const elProto = typeof Element !== 'undefined' ? Element.prototype : {} - // --- Normalization utils --- // See: https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill /* istanbul ignore next */ export const matchesEl = - elProto.matches || elProto.msMatchesSelector || elProto.webkitMatchesSelector + ELEMENT_PROTO.matches || ELEMENT_PROTO.msMatchesSelector || ELEMENT_PROTO.webkitMatchesSelector // See: https://developer.mozilla.org/en-US/docs/Web/API/Element/closest /* istanbul ignore next */ export const closestEl = - elProto.closest || + ELEMENT_PROTO.closest || function(sel) { let el = this do { @@ -48,18 +47,18 @@ export const closestEl = // `requestAnimationFrame()` convenience method /* istanbul ignore next: JSDOM always returns the first option */ export const requestAF = - w.requestAnimationFrame || - w.webkitRequestAnimationFrame || - w.mozRequestAnimationFrame || - w.msRequestAnimationFrame || - w.oRequestAnimationFrame || + WINDOW.requestAnimationFrame || + WINDOW.webkitRequestAnimationFrame || + WINDOW.mozRequestAnimationFrame || + WINDOW.msRequestAnimationFrame || + WINDOW.oRequestAnimationFrame || // Fallback, but not a true polyfill // Only needed for Opera Mini /* istanbul ignore next */ (cb => setTimeout(cb, 16)) export const MutationObs = - w.MutationObserver || w.WebKitMutationObserver || w.MozMutationObserver || null + WINDOW.MutationObserver || WINDOW.WebKitMutationObserver || WINDOW.MozMutationObserver || null // --- Utils --- @@ -71,7 +70,7 @@ export const isElement = el => !!(el && el.nodeType === Node.ELEMENT_NODE) // Get the currently active HTML element export const getActiveElement = (excludes = []) => { - const activeElement = d.activeElement + const { activeElement } = DOCUMENT return activeElement && !excludes.some(el => el === activeElement) ? activeElement : null } @@ -83,7 +82,7 @@ export const isActiveElement = el => isElement(el) && el === getActiveElement() // Determine if an HTML element is visible - Faster than CSS check export const isVisible = el => { - if (!isElement(el) || !el.parentNode || !contains(d.body, el)) { + if (!isElement(el) || !el.parentNode || !contains(DOCUMENT.body, el)) { // Note this can fail for shadow dom elements since they // are not a direct descendant of document.body return false @@ -113,11 +112,11 @@ export const reflow = el => { // Select all elements matching selector. Returns `[]` if none found export const selectAll = (selector, root) => - arrayFrom((isElement(root) ? root : d).querySelectorAll(selector)) + arrayFrom((isElement(root) ? root : DOCUMENT).querySelectorAll(selector)) // Select a single element, returns `null` if not found export const select = (selector, root) => - (isElement(root) ? root : d).querySelector(selector) || null + (isElement(root) ? root : DOCUMENT).querySelector(selector) || null // Determine if an element matches a selector export const matches = (el, selector) => (isElement(el) ? matchesEl.call(el, selector) : false) @@ -140,7 +139,7 @@ export const contains = (parent, child) => parent && isFunction(parent.contains) ? parent.contains(child) : false // Get an element given an ID -export const getById = id => d.getElementById(/^#/.test(id) ? id.slice(1) : id) || null +export const getById = id => DOCUMENT.getElementById(/^#/.test(id) ? id.slice(1) : id) || null // Add a class to an element export const addClass = (el, className) => { @@ -220,12 +219,18 @@ export const getBCR = el => (isElement(el) ? el.getBoundingClientRect() : null) // Get computed style object for an element /* istanbul ignore next: getComputedStyle() doesn't work in JSDOM */ -export const getCS = el => (HAS_WINDOW_SUPPORT && isElement(el) ? w.getComputedStyle(el) : {}) +export const getCS = el => { + const { getComputedStyle } = WINDOW + return getComputedStyle && isElement(el) ? getComputedStyle(el) : {} +} // Returns a `Selection` object representing the range of text selected // Returns `null` if no window support is given /* istanbul ignore next: getSelection() doesn't work in JSDOM */ -export const getSel = () => (HAS_WINDOW_SUPPORT && w.getSelection ? w.getSelection() : null) +export const getSel = () => { + const { getSelection } = WINDOW + return getSelection ? WINDOW.getSelection() : null +} // Return an element's offset with respect to document element // https://j11y.io/jquery/#v=git&fn=jQuery.fn.offset