Skip to content

Commit 34ca811

Browse files
fix(InputMenu/Select/SelectMenu): add display value fallback when no items found (#4689)
Co-authored-by: Benjamin Canac <[email protected]>
1 parent 24a78fa commit 34ca811

File tree

9 files changed

+95
-37
lines changed

9 files changed

+95
-37
lines changed

src/runtime/components/InputMenu.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { AppConfig } from '@nuxt/schema'
55
import theme from '#build/ui/input-menu'
66
import type { UseComponentIconsProps } from '../composables/useComponentIcons'
77
import type { AvatarProps, ChipProps, InputProps } from '../types'
8-
import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps, ComponentConfig } from '../types/utils'
8+
import type { AcceptableValue, ArrayOrNested, GetItemKeys, GetItemValue, GetModelValue, GetModelValueEmits, NestedItem, EmitsToProps, ComponentConfig } from '../types/utils'
99
1010
type InputMenu = ComponentConfig<typeof theme, AppConfig, 'inputMenu'>
1111
@@ -183,7 +183,7 @@ import { useComponentIcons } from '../composables/useComponentIcons'
183183
import { useFormField } from '../composables/useFormField'
184184
import { useLocale } from '../composables/useLocale'
185185
import { usePortal } from '../composables/usePortal'
186-
import { compare, get, isArrayOfArray } from '../utils'
186+
import { compare, get, getDisplayValue, isArrayOfArray } from '../utils'
187187
import { tv } from '../utils/tv'
188188
import UIcon from './Icon.vue'
189189
import UAvatar from './Avatar.vue'
@@ -233,9 +233,11 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.inputMenu ||
233233
buttonGroup: orientation.value
234234
}))
235235
236-
function displayValue(value: T): string {
237-
const item = items.value.find(item => compare(typeof item === 'object' && props.valueKey ? get(item as Record<string, any>, props.valueKey as string) : item, value))
238-
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
236+
function displayValue(value: GetItemValue<T, VK>): string {
237+
return getDisplayValue(items.value, value, {
238+
labelKey: props.labelKey,
239+
valueKey: props.valueKey
240+
}) ?? ''
239241
}
240242
241243
const groups = computed<InputMenuItem[][]>(() =>
@@ -246,7 +248,7 @@ const groups = computed<InputMenuItem[][]>(() =>
246248
: []
247249
)
248250
// eslint-disable-next-line vue/no-dupe-keys
249-
const items = computed(() => groups.value.flatMap(group => group))
251+
const items = computed(() => groups.value.flatMap(group => group) as T[])
250252
251253
const filteredGroups = computed(() => {
252254
if (props.ignoreFilter || !searchTerm.value) {
@@ -441,7 +443,7 @@ defineExpose({
441443
<TagsInputItem v-for="(item, index) in tags" :key="index" :value="item" :class="ui.tagsItem({ class: [props.ui?.tagsItem, isInputItem(item) && item.ui?.tagsItem] })">
442444
<TagsInputItemText :class="ui.tagsItemText({ class: [props.ui?.tagsItemText, isInputItem(item) && item.ui?.tagsItemText] })">
443445
<slot name="tags-item-text" :item="(item as NestedItem<T>)" :index="index">
444-
{{ displayValue(item as T) ?? item }}
446+
{{ displayValue(item as GetItemValue<T, VK>) }}
445447
</slot>
446448
</TagsInputItemText>
447449

src/runtime/components/Select.vue

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ import { useButtonGroup } from '../composables/useButtonGroup'
145145
import { useComponentIcons } from '../composables/useComponentIcons'
146146
import { useFormField } from '../composables/useFormField'
147147
import { usePortal } from '../composables/usePortal'
148-
import { compare, get, isArrayOfArray } from '../utils'
148+
import { get, getDisplayValue, isArrayOfArray } from '../utils'
149149
import { tv } from '../utils/tv'
150150
import UIcon from './Icon.vue'
151151
import UAvatar from './Avatar.vue'
@@ -198,12 +198,20 @@ const items = computed(() => groups.value.flatMap(group => group) as T[])
198198
199199
function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string | undefined {
200200
if (props.multiple && Array.isArray(value)) {
201-
const values = value.map(v => displayValue(v)).filter(Boolean)
202-
return values?.length ? values.join(', ') : undefined
201+
const displayedValues = value
202+
.map(item => getDisplayValue(items.value, item, {
203+
labelKey: props.labelKey,
204+
valueKey: props.valueKey
205+
}))
206+
.filter((v): v is string => v != null && v !== '')
207+
208+
return displayedValues.length > 0 ? displayedValues.join(', ') : undefined
203209
}
204210
205-
const item = items.value.find(item => compare(typeof item === 'object' ? get(item as Record<string, any>, props.valueKey as string) : item, value))
206-
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
211+
return getDisplayValue(items.value, value, {
212+
labelKey: props.labelKey,
213+
valueKey: props.valueKey
214+
})
207215
}
208216
209217
const triggerRef = ref<InstanceType<typeof SelectTrigger> | null>(null)

src/runtime/components/SelectMenu.vue

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ import { useComponentIcons } from '../composables/useComponentIcons'
177177
import { useFormField } from '../composables/useFormField'
178178
import { useLocale } from '../composables/useLocale'
179179
import { usePortal } from '../composables/usePortal'
180-
import { compare, get, isArrayOfArray } from '../utils'
180+
import { compare, get, getDisplayValue, isArrayOfArray } from '../utils'
181181
import { tv } from '../utils/tv'
182182
import UIcon from './Icon.vue'
183183
import UAvatar from './Avatar.vue'
@@ -230,12 +230,20 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.selectMenu |
230230
231231
function displayValue(value: GetItemValue<T, VK> | GetItemValue<T, VK>[]): string | undefined {
232232
if (props.multiple && Array.isArray(value)) {
233-
const values = value.map(v => displayValue(v)).filter(Boolean)
234-
return values?.length ? values.join(', ') : undefined
233+
const displayedValues = value
234+
.map(item => getDisplayValue(items.value, item, {
235+
labelKey: props.labelKey,
236+
valueKey: props.valueKey
237+
}))
238+
.filter((v): v is string => v != null && v !== '')
239+
240+
return displayedValues.length > 0 ? displayedValues.join(', ') : undefined
235241
}
236242
237-
const item = items.value.find(item => compare(typeof item === 'object' && props.valueKey ? get(item as Record<string, any>, props.valueKey as string) : item, value))
238-
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
243+
return getDisplayValue(items.value, value, {
244+
labelKey: props.labelKey,
245+
valueKey: props.valueKey
246+
})
239247
}
240248
241249
const groups = computed<SelectMenuItem[][]>(() =>

src/runtime/utils/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isEqual } from 'ohash/utils'
2+
import type { GetItemKeys, NestedItem } from '../types'
23

34
export function pick<Data extends object, Keys extends keyof Data>(data: Data, keys: Keys[]): Pick<Data, Keys> {
45
const result = {} as Pick<Data, Keys>
@@ -82,6 +83,40 @@ export function compare<T>(value?: T, currentValue?: T, comparator?: string | ((
8283
return isEqual(value, currentValue)
8384
}
8485

86+
export function getDisplayValue<T, V>(
87+
items: T[],
88+
value: V | undefined | null,
89+
options: {
90+
valueKey?: GetItemKeys<T>
91+
labelKey?: keyof NestedItem<T>
92+
} = {}
93+
): string | undefined {
94+
const { valueKey, labelKey } = options
95+
96+
if (value === null || value === undefined) {
97+
return undefined
98+
}
99+
100+
const foundItem = items.find((item) => {
101+
const itemValue = (typeof item === 'object' && item !== null && valueKey)
102+
? get(item, valueKey as string)
103+
: item
104+
return compare(itemValue, value)
105+
})
106+
107+
const source = foundItem ?? value
108+
109+
if (source === null || source === undefined) {
110+
return undefined
111+
}
112+
113+
if (typeof source === 'object') {
114+
return labelKey ? get(source as Record<string, any>, labelKey as string) : undefined
115+
}
116+
117+
return String(source)
118+
}
119+
85120
export function isArrayOfArray<A>(item: A[] | A[][]): item is A[][] {
86121
return Array.isArray(item[0])
87122
}

test/components/Select.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ describe('Select', () => {
3939
it.each([
4040
// Props
4141
['with items', { props }],
42-
['with modelValue', { props: { ...props, modelValue: items[0] } }],
43-
['with defaultValue', { props: { ...props, defaultValue: items[0] } }],
42+
['with modelValue', { props: { ...props, modelValue: items[0]?.value } }],
43+
['with defaultValue', { props: { ...props, defaultValue: items[0]?.value } }],
4444
['with valueKey', { props: { ...props, valueKey: 'label' } }],
4545
['with labelKey', { props: { ...props, labelKey: 'value' } }],
4646
['with multiple', { props: { ...props, multiple: true } }],

test/components/__snapshots__/InputMenu-vue.spec.ts.snap

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,13 @@ exports[`InputMenu > renders with create-item-label slot correctly 1`] = `
137137
<div role="presentation" class="relative divide-y divide-default scroll-py-1 overflow-y-auto flex-1">
138138
<!--v-if-->
139139
<div role="group" aria-labelledby="" id="reka-combobox-group-v-1" class="p-1 isolate">
140-
<div id="reka-combobox-item-v-3" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><span class="truncate">Create item slot</span></div>
140+
<div id="reka-combobox-item-v-3" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 text-dimmed group-data-highlighted:not-group-data-disabled:text-default transition-colors size-5"></svg><span class="truncate">Backlog</span><span class="ms-auto inline-flex gap-1.5 items-center"><!--v-if--></span></div>
141+
<div id="reka-combobox-item-v-5" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 text-dimmed group-data-highlighted:not-group-data-disabled:text-default transition-colors size-5"></svg><span class="truncate">Todo</span><span class="ms-auto inline-flex gap-1.5 items-center"><!--v-if--></span></div>
142+
<div id="reka-combobox-item-v-7" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 text-dimmed group-data-highlighted:not-group-data-disabled:text-default transition-colors size-5"></svg><span class="truncate">In Progress</span><span class="ms-auto inline-flex gap-1.5 items-center"><!--v-if--></span></div>
143+
<div id="reka-combobox-item-v-9" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 text-dimmed group-data-highlighted:not-group-data-disabled:text-default transition-colors size-5"></svg><span class="truncate">Done</span><span class="ms-auto inline-flex gap-1.5 items-center"><!--v-if--></span></div>
144+
<div id="reka-combobox-item-v-11" class="group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75 text-default data-highlighted:not-data-disabled:text-highlighted data-highlighted:not-data-disabled:before:bg-elevated/50 transition-colors before:transition-colors p-1.5 text-sm gap-1.5" data-reka-collection-item="" role="option" tabindex="-1" aria-selected="false" data-state="unchecked"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 16 16" class="shrink-0 text-dimmed group-data-highlighted:not-group-data-disabled:text-default transition-colors size-5"></svg><span class="truncate">Canceled</span><span class="ms-auto inline-flex gap-1.5 items-center"><!--v-if--></span></div>
141145
</div>
146+
<!--v-if-->
142147
</div>
143148
<!--v-if-->
144149
</div>

0 commit comments

Comments
 (0)