mirror of
https://github.com/nuxt/ui.git
synced 2026-09-14 19:51:10 +08:00
0414dd0d1b
Co-authored-by: lorado <4480983+lorado@users.noreply.github.com>
425 lines
18 KiB
TypeScript
425 lines
18 KiB
TypeScript
import { describe, it, expect, test } from 'vitest'
|
|
import { axe } from 'vitest-axe'
|
|
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
|
import { renderEach } from '../component-render'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import InputMenu from '../../src/runtime/components/InputMenu.vue'
|
|
import type { FormInputEvents } from '../../src/module'
|
|
import { renderForm } from '../utils/form'
|
|
import { expectEmitPayloadType } from '../utils/types'
|
|
import theme from '#build/ui/input'
|
|
|
|
describe('InputMenu', () => {
|
|
const sizes = Object.keys(theme.variants.size) as any
|
|
const variants = Object.keys(theme.variants.variant) as any
|
|
|
|
const items = [{
|
|
label: 'Backlog',
|
|
value: 'backlog',
|
|
icon: 'i-lucide-circle-help'
|
|
}, {
|
|
label: 'Todo',
|
|
value: 'todo',
|
|
icon: 'i-lucide-circle-plus'
|
|
}, {
|
|
label: 'In Progress',
|
|
value: 'in_progress',
|
|
icon: 'i-lucide-circle-arrow-up'
|
|
}, {
|
|
label: 'Done',
|
|
value: 'done',
|
|
icon: 'i-lucide-circle-check'
|
|
}, {
|
|
label: 'Canceled',
|
|
value: 'canceled',
|
|
icon: 'i-lucide-circle-x'
|
|
}]
|
|
|
|
const itemsWithDescription = [...items.map(item => ({ ...item, description: 'Description' }))]
|
|
|
|
const props = { open: true, portal: false, items }
|
|
|
|
renderEach(InputMenu, [
|
|
// Props
|
|
['with items', { props }],
|
|
['with items with description', { props: { ...props, items: itemsWithDescription } }],
|
|
['with modelValue', { props: { ...props, modelValue: items[0] } }],
|
|
['with defaultValue', { props: { ...props, defaultValue: items[0] } }],
|
|
['with valueKey', { props: { ...props, valueKey: 'label', defaultValue: 'Backlog' } }],
|
|
['with by', { props: { ...props, by: 'value', defaultValue: items[0] } }],
|
|
['with labelKey', { props: { ...props, labelKey: 'value' } }],
|
|
['with descriptionKey', { props: { ...props, descriptionKey: 'description' } }],
|
|
['with multiple', { props: { ...props, multiple: true } }],
|
|
['with multiple and modelValue', { props: { ...props, multiple: true, modelValue: [items[0], items[1]] } }],
|
|
['with id', { props: { ...props, id: 'id' } }],
|
|
['with name', { props: { ...props, name: 'name' } }],
|
|
['with placeholder', { props: { ...props, placeholder: 'Search...' } }],
|
|
['with disabled', { props: { ...props, disabled: true } }],
|
|
['with required', { props: { ...props, required: true } }],
|
|
// Autocomplete
|
|
['with autocomplete mode', { props: { ...props, mode: 'autocomplete' as const } }],
|
|
['with autocomplete mode and modelValue', { props: { ...props, mode: 'autocomplete' as const, modelValue: 'Backlog' } }],
|
|
['with autocomplete mode and defaultValue', { props: { ...props, mode: 'autocomplete' as const, defaultValue: 'Backlog' } }],
|
|
['with autocomplete mode and placeholder', { props: { ...props, mode: 'autocomplete' as const, placeholder: 'Type something...' } }],
|
|
['with autocomplete mode and content', { props: { ...props, mode: 'autocomplete' as const, content: { hideWhenEmpty: true } } }],
|
|
['with html autocomplete attribute', { props: { ...props, autocomplete: 'email' } }],
|
|
['with icon', { props: { icon: 'i-lucide-search' } }],
|
|
['with leading and icon', { props: { leading: true, icon: 'i-lucide-arrow-left' } }],
|
|
['with leadingIcon', { props: { leadingIcon: 'i-lucide-arrow-left' } }],
|
|
['with trailing and icon', { props: { trailing: true, icon: 'i-lucide-arrow-right' } }],
|
|
['with trailingIcon', { props: { trailingIcon: 'i-lucide-arrow-right' } }],
|
|
['with avatar', { props: { avatar: { src: 'https://github.com/benjamincanac.png' } } }],
|
|
['with avatar and leadingIcon', { props: { avatar: { src: 'https://github.com/benjamincanac.png' }, leadingIcon: 'i-lucide-arrow-left' } }],
|
|
['with avatar and trailingIcon', { props: { avatar: { src: 'https://github.com/benjamincanac.png' }, trailingIcon: 'i-lucide-arrow-right' } }],
|
|
['with loading', { props: { loading: true } }],
|
|
['with loading and avatar', { props: { loading: true, avatar: { src: 'https://github.com/benjamincanac.png' } } }],
|
|
['with loading trailing', { props: { loading: true, trailing: true } }],
|
|
['with loading trailing and avatar', { props: { loading: true, trailing: true, avatar: { src: 'https://github.com/benjamincanac.png' } } }],
|
|
['with loadingIcon', { props: { loading: true, loadingIcon: 'i-lucide-loader' } }],
|
|
['with trailingIcon', { props: { ...props, trailingIcon: 'i-lucide-chevron-down' } }],
|
|
['with selectedIcon', { props: { ...props, selectedIcon: 'i-lucide-check' } }],
|
|
['with clear', { props: { ...props, clear: true, modelValue: items[0] } }],
|
|
['with clear and clearIcon', { props: { ...props, clear: true, clearIcon: 'i-lucide-x', modelValue: items[0] } }],
|
|
['with arrow', { props: { ...props, arrow: true } }],
|
|
['with virtualize', { props: { ...props, virtualize: true } }],
|
|
...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
|
|
...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { ...props, variant } }]),
|
|
...variants.map((variant: string) => [`with primary variant ${variant} highlight`, { props: { ...props, variant, highlight: true } }]),
|
|
...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { ...props, variant, color: 'neutral' } }]),
|
|
...variants.map((variant: string) => [`with neutral variant ${variant} highlight`, { props: { ...props, variant, color: 'neutral', highlight: true } }]),
|
|
['with ariaLabel', { props, attrs: { 'aria-label': 'Aria label' } }],
|
|
['with as', { props: { ...props, as: 'section' } }],
|
|
['with class', { props: { ...props, class: 'absolute' } }],
|
|
['with ui', { props: { ...props, ui: { group: 'p-2' } } }],
|
|
// Slots
|
|
['with leading slot', { slots: { leading: () => 'Leading slot' } }],
|
|
['with default slot', { slots: { default: () => 'Default slot' } }],
|
|
['with trailing slot', { slots: { trailing: () => 'Trailing slot' } }],
|
|
['with item slot', { props, slots: { item: () => 'Item slot' } }],
|
|
['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
|
|
['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
|
|
['with item-description slot', { props: { ...props, items: itemsWithDescription }, slots: { 'item-description': () => 'Item description slot' } }],
|
|
['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
|
|
['with create-item-label slot', { props: { ...props, searchTerm: 'New value', createItem: true }, slots: { 'create-item-label': () => 'Create item slot' } }]
|
|
])
|
|
|
|
renderEach(
|
|
InputMenu,
|
|
[
|
|
['with .trim modifier', { props: { modelModifiers: { trim: true } } }, { input: 'input ', expected: 'input' }],
|
|
['with .number modifier', { props: { modelModifiers: { number: true } } }, { input: '42', expected: 42 }],
|
|
['with .nullable modifier', { props: { modelModifiers: { nullable: true } } }, { input: null, expected: null }],
|
|
['with .optional modifier', { props: { modelModifiers: { optional: true } } }, { input: undefined, expected: undefined }]
|
|
],
|
|
'%s works',
|
|
async (_, options, spec) => {
|
|
const wrapper = mount(InputMenu, {
|
|
...options
|
|
})
|
|
|
|
const input = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
await input.setValue(spec.input)
|
|
|
|
expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [[spec.expected]] })
|
|
}
|
|
)
|
|
|
|
it('with trailing false should not render trailing section', () => {
|
|
const wrapper = mount(InputMenu, {
|
|
props: {
|
|
...props,
|
|
trailing: false
|
|
}
|
|
})
|
|
|
|
expect(wrapper.find('[data-slot="trailing"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-slot="trailingIcon"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('passes accessibility tests', async () => {
|
|
const wrapper = await mountSuspended(InputMenu, {
|
|
props: {
|
|
items,
|
|
modelValue: items[0],
|
|
placeholder: 'Select an item',
|
|
icon: 'i-lucide-search',
|
|
trailingIcon: 'i-lucide-chevron-down',
|
|
required: true
|
|
}
|
|
})
|
|
|
|
expect(await axe(wrapper.element)).toHaveNoViolations()
|
|
})
|
|
|
|
describe('emits', () => {
|
|
test('update:modelValue event', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'] } })
|
|
const input = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
await input.setValue('Option 1')
|
|
expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [['Option 1']] })
|
|
})
|
|
|
|
test('change event', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'] } })
|
|
const input = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
await input.setValue('Option 1')
|
|
expect(wrapper.emitted()).toMatchObject({ change: [[{ type: 'change' }]] })
|
|
})
|
|
|
|
test('blur event', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'] } })
|
|
const input = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
await input.vm.$emit('update:open', false)
|
|
expect(wrapper.emitted()).toMatchObject({ blur: [[{ type: 'blur' }]] })
|
|
})
|
|
|
|
test('remove-tag event', async () => {
|
|
const wrapper = mount(InputMenu, { props: { modelValue: ['Option 1'], items: ['Option 1', 'Option 2'], multiple: true } })
|
|
const input = wrapper.findComponent({ name: 'TagsInputRoot' })
|
|
await input.vm.$emit('remove-tag', 'Option 1')
|
|
expect(wrapper.emitted()).toMatchObject({ 'remove-tag': [['Option 1']] })
|
|
})
|
|
|
|
test('update:modelValue event with autocomplete', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete' as const } })
|
|
const input = wrapper.findComponent({ name: 'AutocompleteRoot' })
|
|
await input.setValue('Option 1')
|
|
expect(wrapper.emitted()).toMatchObject({ 'update:modelValue': [['Option 1']] })
|
|
})
|
|
|
|
test('change event with autocomplete', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete' as const } })
|
|
const input = wrapper.findComponent({ name: 'AutocompleteRoot' })
|
|
await input.setValue('Option 1')
|
|
expect(wrapper.emitted()).toMatchObject({ change: [[{ type: 'change' }]] })
|
|
})
|
|
|
|
test('searchTerm syncs when parent updates modelValue in autocomplete mode', async () => {
|
|
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete' as const, modelValue: 'Option 1' } })
|
|
|
|
await wrapper.setProps({ modelValue: 'Option 2' })
|
|
|
|
const emissions = wrapper.emitted('update:searchTerm')
|
|
expect(emissions).toBeTruthy()
|
|
expect(emissions![emissions!.length - 1]).toEqual(['Option 2'])
|
|
})
|
|
})
|
|
|
|
describe('create-item', () => {
|
|
// With `create-item`, the create item is always registered so reka-ui's collection
|
|
// never goes from empty to non-empty, leaving the highlight stale when async items load.
|
|
test('re-highlights first item when items change while open', async () => {
|
|
const wrapper = mount(InputMenu, {
|
|
attachTo: document.body,
|
|
props: {
|
|
open: true,
|
|
portal: false,
|
|
ignoreFilter: true,
|
|
createItem: 'always',
|
|
multiple: true,
|
|
items: []
|
|
}
|
|
})
|
|
|
|
const root = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
// Track open state (the watcher only re-highlights while the menu is open)
|
|
await root.vm.$emit('update:open', true)
|
|
await flushPromises()
|
|
|
|
// `searchTerm` is reset on mount, so set it afterwards to render the create item.
|
|
// It becomes the only (highlighted) item, mirroring the autocomplete bug.
|
|
await wrapper.setProps({ searchTerm: 'a' })
|
|
await flushPromises()
|
|
|
|
// Items arrive asynchronously (e.g. fetched from a backend)
|
|
await wrapper.setProps({ items: ['Option 1', 'Option 2'] })
|
|
await flushPromises()
|
|
|
|
const highlighted = wrapper.find('[role="option"][data-highlighted]')
|
|
expect(highlighted.exists()).toBe(true)
|
|
expect(highlighted.text()).toContain('Option 1')
|
|
|
|
wrapper.unmount()
|
|
})
|
|
})
|
|
|
|
describe('it should display correct label', () => {
|
|
test.each([null, undefined, ''])('falsy model value %s should display placeholder', (modelValue) => {
|
|
const wrapper = mount(InputMenu, {
|
|
props: {
|
|
items,
|
|
modelValue,
|
|
placeholder: 'Select an item'
|
|
}
|
|
})
|
|
|
|
expect(wrapper.find('input').attributes('placeholder')).toBe('Select an item')
|
|
})
|
|
|
|
test('with string array and string value', () => {
|
|
const wrapper = mount(InputMenu, {
|
|
props: {
|
|
items: ['Apple', 'Banana', 'Cherry'],
|
|
modelValue: 'Banana'
|
|
}
|
|
})
|
|
|
|
expect(wrapper.find('input').element.value).toBe('Banana')
|
|
})
|
|
|
|
test('with multiple and empty array value should display placeholder', () => {
|
|
const wrapper = mount(InputMenu, {
|
|
props: {
|
|
items,
|
|
multiple: true,
|
|
modelValue: [],
|
|
placeholder: 'Select items'
|
|
}
|
|
})
|
|
expect(wrapper.find('input').attributes('placeholder')).toBe('Select items')
|
|
})
|
|
|
|
test('with falsy modelValue and options items contain falsy', async () => {
|
|
const wrapper = mount(InputMenu, {
|
|
props: {
|
|
items: [
|
|
{
|
|
label: 'John Doe',
|
|
value: null
|
|
},
|
|
{
|
|
label: 'John Lennon',
|
|
value: 1
|
|
}
|
|
],
|
|
valueKey: 'value',
|
|
modelValue: null
|
|
}
|
|
})
|
|
expect(wrapper.find('input').element.value).toBe('John Doe')
|
|
})
|
|
})
|
|
|
|
describe('form integration', async () => {
|
|
async function createForm(validateOn?: FormInputEvents[]) {
|
|
const wrapper = await renderForm({
|
|
props: {
|
|
validateOn,
|
|
validateOnInputDelay: 0,
|
|
async validate(state: any) {
|
|
if (state.value !== 'Option 2')
|
|
return [{ name: 'value', message: 'Error message' }]
|
|
return []
|
|
}
|
|
},
|
|
slotVars: {
|
|
items: ['Option 1', 'Option 2']
|
|
},
|
|
slotTemplate: `
|
|
<UFormField name="value">
|
|
<UInputMenu id="input" v-model="state.value" :items="items" />
|
|
</UFormField>
|
|
`
|
|
})
|
|
const input = wrapper.findComponent({ name: 'ComboboxRoot' })
|
|
return {
|
|
wrapper,
|
|
input
|
|
}
|
|
}
|
|
|
|
test('validate on blur works', async () => {
|
|
const { wrapper, input } = await createForm(['blur'])
|
|
await input.vm.$emit('update:open', false)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Error message')
|
|
|
|
await input.setValue('Option 2')
|
|
await input.vm.$emit('update:open', false)
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).not.toContain('Error message')
|
|
})
|
|
|
|
test('validate on change works', async () => {
|
|
const { input, wrapper } = await createForm(['change'])
|
|
|
|
input.setValue('Option 1')
|
|
await flushPromises()
|
|
expect(wrapper.text()).toContain('Error message')
|
|
|
|
input.setValue('Option 2')
|
|
await flushPromises()
|
|
expect(wrapper.text()).not.toContain('Error message')
|
|
})
|
|
|
|
test('should have the correct types', () => {
|
|
// with object item
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [{ label: 'foo', value: 'bar' }]
|
|
})).toEqualTypeOf<[{ label: string, value: string }]>()
|
|
|
|
// with object item and multiple
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [{ label: 'foo', value: 1 }],
|
|
multiple: true
|
|
})).toEqualTypeOf<[{ label: string, value: number }[]]>()
|
|
|
|
// with object item and valueKey
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [{ label: 'foo', value: 'bar' }],
|
|
valueKey: 'value'
|
|
})).toEqualTypeOf<[string]>()
|
|
|
|
// with object item and multiple and valueKey
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [{ label: 'foo', value: 1 }],
|
|
multiple: true,
|
|
valueKey: 'value'
|
|
})).toEqualTypeOf<[number[]]>()
|
|
|
|
// with object item and object valueKey
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [{ label: 'foo', value: { id: 1, name: 'bar' } }],
|
|
valueKey: 'value'
|
|
})).toEqualTypeOf<[{ id: number, name: string }]>()
|
|
|
|
// with string item
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: ['foo']
|
|
})).toEqualTypeOf<[string]>()
|
|
|
|
// with string item and multiple
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: ['foo'],
|
|
multiple: true
|
|
})).toEqualTypeOf<[string[]]>()
|
|
|
|
// with groups
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [['foo']]
|
|
})).toEqualTypeOf<[string]>()
|
|
|
|
// with groups and multiple
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [['foo']],
|
|
multiple: true
|
|
})).toEqualTypeOf<[string[]]>()
|
|
|
|
// with groups, multiple and mixed types
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
|
|
multiple: true
|
|
})).toEqualTypeOf<[(string | number | { value: string } | { value: number })[]]>()
|
|
|
|
// with groups, multiple, mixed types and valueKey
|
|
expectEmitPayloadType('update:modelValue', () => InputMenu({
|
|
items: [['foo', { value: 1 }], [{ value: 'bar' }, 2]],
|
|
multiple: true,
|
|
valueKey: 'value'
|
|
})).toEqualTypeOf<[(string | number)[]]>()
|
|
})
|
|
})
|
|
})
|