Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(SelectMenu): allow creating option despite search #1080

Merged
merged 12 commits into from
Dec 15, 2023
89 changes: 54 additions & 35 deletions docs/components/content/examples/SelectMenuExampleCreatable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,43 +58,62 @@ function generateColorFromString (str) {
</script>

<template>
<USelectMenu
v-model="labels"
by="id"
name="labels"
:options="options"
option-attribute="name"
multiple
searchable
creatable
>
<template #label>
<template v-if="labels.length">
<span class="flex items-center -space-x-1">
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
</span>
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
<div>
<USelectMenu
v-model="labels"
by="id"
name="labels"
:options="options"
option-attribute="name"
multiple
searchable
creatable
>
<template #label>
<template v-if="labels.length">
<span class="flex items-center -space-x-1">
<span v-for="label of labels" :key="label.id" class="flex-shrink-0 w-2 h-2 mt-px rounded-full" :style="{ background: `#${label.color}` }" />
</span>
<span>{{ labels.length }} label{{ labels.length > 1 ? 's' : '' }}</span>
</template>
<template v-else>
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
</template>
</template>
<template v-else>
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
<template #option="{ option }">
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
:style="{ background: `#${option.color}` }"
/>
<span class="truncate">{{ option.name }}</span>
</template>
</template>
<template #option-create="{ option }">
<span class="flex-shrink-0">New label:</span>
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
:style="{ background: `#${generateColorFromString(option.name)}` }"
/>
<span class="block truncate">{{ option.name }}</span>
</template>
</USelectMenu>

<template #option="{ option }">
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full"
:style="{ background: `#${option.color}` }"
/>
<span class="truncate">{{ option.name }}</span>
</template>

<template #option-create="{ option }">
<span class="flex-shrink-0">New label:</span>
<span
class="flex-shrink-0 w-2 h-2 mt-px rounded-full -mx-1"
:style="{ background: `#${generateColorFromString(option.name)}` }"
/>
<span class="block truncate">{{ option.name }}</span>
</template>
</USelectMenu>
<USelectMenu
v-model="labels"
by="id"
name="labels"
:options="options"
option-attribute="name"
multiple
searchable
creatable="always"
class="mt-2"
>
<template #label>
<template v-if="!labels.length">
<span class="text-gray-500 dark:text-gray-400 truncate">Select labels</span>
</template>
</template>
</USelectMenu>
</div>
</template>
6 changes: 4 additions & 2 deletions docs/content/3.forms/4.select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ componentProps:

### Create option

Use the `creatable` prop to enable the creation of new options when the search doesn't return any results (only works with `searchable`).
Use the `creatable` prop to enable the creation of new options when the search doesn't return any results (only works with `searchable`). However, if you want to create options despite search query (apart from exact match), you can set `creatable` as `'always'`.
ineshbose marked this conversation as resolved.
Show resolved Hide resolved

Try to search for something that doesn't exist in the example below.
Try to search for something that doesn't exist in the first input below.

Try to search for something that exists in the second input below, but not an exact match.

::component-example
---
Expand Down
81 changes: 59 additions & 22 deletions src/runtime/components/forms/SelectMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,7 @@
</li>
</component>

<component :is="searchable ? 'HComboboxOption' : 'HListboxOption'" v-if="creatable && queryOption && !filteredOptions.length" v-slot="{ active, selected }" :value="queryOption" as="template">
<li :class="[uiMenu.option.base, uiMenu.option.rounded, uiMenu.option.padding, uiMenu.option.size, uiMenu.option.color, active ? uiMenu.option.active : uiMenu.option.inactive]">
<div :class="uiMenu.option.container">
<slot name="option-create" :option="queryOption" :active="active" :selected="selected">
<span :class="uiMenu.option.create">Create "{{ queryOption[optionAttribute] }}"</span>
</slot>
</div>
</li>
</component>
<component :is="createOptionVNode" v-if="showCreateOption" :value="queryOption" />
<p v-else-if="searchable && query && !filteredOptions.length" :class="uiMenu.option.empty">
<slot name="option-empty" :query="query">
No results for "{{ query }}".
Expand All @@ -120,7 +112,7 @@
</template>

<script lang="ts">
import { ref, computed, toRef, watch, defineComponent } from 'vue'
import { ref, computed, toRef, watch, defineComponent, h } from 'vue'
import type { PropType, ComponentPublicInstance } from 'vue'
import {
Combobox as HCombobox,
Expand Down Expand Up @@ -249,7 +241,7 @@ export default defineComponent({
default: 200
},
creatable: {
type: Boolean,
type: [Boolean, String] as PropType<boolean | 'always'>,
default: false
},
placeholder: {
Expand Down Expand Up @@ -411,32 +403,75 @@ export default defineComponent({

const debouncedSearch = typeof props.searchable === 'function' ? useDebounceFn(props.searchable, props.debounce) : undefined

const filteredOptions = computedAsync(async () => {
if (props.searchable && debouncedSearch) {
return await debouncedSearch(query.value)
}

if (query.value === '') {
return props.options
}
const matchOption = (searchFrom: any, searchFor: string, method: 'search' | 'exact') =>
method === 'exact' ? String(searchFrom) === searchFor : String(searchFrom).search(new RegExp(searchFor, 'i')) !== -1

return (props.options as any[]).filter((option: any) => {
const _search = (searchFrom: any[], searchFor: string, method: 'search' | 'exact' = 'search') =>
(searchFrom).filter((option: any) => {
return (props.searchAttributes?.length ? props.searchAttributes : [props.optionAttribute]).some((searchAttribute: any) => {
if (['string', 'number'].includes(typeof option)) {
return String(option).search(new RegExp(query.value, 'i')) !== -1
return matchOption(option, searchFor, method)
}

const child = get(option, searchAttribute)

return child !== null && child !== undefined && String(child).search(new RegExp(query.value, 'i')) !== -1
return child !== null && child !== undefined && matchOption(child, searchFor, method)
})
})

const filteredOptions = computedAsync(async () => {
if (props.searchable && debouncedSearch) {
return await debouncedSearch(query.value)
}

if (query.value === '') {
return props.options
}

return _search(props.options, query.value)
})

const queryOption = computed(() => {
return query.value === '' ? null : { [props.optionAttribute]: query.value }
})

const showCreateOption = computed(() =>
props.creatable
&& queryOption.value
&& (
props.creatable !== 'always'
? !filteredOptions.value.length
: !_search(filteredOptions.value, query.value, 'exact').length
)
)

const createOptionVNode = h(
props.searchable ? HComboboxOption : HListboxOption,
{ as: 'template' },
{
default: ({ active, selected }) => h(
'li',
{
class: [
uiMenu.value.option.base,
uiMenu.value.option.rounded,
uiMenu.value.option.padding,
uiMenu.value.option.size,
uiMenu.value.option.color,
active ? uiMenu.value.option.active : uiMenu.value.option.inactive
]
},
h(
'div',
{ class: uiMenu.value.option.container },
slots['option-create']
? slots['option-create']({ option: queryOption.value, active, selected })
: h('span', { class: uiMenu.value.option.create }, [`Create "${queryOption.value[props.optionAttribute]}"`])
)
)
}
)

function clearOnClose () {
if (props.clearSearchOnClose) {
query.value = ''
Expand Down Expand Up @@ -490,6 +525,8 @@ export default defineComponent({
trailingWrapperIconClass,
filteredOptions,
queryOption,
showCreateOption,
createOptionVNode,
query,
onUpdate
}
Expand Down