Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const value = ref('')
<template>
<B24InputMenu
v-model="value"
autocomplete
mode="autocomplete"
:items="items"
:content="{ hideWhenEmpty: true }"
placeholder="Type a status..."
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/2.components/input-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ props:

### Autocomplete

Use the `autocomplete` prop to turn the InputMenu into a free-form text input with suggestions. The `modelValue` becomes the input text (`string`) instead of a selected item.
Use the `mode` prop set to `autocomplete` to turn the InputMenu into a free-form text input with suggestions. The `modelValue` becomes the input text (`string`) instead of a selected item.

::component-example
---
Expand All @@ -269,7 +269,7 @@ name: 'input-menu-autocomplete-example'
::

::caution
When `autocomplete` is `true`, `multiple`, `by`, `resetSearchTermOnSelect` and `resetModelValueOnClear` are not applicable.
When `mode` is `autocomplete`, `multiple`, `by`, `resetSearchTermOnSelect` and `resetModelValueOnClear` are not applicable.
::

::tip
Expand Down
36 changes: 20 additions & 16 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type IsClearUsed<M extends boolean, C extends boolean | object> = M extends fals
? (C extends true ? null : C extends object ? null : never)
: never

export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false, Mod extends Omit<ModelModifiers, 'lazy'> = Omit<ModelModifiers, 'lazy'>, C extends boolean | object = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'resetSearchTermOnSelect' | 'resetModelValueOnClear' | 'highlightOnHover' | 'openOnClick' | 'openOnFocus' | 'by'>, UseComponentIconsProps, /** @vue-ignore */ Omit<InputHTMLAttributes, 'autocomplete' | 'disabled' | 'name' | 'type' | 'placeholder' | 'autofocus' | 'maxlength' | 'minlength' | 'pattern' | 'size' | 'min' | 'max' | 'step'> {
export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOrNested<InputMenuItem>, VK extends GetItemKeys<T> | undefined = undefined, M extends boolean = false, Mod extends Omit<ModelModifiers, 'lazy'> = Omit<ModelModifiers, 'lazy'>, C extends boolean | object = false> extends Pick<ComboboxRootProps<T>, 'open' | 'defaultOpen' | 'disabled' | 'name' | 'resetSearchTermOnBlur' | 'resetSearchTermOnSelect' | 'resetModelValueOnClear' | 'highlightOnHover' | 'openOnClick' | 'openOnFocus' | 'by'>, UseComponentIconsProps, /** @vue-ignore */ Omit<InputHTMLAttributes, 'disabled' | 'name' | 'type' | 'placeholder' | 'autofocus' | 'maxlength' | 'minlength' | 'pattern' | 'size' | 'min' | 'max' | 'step'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
Expand Down Expand Up @@ -173,11 +173,13 @@ export interface InputMenuProps<T extends ArrayOrNested<InputMenuItem> = ArrayOr
/** Keep the mobile text size on all breakpoints. (Left for backward compatibility.) */
fixed?: boolean
/**
* When `true`, the input accepts free-form text with optional suggestions.
* The `modelValue` becomes the input text (string) instead of a selected item.
* @defaultValue false
* The behavior of the InputMenu.
* - `combobox`: select one (or many) items from a list of suggestions.
* - `autocomplete`: free-form text input with optional suggestions. The `modelValue`
* becomes the input text (`string`) instead of a selected item.
* @defaultValue 'combobox'
*/
autocomplete?: boolean
mode?: 'combobox' | 'autocomplete'
/**
* Determines if custom user input that does not exist in options can be added.
* @defaultValue false
Expand Down Expand Up @@ -294,9 +296,11 @@ const { t } = useLocale()
const appConfig = useAppConfig() as InputMenu['AppConfig']
const { filterGroups } = useFilter()

const isAutocomplete = computed(() => props.mode === 'autocomplete')
const rootPropsPick = reactivePick(props, 'as', 'modelValue', 'defaultValue', 'open', 'defaultOpen', 'required', 'multiple', 'resetSearchTermOnBlur', 'resetSearchTermOnSelect', 'resetModelValueOnClear', 'highlightOnHover', 'openOnClick', 'openOnFocus', 'by')
const rootProps = useForwardProps(props.autocomplete ? reactiveOmit(rootPropsPick, 'multiple', 'resetSearchTermOnSelect', 'resetModelValueOnClear', 'by') : rootPropsPick, emits)
const Component = computed(() => props.autocomplete ? Autocomplete : Combobox)
const rootPropsOmitted = reactiveOmit(rootPropsPick, 'multiple', 'resetSearchTermOnSelect', 'resetModelValueOnClear', 'by')
const rootProps = useForwardProps(computed(() => isAutocomplete.value ? rootPropsOmitted : rootPropsPick), emits)
const Component = computed(() => isAutocomplete.value ? Autocomplete : Combobox)
const portalProps = usePortal(toRef(() => props.portal))
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
const arrowProps = toRef(() => defu(typeof props.arrow === 'boolean' ? {} : props.arrow, { width: 20, height: 10 }) as ComboboxArrowProps)
Expand Down Expand Up @@ -409,7 +413,7 @@ function autoFocus() {

onMounted(() => {
nextTick(() => {
if (props.autocomplete) {
if (isAutocomplete.value) {
searchTerm.value = String(props.modelValue ?? props.defaultValue ?? '')
} else {
searchTerm.value = ''
Expand All @@ -422,7 +426,7 @@ onMounted(() => {
})

watch(() => props.modelValue, (newValue) => {
if (props.autocomplete) {
if (isAutocomplete.value) {
searchTerm.value = String(newValue ?? '')
}
})
Expand Down Expand Up @@ -454,15 +458,15 @@ function onUpdate(value: any) {
emitFormChange()
emitFormInput()

if (props.autocomplete) {
if (isAutocomplete.value) {
searchTerm.value = String(value ?? '')
} else if (props.resetSearchTermOnSelect) {
searchTerm.value = ''
}
}

function onInputUpdate(value: string) {
if (!props.autocomplete) {
if (!isAutocomplete.value) {
searchTerm.value = value
}
}
Expand All @@ -488,7 +492,7 @@ function onUpdateOpen(value: boolean) {

// Since we use `displayValue` prop inside ComboboxInput we should reset searchTerm manually
// https://reka-ui.com/docs/components/combobox#api-reference
if (!props.autocomplete && props.resetSearchTermOnBlur) {
if (!isAutocomplete.value && props.resetSearchTermOnBlur) {
const STATE_ANIMATION_DELAY_MS = 100

timeoutId = setTimeout(() => {
Expand Down Expand Up @@ -620,7 +624,7 @@ defineExpose({
</span>

<span data-slot="itemTrailing" :class="b24ui.itemTrailing({ class: [props.b24ui?.itemTrailing, isInputItem(item) && item.b24ui?.itemTrailing], colorItem: isInputItem(item) ? item?.color : undefined })">
<Component.ItemIndicator v-if="!props.autocomplete" as-child>
<Component.ItemIndicator v-if="!isAutocomplete" as-child>
<Component
:is="props.selectedIcon || icons.check"
data-slot="itemTrailingIcon"
Expand Down Expand Up @@ -657,7 +661,7 @@ defineExpose({
:disabled="disabled"
data-slot="root"
:class="b24ui.root({ class: [props.b24ui?.root, props.class] })"
:as-child="!!props.multiple && !props.autocomplete"
:as-child="!!props.multiple && !isAutocomplete"
ignore-filter
@update:model-value="onUpdate"
@update:open="onUpdateOpen"
Expand All @@ -672,7 +676,7 @@ defineExpose({
/>
<Component.Anchor :as-child="!props.multiple" data-slot="base" :class="b24ui.base({ class: props.b24ui?.base })">
<TagsInputRoot
v-if="props.multiple && !props.autocomplete"
v-if="props.multiple && !isAutocomplete"
v-slot="{ modelValue: tags }"
:model-value="(modelValue as string[])"
:disabled="disabled"
Expand Down Expand Up @@ -726,7 +730,7 @@ defineExpose({
v-else
:id="id"
ref="inputRef"
v-bind="{ ...(!props.autocomplete ? { displayValue } : {}), ...$attrs, ...ariaAttrs }"
v-bind="{ ...(!isAutocomplete ? { displayValue } : {}), ...$attrs, ...ariaAttrs }"
:type="props.type"
:placeholder="props.placeholder"
:required="props.required"
Expand Down
21 changes: 13 additions & 8 deletions test/components/InputMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ describe('InputMenu', () => {
['with disabled', { props: { ...props, disabled: true } }],
['with required', { props: { ...props, required: true } }],
// Autocomplete
['with autocomplete', { props: { ...props, autocomplete: true } }],
['with autocomplete and modelValue', { props: { ...props, autocomplete: true, modelValue: 'Backlog' } }],
['with autocomplete and defaultValue', { props: { ...props, autocomplete: true, defaultValue: 'Backlog' } }],
['with autocomplete and placeholder', { props: { ...props, autocomplete: true, placeholder: 'Type something...' } }],
['with autocomplete and content', { props: { ...props, autocomplete: true, content: { hideWhenEmpty: true } } }],
['with autocomplete', { props: { ...props, mode: 'autocomplete' } }],
['with autocomplete and modelValue', { props: { ...props, mode: 'autocomplete', modelValue: 'Backlog' } }],
['with autocomplete and defaultValue', { props: { ...props, mode: 'autocomplete', defaultValue: 'Backlog' } }],
['with autocomplete and placeholder', { props: { ...props, mode: 'autocomplete', placeholder: 'Type something...' } }],
['with autocomplete and content', { props: { ...props, mode: 'autocomplete', content: { hideWhenEmpty: true } } }],
['with icon', { props: { icon: SignIcon } }],
['with leading and icon', { props: { leading: true, icon: SignIcon } }],
['with trailingIcon', { props: { trailingIcon: SignIcon } }],
Expand Down Expand Up @@ -162,28 +162,33 @@ describe('InputMenu', () => {
})

test('update:modelValue event with autocomplete', async () => {
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], autocomplete: true } })
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete' } })
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'], autocomplete: true } })
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete' } })
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'], autocomplete: true, modelValue: 'Option 1' } })
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'], mode: 'autocomplete', 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'])
})

test('forwards the native autocomplete attribute to the input', () => {
const wrapper = mount(InputMenu, { props: { items: ['Option 1', 'Option 2'] }, attrs: { autocomplete: 'email' } })
expect(wrapper.find('input').attributes('autocomplete')).toBe('email')
})
})

describe('it should display correct label', () => {
Expand Down