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(InputMenu/SelectMenu): add create-item prop #2472

Merged
merged 31 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
546ad74
chore(SelectMenu): reimplement creatable
ineshbose Oct 28, 2024
e6afb05
chore: up
ineshbose Oct 30, 2024
9f00884
chore: up
ineshbose Oct 30, 2024
d7b7312
chore: up
ineshbose Oct 30, 2024
87a885a
Merge branch 'v3' into v3
ineshbose Oct 30, 2024
c73c372
chore: up
ineshbose Oct 30, 2024
17a9e8c
chore: up
ineshbose Oct 30, 2024
ca35a45
chore(InputMenu): add creatable
ineshbose Oct 30, 2024
a683fc9
Merge branch 'v3' into v3
ineshbose Nov 3, 2024
e96f600
Merge branch 'v3' into v3
ineshbose Nov 5, 2024
79f26df
chore: up
ineshbose Nov 5, 2024
2161726
chore: rename to create-item
ineshbose Nov 5, 2024
e79c590
Merge branch 'v3' into v3
benjamincanac Nov 5, 2024
95470e0
chore: fix docs
ineshbose Nov 5, 2024
c6506a9
chore: avoid unnecessary updates, clones #2507
ineshbose Nov 5, 2024
054e96f
chore: add check if existing modelValue is custom
ineshbose Nov 6, 2024
2cd820b
Merge branch 'v3' into v3
ineshbose Nov 6, 2024
974619d
chore: test
ineshbose Nov 6, 2024
5e98fd7
Merge branch 'v3' into v3
benjamincanac Nov 6, 2024
1fe12e5
use `Create` instead of `Add`
benjamincanac Nov 6, 2024
80081d7
Update InputMenu.vue
ineshbose Nov 6, 2024
4be655a
Update SelectMenu.vue
ineshbose Nov 6, 2024
f304631
Merge branch 'v3' into v3
ineshbose Nov 11, 2024
981cb0f
chore: up
ineshbose Nov 11, 2024
4c21931
chore: up
ineshbose Nov 11, 2024
84ba443
Merge branch 'v3' into v3
benjamincanac Nov 12, 2024
b074743
Merge branch 'v3' into v3
benjamincanac Nov 12, 2024
4a26b0a
docs: improve callouts
benjamincanac Nov 12, 2024
a004ccc
handle create locale
benjamincanac Nov 12, 2024
38b962c
add item in `@create`
benjamincanac Nov 12, 2024
5b91759
up
benjamincanac Nov 12, 2024
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
36 changes: 36 additions & 0 deletions docs/content/3.components/input-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,42 @@ props:
---
::

### Create Item

Use the `create-item` prop to allow user input.

::component-code
---
prettier: true
ignore:
- modelValue
- items
external:
- items
- modelValue
items:
createItem:
- true
- 'always'
props:
modelValue: 'Backlog'
items:
- Backlog
- Todo
- In Progress
- Done
createItem: true
---
::

::note
The create option shows when no match is found by default. Set it to `always` to show it even when similar values exist.
::

::tip{to="#emits"}
Use the `@create` event to handle the creation of the item. You will receive the event and the item as arguments.
::

### Content

Use the `content` prop to control how the InputMenu content is rendered, like its `align` or `side` for example.
Expand Down
42 changes: 42 additions & 0 deletions docs/content/3.components/select-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,48 @@ props:
---
::

::tip
You can set the `search-input` prop to `false` to hide the search input.
::

### Create Item

Use the `create-item` prop to allow user input.

::component-code
---
prettier: true
ignore:
- modelValue
- items
- class
external:
- items
- modelValue
items:
createItem:
- true
- 'always'
props:
modelValue: 'Backlog'
createItem: true
items:
- Backlog
- Todo
- In Progress
- Done
class: 'w-48'
---
::

::note
The create option shows when no match is found by default. Set it to `always` to show it even when similar values exist.
::

::tip{to="#emits"}
Use the `@create` event to handle the creation of the item. You will receive the event and the item as arguments.
::

### Content

Use the `content` prop to control how the SelectMenu content is rendered, like its `align` or `side` for example.
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Alert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { tv, type VariantProps } from 'tailwind-variants'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/alert'
import { useLocale } from '../composables/useLocale'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { AvatarProps, ButtonProps } from '../types'

Expand Down Expand Up @@ -66,6 +65,7 @@ extendDevtoolsMeta<AlertProps>({ defaultProps: { title: 'Heads up!' } })
import { computed } from 'vue'
import { Primitive } from 'radix-vue'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
import UButton from './Button.vue'
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Carousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import type { FadeOptionsType } from 'embla-carousel-fade'
import type { WheelGesturesPluginOptions } from 'embla-carousel-wheel-gestures'
import _appConfig from '#build/app.config'
import theme from '#build/ui/carousel'
import { useLocale } from '../composables/useLocale'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { ButtonProps } from '../types'
import type { AcceptableValue, PartialString } from '../types/utils'
Expand Down Expand Up @@ -101,6 +100,7 @@ import useEmblaCarousel from 'embla-carousel-vue'
import { useForwardProps } from 'radix-vue'
import { reactivePick, computedAsync } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import UButton from './Button.vue'

