diff --git a/src/config.ts b/src/config.ts index 48eaad5cb..c7e9c31b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ +import { ComponentPublicInstance } from 'vue' import { GlobalMountOptions } from './types' import { VueWrapper } from './vueWrapper' -import { ComponentPublicInstance } from 'vue' interface GlobalConfigOptions { global: GlobalMountOptions @@ -19,7 +19,7 @@ interface Plugin { } class Pluggable { - installedPlugins = [] as Array + installedPlugins: Plugin[] = [] install( handler: ( @@ -55,7 +55,12 @@ class Pluggable { } export const config: GlobalConfigOptions = { - global: {}, + global: { + stubs: { + transition: true, + 'transition-group': true + } + }, plugins: { VueWrapper: new Pluggable(), DOMWrapper: new Pluggable() diff --git a/src/domWrapper.ts b/src/domWrapper.ts index 503346399..b72f3d29a 100644 --- a/src/domWrapper.ts +++ b/src/domWrapper.ts @@ -2,6 +2,7 @@ import { nextTick } from 'vue' import { createWrapperError } from './errorWrapper' import { TriggerOptions, createDOMEvent } from './createDomEvent' +import { isElementVisible } from './utils/isElementVisible' export class DOMWrapper { element: ElementType @@ -36,6 +37,10 @@ export class DOMWrapper { return true } + isVisible() { + return isElementVisible(this.element) + } + text() { return this.element.textContent?.trim() } diff --git a/src/mount.ts b/src/mount.ts index a88960a41..ada6d81b5 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -350,11 +350,9 @@ export function mount( app.mixin(attachEmitListener()) // stubs - if (global.stubs || options?.shallow) { - stubComponents(global.stubs, options?.shallow) - } else { - transformVNodeArgs() - } + // even if we are using `mount`, we will still + // stub out Transition and Transition Group by default. + stubComponents(global.stubs, options?.shallow) // mount the app! const vm = app.mount(el) diff --git a/src/stubs.ts b/src/stubs.ts index d4a68e2e6..42ad49217 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -1,5 +1,7 @@ import { transformVNodeArgs, + Transition, + TransitionGroup, h, ComponentPublicInstance, Slots, @@ -34,6 +36,17 @@ const createStub = ({ name, props }: StubOptions): ComponentOptions => { return defineComponent({ name: name || anonName, render, props }) } +const createTransitionStub = ({ + name, + props +}: StubOptions): ComponentOptions => { + const render = (ctx: ComponentPublicInstance) => { + return h(name, {}, ctx.$slots) + } + + return defineComponent({ name, render, props }) +} + const resolveComponentStubByName = ( componentName: string, stubs: Record @@ -78,6 +91,28 @@ export function stubComponents( transformVNodeArgs((args, instance: ComponentInternalInstance | null) => { const [nodeType, props, children, patchFlag, dynamicProps] = args const type = nodeType as VNodeTypes + + // stub transition by default via config.global.stubs + if (type === Transition && stubs['transition']) { + return [ + createTransitionStub({ name: 'transition-stub', props: undefined }), + undefined, + children + ] + } + + // stub transition-group by default via config.global.stubs + if (type === TransitionGroup && stubs['transition-group']) { + return [ + createTransitionStub({ + name: 'transition-group-stub', + props: undefined + }), + undefined, + children + ] + } + // args[0] can either be: // 1. a HTML tag (div, span...) // 2. An object of component options, such as { name: 'foo', render: [Function], props: {...} } diff --git a/src/types.ts b/src/types.ts index 890a412ab..df204dfaf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export type GlobalMountOptions = { components?: Record directives?: Record stubs?: Record + renderStubDefaultSlot?: boolean } export interface VueWrapperMeta { diff --git a/src/utils/isElementVisible.ts b/src/utils/isElementVisible.ts new file mode 100644 index 000000000..4ca1ffb46 --- /dev/null +++ b/src/utils/isElementVisible.ts @@ -0,0 +1,36 @@ +/*! + * isElementVisible + * Adapted from https://github.com/testing-library/jest-dom + * Licensed under the MIT License. + */ + +function isStyleVisible(element: T) { + if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { + return false + } + + const { display, visibility, opacity } = element.style + + return ( + display !== 'none' && + visibility !== 'hidden' && + visibility !== 'collapse' && + opacity !== '0' + ) +} + +function isAttributeVisible(element: T) { + return ( + !element.hasAttribute('hidden') && + (element.nodeName === 'DETAILS' ? element.hasAttribute('open') : true) + ) +} + +export function isElementVisible(element: T) { + return ( + element.nodeName !== '#comment' && + isStyleVisible(element) && + isAttributeVisible(element) && + (!element.parentElement || isElementVisible(element.parentElement)) + ) +} diff --git a/tests/isVisible.spec.ts b/tests/isVisible.spec.ts new file mode 100644 index 000000000..bce409303 --- /dev/null +++ b/tests/isVisible.spec.ts @@ -0,0 +1,118 @@ +import { mount } from '../src' + +describe('isVisible', () => { + const Comp = { + template: `
`, + props: { + show: { + type: Boolean + } + } + } + + it('returns false when element hidden via v-show', () => { + const wrapper = mount(Comp, { + props: { + show: false + } + }) + + expect(wrapper.find('span').isVisible()).toBe(false) + }) + + it('returns true when element is visible via v-show', () => { + const wrapper = mount(Comp, { + props: { + show: true + } + }) + + expect(wrapper.find('span').isVisible()).toBe(true) + }) + + it('returns false when element parent is invisible via v-show', () => { + const Comp = { + template: `
` + } + const wrapper = mount(Comp) + + expect(wrapper.find('span').isVisible()).toBe(false) + }) + + it('element becomes hidden reactively', async () => { + const Comp = { + template: ` + + + + Item: {{ item }} + + + + `, + methods: { + add() { + this.items.push(2) + }, + remove() { + this.items.splice(1) // back to [1] + } + }, + data() { + return { + items: [1] + } + } + } + const wrapper = mount(Comp) + + expect(wrapper.html()).toContain('Item: 1') + await wrapper.find('#add').trigger('click') + expect(wrapper.html()).toContain('Item: 1') + expect(wrapper.html()).toContain('Item: 2') + await wrapper.find('#remove').trigger('click') + expect(wrapper.html()).toContain('Item: 1') + expect(wrapper.html()).not.toContain('Item: 2') + }) +}) diff --git a/tests/mountingOptions/stubs.global.spec.ts b/tests/mountingOptions/stubs.global.spec.ts index 2d5f7bbe6..1fe7280fb 100644 --- a/tests/mountingOptions/stubs.global.spec.ts +++ b/tests/mountingOptions/stubs.global.spec.ts @@ -6,12 +6,13 @@ import ComponentWithoutName from '../components/ComponentWithoutName.vue' import ComponentWithSlots from '../components/ComponentWithSlots.vue' describe('mounting options: stubs', () => { + let configStubsSave = config.global.stubs beforeEach(() => { - config.global.stubs = {} + config.global.stubs = configStubsSave }) afterEach(() => { - config.global.stubs = {} + config.global.stubs = configStubsSave }) it('handles Array syntax', () => { @@ -308,6 +309,59 @@ describe('mounting options: stubs', () => { expect(wrapper.html()).toBe('') }) + it('stubs transition by default', () => { + const Comp = { + template: `
` + } + const wrapper = mount(Comp) + + expect(wrapper.html()).toBe( + '
' + ) + }) + + it('opts out of stubbing transition by default', () => { + const Comp = { + template: `
` + } + const wrapper = mount(Comp, { + global: { + stubs: { + transition: false + } + } + }) + + // Vue removes at run-time and does it's magic, so should not + // appear in the html when it isn't stubbed. + expect(wrapper.html()).toBe('
') + }) + + it('opts out of stubbing transition-group by default', () => { + const Comp = { + template: `
` + } + const wrapper = mount(Comp, { + global: { + stubs: { + 'transition-group': false + } + } + }) + + // Vue removes at run-time and does it's magic, so should not + // appear in the html when it isn't stubbed. + expect(wrapper.html()).toBe('
') + }) + + it('stubs transition-group by default', () => { + const Comp = { + template: `
` + } + const wrapper = mount(Comp) + expect(wrapper.find('#content').exists()).toBe(true) + }) + describe('stub slots', () => { const Component = { name: 'Parent',