UNPKG

buefy

Version:

Lightweight UI components for Vue.js (v3) based on Bulma

784 lines (660 loc) 27.5 kB
import { mount, shallowMount } from '@vue/test-utils' import type { DOMWrapper, VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import BAutocomplete from '@components/autocomplete/Autocomplete.vue' const findStringsStartingWith = (array: string[], value: string) => array.filter((x) => x.startsWith(value)) const DATA_LIST = [ 'Angular', 'Angular 2', 'Aurelia', 'Backbone', 'Ember', 'jQuery', 'Meteor', 'Node.js', 'Polymer', 'React', 'RxJS', 'Vue.js' ] const dropdownMenu = '.dropdown-menu' let wrapper: VueWrapper<InstanceType<typeof BAutocomplete>> let $input: DOMWrapper<HTMLInputElement | HTMLTextAreaElement> let $dropdown: DOMWrapper<Element> const stubs = { 'b-icon': true } describe('BAutocomplete', () => { beforeEach(() => { wrapper = mount(BAutocomplete, { attachTo: document.body, // isVisible tests require attachTo props: { checkInfiniteScroll: true }, global: { stubs } }) $input = wrapper.find('input') $dropdown = wrapper.find(dropdownMenu) }) it('renders and initializes correctly', () => { expect(wrapper.vm).toBeTruthy() expect(wrapper.vm.$options.name).toBe('BAutocomplete') }) it('render correctly', () => { expect(wrapper.html()).toMatchSnapshot() }) it('has an input type', () => { expect(wrapper.find('.control .input[type=text]').exists()).toBeTruthy() }) it('has a dropdown menu hidden by default', () => { expect(wrapper.find(dropdownMenu).exists()).toBeTruthy() expect($dropdown.isVisible()).toBeFalsy() }) it('can apply a maximum height for the dropdown', async () => { expect(wrapper.vm.contentStyle.maxHeight).toBeUndefined() const maxHeight = 200 await wrapper.setProps({ maxHeight }) expect(wrapper.vm.contentStyle.maxHeight).toBe(`${maxHeight}px`) await wrapper.setProps({ maxHeight: `${maxHeight}rem` }) expect(wrapper.vm.contentStyle.maxHeight).toBe(`${maxHeight}rem`) }) it('can select a value using v-model', async () => { const modelValue = DATA_LIST[0] await wrapper.setProps({ modelValue }) expect(wrapper.vm.newValue).toBe(modelValue) }) it('can emit input, focus and blur events', async () => { const VALUE_TYPED = 'test' await wrapper.setProps({ data: DATA_LIST }) await $input.trigger('focus') expect(wrapper.emitted().focus).toBeTruthy() await $input.setValue(VALUE_TYPED) const valueEmitted = wrapper.emitted()['update:modelValue'][0] expect(valueEmitted).toContainEqual(VALUE_TYPED) await $input.trigger('blur') expect(wrapper.emitted().blur).toBeTruthy() }) it('can emit select-header by keyboard and click', async () => { const VALUE_TYPED = 'test' const wrapper = mount(BAutocomplete, { props: { checkInfiniteScroll: true, selectableHeader: true, selectableFooter: true }, slots: { header: '<h1>SLOT HEADER</h1>', footer: '<h1>SLOT FOOTER</h1>' }, global: { stubs } }) const $input = wrapper.find('input') await $input.trigger('focus') await $input.setValue(VALUE_TYPED) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) const $header = wrapper.find('.dropdown-item.dropdown-header') await $header.trigger('click') const emitted = wrapper.emitted() expect(emitted['select-header']).toBeTruthy() expect(emitted['select-header']).toHaveLength(2) }) it('can emit select-footer by keyboard and click', async () => { const VALUE_TYPED = 'test' const wrapper = mount(BAutocomplete, { propsData: { checkInfiniteScroll: true, selectableHeader: true, selectableFooter: true }, slots: { header: '<h1>SLOT HEADER</h1>', footer: '<h1>SLOT FOOTER</h1>' }, global: { stubs } }) const $input = wrapper.find('input') await $input.trigger('focus') await $input.setValue(VALUE_TYPED) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) await $input.trigger('blur') const $footer = wrapper.find('.dropdown-item.dropdown-footer') await $footer.trigger('click') const emitted = wrapper.emitted() expect(emitted['select-footer']).toBeTruthy() expect(emitted['select-footer']).toHaveLength(2) }) it('can autocomplete with keydown', async () => { const VALUE_TYPED = 'Ang' await wrapper.setProps({ data: DATA_LIST }) await $input.trigger('focus') await $input.setValue(VALUE_TYPED) expect($dropdown.isVisible()).toBeTruthy() const itemsInDropdowm = findStringsStartingWith(DATA_LIST, VALUE_TYPED) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe(itemsInDropdowm[0]) expect($dropdown.isVisible()).toBeFalsy() await $input.trigger('focus') await $input.setValue(VALUE_TYPED) expect($dropdown.isVisible()).toBeTruthy() await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe(itemsInDropdowm[1]) expect($dropdown.isVisible()).toBeFalsy() }) it('close dropdown on esc', async () => { vi.useFakeTimers() await wrapper.setProps({ data: DATA_LIST }) await wrapper.setData({ isActive: true }) expect($dropdown.isVisible()).toBeTruthy() await $input.trigger('keydown', { key: 'Escape' }) expect($dropdown.isVisible()).toBeFalsy() wrapper.vm.calcDropdownInViewportVertical = vi.fn( () => wrapper.vm.calcDropdownInViewportVertical ) vi.runAllTimers() expect(wrapper.vm.calcDropdownInViewportVertical).toHaveBeenCalled() vi.useRealTimers() }) it('close dropdown on click outside', async () => { await wrapper.setProps({ data: DATA_LIST }) await wrapper.setData({ isActive: true }) expect($dropdown.isVisible()).toBeTruthy() window.document.body.click() await wrapper.vm.$nextTick() expect($dropdown.isVisible()).toBeFalsy() vi.useRealTimers() }) it('open dropdown on down key click', async () => { wrapper.vm.setHovered = vi.fn(() => wrapper.vm.setHovered) await wrapper.setProps({ data: DATA_LIST, dropdownPosition: 'bottom' }) expect($dropdown.isVisible()).toBeFalsy() await $input.trigger('focus') await $input.trigger('keydown.down') expect($dropdown.isVisible()).toBeTruthy() }) it('manages tab pressed as expected', async () => { // hovered is null await $input.trigger('keydown', { key: 'Tab' }) expect($dropdown.isVisible()).toBeFalsy() // The first element will be hovered await wrapper.setProps({ openOnFocus: true, keepFirst: true }) await wrapper.setProps({ data: DATA_LIST }) // Set props in 2 steps to trigger Watch with keepFirst true so the 1st element is hovered await $input.trigger('focus') await $input.trigger('keydown', { key: 'Tab' }) expect($input.element.value).toBe(DATA_LIST[0]) }) it('can be used with objects', async () => { const data = [ { id: 1, name: 'Value 1' }, { id: 2, name: 'Value 2' } ] await wrapper.setProps({ data, field: 'name' }) await wrapper.setData({ isActive: true }) expect($dropdown.isVisible()).toBeTruthy() await $input.trigger('keydown', { key: 'Enter' }) expect(wrapper.vm.hovered).toBeNull() await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe(data[0].name) }) it('clears the value if clearOnSelect is used', async () => { await wrapper.setProps({ data: DATA_LIST, clearOnSelect: true }) await wrapper.setData({ isActive: true }) expect($dropdown.isVisible()).toBeTruthy() await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe('') }) it('supports custom formatter', async () => { await wrapper.setProps({ data: DATA_LIST, customFormatter: (val: string) => `${val} formatted` }) await wrapper.setData({ isActive: true }) expect($dropdown.isVisible()).toBeTruthy() await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe(`${DATA_LIST[0]} formatted`) }) it('can openOnFocus and keepFirst', async () => { await wrapper.setProps({ openOnFocus: true, keepFirst: true }) expect($dropdown.isVisible()).toBeFalsy() await $input.trigger('focus') expect(wrapper.vm.hovered).toBeNull() await wrapper.setProps({ data: DATA_LIST }) // Set props in 2 steps to trigger the Watch for data having keepFirst true await $input.trigger('focus') expect($dropdown.isVisible()).toBeTruthy() expect(wrapper.vm.hovered).toBe(DATA_LIST[0]) }) it('clear button does not exist when the search input is empty', async () => { await wrapper.setData({ newValue: '' }) await wrapper.setProps({ clearable: true }) const subject = wrapper.find('b-icon-stub').exists() expect(subject).toBeFalsy() }) it('clear button exists when the search input is not empty and clearable property is true', async () => { await wrapper.setData({ newValue: 'Jquery' }) await wrapper.setProps({ clearable: true }) const subject = wrapper.find('b-icon-stub').exists() expect(subject).toBeTruthy() }) it('clears search input text when clear button gets clicked', async () => { await wrapper.setData({ newValue: 'Jquery' }) await wrapper.setProps({ clearable: true }) wrapper.find('b-icon-stub').trigger('click') const subject = wrapper.vm.newValue expect(subject).toEqual('') }) it('clear button does not appear when clearable property is not set to true', async () => { await wrapper.setData({ newValue: 'Jquery' }) const subject = wrapper.find('b-icon-stub').exists() expect(subject).toBeFalsy() }) it('can have a custom clickable right icon', async () => { await wrapper.setProps({ iconRight: 'delete', iconRightClickable: true }) const icon = wrapper.find('b-icon-stub') expect(icon.exists()).toBeTruthy() await icon.trigger('click') expect(wrapper.emitted()['icon-right-click']).toBeTruthy() }) it('cleans up event listeners on unmount', () => { document.removeEventListener = vi.fn() window.removeEventListener = vi.fn() wrapper.unmount() expect(document.removeEventListener).toBeCalledWith('click', expect.any(Function)) expect(window.removeEventListener).toBeCalledWith('resize', expect.any(Function)) }) it('emit active with payload true', async () => { await wrapper.setProps({ data: DATA_LIST, openOnFocus: true, keepFirst: true }) await $input.trigger('focus') const { active } = wrapper.emitted() expect(active).toBeTruthy() expect(active[0]).toEqual([true]) }) it('updates ariaAutocomplete with keepFirst', async () => { await wrapper.setProps({ keepFirst: true }) expect(wrapper.vm.ariaAutocomplete).toBe('both') await wrapper.setProps({ keepFirst: false }) expect(wrapper.vm.ariaAutocomplete).toBe('list') }) it('clears selection when value changes from 0', async () => { await wrapper.setProps({ data: [0, 1] }) wrapper.vm.setSelected(0) await wrapper.vm.$nextTick() await wrapper.setData({ newValue: '1' }) expect(wrapper.vm.selected).toBeNull() }) it('opens dropdown when value is 0', async () => { await wrapper.setProps({ data: ['0', '1'] }) await wrapper.setData({ hasFocus: true }) await $input.setValue('0') expect(wrapper.vm.isActive).toBe(true) }) it('updates model when clearOnSelect is used', async () => { await wrapper.setProps({ data: DATA_LIST, clearOnSelect: true }) await wrapper.setData({ isActive: true }) await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Enter' }) const emitted = wrapper.emitted()['update:modelValue'] expect(emitted[emitted.length - 1]).toEqual(['']) }) it('updates whiteList when data changes', async () => { await wrapper.setProps({ data: ['a'] }) const len = wrapper.vm.whiteList.length await wrapper.setProps({ data: ['a', 'b'] }) await wrapper.vm.$nextTick() expect(wrapper.vm.whiteList.length).toBeGreaterThan(len) }) describe('fallthrough attributes', () => { const attrs = { class: 'fallthrough-class', style: 'font-size: 2rem;', id: 'fallthrough-id' } it('binds class/style/id to root if compatFallthrough true', async () => { const wrapper = shallowMount(BAutocomplete, { attrs }) const root = wrapper.find('div.autocomplete.control') expect(root.classes(attrs.class)).toBe(true) expect(root.attributes('style')).toBe(attrs.style) expect(root.attributes('id')).toBe(attrs.id) const input = wrapper.findComponent({ ref: 'input' }) expect(input.classes(attrs.class)).toBe(false) expect(input.attributes('style')).toBeUndefined() expect(input.attributes('id')).toBeUndefined() }) it('binds class/style/id to input if compatFallthrough false', async () => { const wrapper = shallowMount(BAutocomplete, { attrs, props: { compatFallthrough: false } }) const input = wrapper.findComponent({ ref: 'input' }) expect(input.classes(attrs.class)).toBe(true) expect(input.attributes('style')).toBe(attrs.style) expect(input.attributes('id')).toBe(attrs.id) const root = wrapper.find('div.autocomplete.control') expect(root.classes(attrs.class)).toBe(false) expect(root.attributes('style')).toBeUndefined() expect(root.attributes('id')).toBeUndefined() }) }) // Test for bug #4098: Arrow key selection doesn't work with computed data it('should handle arrow key navigation with computed data containing objects', async () => { // This test reproduces the bug where arrow key navigation fails with computed data // that contains objects, due to proxy comparison issues const DATA_OBJECTS = [ { id: 1, name: 'Angular' }, { id: 2, name: 'React' }, { id: 3, name: 'Vue.js' } ] // Simulate computed data by creating a new array reference each time // This mimics how Vue's reactivity system creates new proxy objects const createComputedData = () => { return DATA_OBJECTS.map((item) => ({ ...item })) } // Set initial data await wrapper.setProps({ data: createComputedData(), field: 'name', openOnFocus: true }) // Focus and open dropdown await $input.trigger('focus') expect($dropdown.isVisible()).toBeTruthy() // Update data to simulate computed property changes (creates new object references) // This is where the bug manifests - the hovered item reference becomes stale await wrapper.setProps({ data: createComputedData() }) // Try to navigate with arrow keys await $input.trigger('keydown', { key: 'Down' }) // The first item should be hovered after pressing Down expect(wrapper.vm.hovered).not.toBeNull() expect(wrapper.vm.hovered?.name).toBe('Angular') // Navigate to second item await $input.trigger('keydown', { key: 'Down' }) // This should move to the second item, but due to the bug it might not work expect(wrapper.vm.hovered?.name).toBe('React') // Select the hovered item await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe('React') }) it('should handle arrow key navigation consistently after data updates', async () => { // Additional test to verify the fix works with multiple data updates const DATA_OBJECTS = [ { id: 1, name: 'Angular', category: 'framework' }, { id: 2, name: 'React', category: 'library' }, { id: 3, name: 'Vue.js', category: 'framework' } ] await wrapper.setProps({ data: DATA_OBJECTS, field: 'name', openOnFocus: true }) await $input.trigger('focus') expect($dropdown.isVisible()).toBeTruthy() // Navigate to second item await $input.trigger('keydown', { key: 'Down' }) await $input.trigger('keydown', { key: 'Down' }) expect(wrapper.vm.hovered?.name).toBe('React') // Update data (simulating computed property change) const updatedData = DATA_OBJECTS.map((item) => ({ ...item, updated: true })) await wrapper.setProps({ data: updatedData }) // Navigation should still work after data update await $input.trigger('keydown', { key: 'Down' }) expect(wrapper.vm.hovered?.name).toBe('Vue.js') await $input.trigger('keydown', { key: 'Enter' }) expect($input.element.value).toBe('Vue.js') }) it('handles appendToBody prop changes', async () => { const data = ['Angular', 'Vue.js', 'React'] const wrapper = mount(BAutocomplete, { props: { data, appendToBody: false }, attachTo: document.body }) await wrapper.setProps({ appendToBody: true }) const input = wrapper.find('input') await input.trigger('focus') wrapper.vm.newValue = 'Vue' await wrapper.vm.$nextTick() expect(wrapper.vm.isActive).toBe(true) wrapper.unmount() }) it('handles appendToBody switching back and forth', async () => { const data = ['Angular', 'Vue.js', 'React'] const wrapper = mount(BAutocomplete, { props: { data, appendToBody: false, openOnFocus: true }, attachTo: document.body }) const input = wrapper.find('input') await input.trigger('focus') await wrapper.setProps({ appendToBody: true }) wrapper.vm.newValue = 'Vue' await wrapper.vm.$nextTick() expect(wrapper.vm.$data._bodyEl).toBeDefined() await wrapper.setProps({ appendToBody: false }) expect(wrapper.vm.$data._bodyEl).toBeUndefined() expect(wrapper.vm.isActive).toBe(true) wrapper.unmount() }) it('navigates correctly with infinite scroll)', async () => { interface MovieData { title: string; poster_path: string; release_date: string; vote_average: number } const firstPageResults: MovieData[] = [ { title: 'Spider-Man', poster_path: 'path1.jpg', release_date: '2002-05-03', vote_average: 7.3 }, { title: 'Spider-Man 2', poster_path: 'path2.jpg', release_date: '2004-06-30', vote_average: 7.3 }, { title: 'Spider-Man 3', poster_path: 'path3.jpg', release_date: '2007-05-04', vote_average: 6.2 } ] const secondPageResults: MovieData[] = [ { title: 'The Amazing Spider-Man', poster_path: 'path4.jpg', release_date: '2012-07-03', vote_average: 6.9 }, { title: 'The Amazing Spider-Man 2', poster_path: 'path5.jpg', release_date: '2014-05-02', vote_average: 6.6 }, { title: 'Spider-Man: Homecoming', poster_path: 'path6.jpg', release_date: '2017-07-07', vote_average: 7.4 } ] const wrapper = mount(BAutocomplete, { attachTo: document.body, props: { data: [], field: 'title', checkInfiniteScroll: true, loading: false } }) try { const input = wrapper.find('input') wrapper.vm.isActive = true await nextTick() await wrapper.setProps({ data: firstPageResults }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Spider-Man') await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Spider-Man 2') await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Spider-Man 3') const combinedResults = [...firstPageResults, ...secondPageResults] await wrapper.setProps({ data: combinedResults }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('The Amazing Spider-Man') await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('The Amazing Spider-Man 2') await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Spider-Man: Homecoming') } finally { wrapper.unmount() } }) it('handles rapid async data updates', async () => { const wrapper = mount(BAutocomplete, { attachTo: document.body, props: { data: [], field: 'title', checkInfiniteScroll: true } }) try { const input = wrapper.find('input') wrapper.vm.isActive = true await nextTick() const updates = [ [{ title: 'Movie A' }], [{ title: 'Movie A' }, { title: 'Movie B' }], [{ title: 'Movie A' }, { title: 'Movie B' }, { title: 'Movie C' }], [{ title: 'Movie A' }, { title: 'Movie B' }, { title: 'Movie C' }, { title: 'Movie D' }] ] for (const update of updates) { await wrapper.setProps({ data: update }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) } expect(wrapper.vm.hovered.title).toBe('Movie D') } finally { wrapper.unmount() } }) it('handles navigation at boundary with loading state', async () => { const wrapper = mount(BAutocomplete, { attachTo: document.body, props: { data: [{ title: 'Result 1' }, { title: 'Result 2' }, { title: 'Result 3' }], field: 'title', checkInfiniteScroll: true, loading: false } }) try { const input = wrapper.find('input') wrapper.vm.isActive = true await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) await input.trigger('keydown', { key: 'ArrowDown' }) await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Result 3') await wrapper.setProps({ loading: true }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Result 3') await wrapper.setProps({ loading: false, data: [{ title: 'Result 1' }, { title: 'Result 2' }, { title: 'Result 3' }, { title: 'Result 4' }, { title: 'Result 5' }] }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.title).toBe('Result 4') } finally { wrapper.unmount() } }) it('handles duplicate getValue results (reference and value fallback)', async () => { const wrapper = mount(BAutocomplete, { attachTo: document.body, props: { data: [ { name: 'John', role: 'Developer', id: 1 }, { name: 'John', role: 'Designer', id: 2 }, { name: 'John', role: 'Manager', id: 3 } ], field: 'name', checkInfiniteScroll: true } }) try { const input = wrapper.find('input') wrapper.vm.isActive = true await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.role).toBe('Designer') const originalData = wrapper.vm.data const moreJohns = [ { name: 'John', role: 'Tester', id: 4 }, { name: 'John', role: 'Admin', id: 5 } ] await wrapper.setProps({ data: [...originalData, ...moreJohns] }) await nextTick() await input.trigger('keydown', { key: 'ArrowDown' }) expect(wrapper.vm.hovered.role).toBe('Manager') } finally { wrapper.unmount() } }) })