const props = withDefaults(defineProps<CarouselProps<T>>(), {
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/components/CommandPalette.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import _appConfig from '#build/app.config'
import theme from '#build/ui/command-palette'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { useLocale } from '../composables/useLocale'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { AvatarProps, ButtonProps, ChipProps, KbdProps, InputProps } from '../types'
import type { DynamicSlots, PartialString } from '../types/utils'
Expand Down Expand Up @@ -36,7 +35,7 @@ export interface CommandPaletteGroup<T> {
slot?: string
items?: T[]
/**
* Wether to filter group items with [useFuse](https://vueuse.org/integrations/useFuse).
* Whether to filter group items with [useFuse](https://vueuse.org/integrations/useFuse).
* When `false`, items will not be filtered which is useful for custom filtering (useAsyncData, useFetch, etc.).
* @defaultValue true
*/
Expand Down Expand Up @@ -125,6 +124,7 @@ import { defu } from 'defu'
import { reactivePick } from '@vueuse/core'
import { useFuse } from '@vueuse/integrations/useFuse'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import { omit, get } from '../utils'
import { highlight } from '../utils/fuse'
import UIcon from './Icon.vue'
Expand Down
86 changes: 76 additions & 10 deletions src/runtime/components/InputMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import _appConfig from '#build/app.config'
import theme from '#build/ui/input-menu'
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
import { useLocale } from '../composables/useLocale'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { AvatarProps, ChipProps, InputProps } from '../types'
import type { AcceptableValue, ArrayOrWrapped, PartialString, MaybeArrayOfArray, MaybeArrayOfArrayItem, SelectModelValue, SelectModelValueEmits, SelectItemKey } from '../types/utils'
Expand Down Expand Up @@ -98,6 +97,11 @@
items?: I
/** Highlight the ring color like a focus state. */
highlight?: boolean
/**
* Determines if custom user input that does not exist in options can be added.
* @defaultValue false
*/
createItem?: boolean | 'always' | { placement?: 'top' | 'bottom', when?: 'empty' | 'always' }
class?: any
ui?: PartialString<typeof inputMenu.slots>
/** The controlled value of the Combobox. Can be binded-with with `v-model`. */
Expand All @@ -110,6 +114,7 @@
change: [payload: Event]
blur: [payload: FocusEvent]
focus: [payload: FocusEvent]
create: [payload: Event, item: T]
} & SelectModelValueEmits<T, V, M>

type SlotProps<T> = (props: { item: T, index: number }) => any
Expand All @@ -124,21 +129,23 @@
'item-trailing': SlotProps<T>
'tags-item-text': SlotProps<T>
'tags-item-delete': SlotProps<T>
'create-item-label'(props: { item: T }): any
}

extendDevtoolsMeta({ defaultProps: { items: ['Option 1', 'Option 2', 'Option 3'] } })
</script>

<script setup lang="ts" generic="T extends MaybeArrayOfArrayItem<I>, I extends MaybeArrayOfArray<InputMenuItem | AcceptableValue> = MaybeArrayOfArray<InputMenuItem | AcceptableValue>, V extends SelectItemKey<T> | undefined = undefined, M extends boolean = false">
import { computed, ref, toRef, onMounted } from 'vue'
import { computed, ref, toRef, onMounted, toRaw } from 'vue'
import { ComboboxRoot, ComboboxArrow, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxPortal, ComboboxContent, ComboboxViewport, ComboboxEmpty, ComboboxGroup, ComboboxLabel, ComboboxSeparator, ComboboxItem, ComboboxItemIndicator, TagsInputRoot, TagsInputItem, TagsInputItemText, TagsInputItemDelete, TagsInputInput, useForwardPropsEmits } from 'radix-vue'
import { defu } from 'defu'
import { isEqual } from 'ohash'
import { reactivePick } from '@vueuse/core'
import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useButtonGroup } from '../composables/useButtonGroup'
import { useComponentIcons } from '../composables/useComponentIcons'
import { useFormField } from '../composables/useFormField'
import { useLocale } from '../composables/useLocale'
import { get, escapeRegExp } from '../utils'
import UIcon from './Icon.vue'
import UAvatar from './Avatar.vue'
Expand Down Expand Up @@ -170,6 +177,8 @@
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))

const [DefineCreateItemTemplate, ReuseCreateItemTemplate] = createReusableTemplate()

const inputSize = computed(() => buttonGroupSize.value || formGroupSize.value)

const ui = computed(() => inputMenu({
Expand All @@ -194,23 +203,27 @@
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
}

function filterFunction(items: ArrayOrWrapped<T>, searchTerm: string): ArrayOrWrapped<T> {
function filterFunction(
inputItems: ArrayOrWrapped<T> = items.value as ArrayOrWrapped<T>,
filterSearchTerm: string = searchTerm.value,
comparator = (item: any, term: string) => String(item).search(new RegExp(term, 'i')) !== -1
): ArrayOrWrapped<T> {
if (props.filter === false) {
return items
return inputItems
}

const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
const escapedSearchTerm = escapeRegExp(searchTerm)
const escapedSearchTerm = escapeRegExp(filterSearchTerm ?? '')

return items.filter((item) => {
return inputItems.filter((item) => {
if (typeof item !== 'object') {
return String(item).search(new RegExp(escapedSearchTerm, 'i')) !== -1
return comparator(item, escapedSearchTerm)
}

return fields.some((field) => {
const child = get(item, field as string)

return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
return child !== null && child !== undefined && comparator(child, escapedSearchTerm)
})
}) as ArrayOrWrapped<T>
}
Expand All @@ -219,6 +232,36 @@
// eslint-disable-next-line vue/no-dupe-keys
const items = computed(() => groups.value.flatMap(group => group) as T[])

const creatable = computed(() => {
if (!props.createItem) {
return false
}

const isModelValueCustom = props.modelValue && filterFunction((props.multiple && Array.isArray(props.modelValue) ? props.modelValue : [props.modelValue]) as ArrayOrWrapped<T>, searchTerm.value, (item, term) => String(item) === term).length === 1

if (isModelValueCustom) {
return false
}

const filteredItems = filterFunction()
const newItem = searchTerm.value && {
item: props.valueKey ? { [props.valueKey]: searchTerm.value, [props.labelKey ?? 'label']: searchTerm.value } : searchTerm.value,
position: ((typeof props.createItem === 'object' && props.createItem.placement) || 'bottom') as 'top' | 'bottom'
}

if ((typeof props.createItem === 'object' && props.createItem.when === 'always') || props.createItem === 'always') {
return (filteredItems.length === 1 && filterFunction(filteredItems, searchTerm.value, (item, term) => String(item) === term).length === 1) ? false : newItem
}

return filteredItems.length > 0 ? false : newItem
})

const rootItems = computed(() => [
...(creatable.value && creatable.value.position === 'top' ? [creatable.value.item] : []),
...filterFunction(),
...(creatable.value && creatable.value.position === 'bottom' ? [creatable.value.item] : [])
] as ArrayOrWrapped<T>)

const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)

function autoFocus() {
Expand All @@ -234,6 +277,9 @@
})

function onUpdate(value: any) {
if (toRaw(props.modelValue) === value) {
return
}
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
Expand Down Expand Up @@ -267,16 +313,32 @@
</script>

<template>
<DefineCreateItemTemplate>
<ComboboxGroup v-if="creatable" :class="ui.group({ class: props.ui?.group })">
<ComboboxItem
:class="ui.item({ class: props.ui?.item })"
:value="valueKey && typeof creatable.item === 'object' ? get(creatable.item, props.valueKey as string) : creatable.item"
@select="e => emits('create', e, (creatable as any).item as T)"
>
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
<slot name="create-item-label" :item="(creatable.item as T)">
{{ t('ui.inputMenu.create', { label: typeof creatable.item === 'object' ? get(creatable.item, props.labelKey as string) : creatable.item }) }}
</slot>
</span>
</ComboboxItem>
</ComboboxGroup>
</DefineCreateItemTemplate>

<ComboboxRoot
:id="id"
v-slot="{ modelValue, open }"

Check warning on line 334 in src/runtime/components/InputMenu.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 20)

Variable 'modelValue' is already declared in the upper scope
v-bind="rootProps"
v-model:search-term="searchTerm"
:name="name"
:disabled="disabled"
:multiple="multiple"
:display-value="displayValue"
:filter-function="filterFunction"
:filter-function="() => rootItems"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:as-child="!!multiple"
@update:model-value="onUpdate"
Expand Down Expand Up @@ -355,6 +417,8 @@
</ComboboxEmpty>

<ComboboxViewport :class="ui.viewport({ class: props.ui?.viewport })">
<ReuseCreateItemTemplate v-if="creatable && creatable.position === 'top'" />

<ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
Expand Down Expand Up @@ -401,6 +465,8 @@
</ComboboxItem>
</template>
</ComboboxGroup>

<ReuseCreateItemTemplate v-if="creatable && creatable.position === 'bottom'" />
</ComboboxViewport>

<ComboboxArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/components/Modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { DialogRootProps, DialogRootEmits, DialogContentProps } from 'radix
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/modal'
import { useLocale } from '../composables/useLocale'
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
import type { ButtonProps } from '../types'

Expand Down Expand Up @@ -78,6 +77,7 @@ import { computed, toRef } from 'vue'
import { DialogRoot, DialogTrigger, DialogPortal, DialogOverlay, DialogContent, DialogTitle, DialogDescription, DialogClose, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import UButton from './Button.vue'

const props = withDefaults(defineProps<ModalProps>(), {
Expand Down
Loading
Loading