Skip to content

Commit f6f9823

Browse files
committed
feat(InputMenu/RadioGroup/Select/SelectMenu): handle labelKey and use get to support dot notation
1 parent 296ae45 commit f6f9823

File tree

12 files changed

+436
-21
lines changed

12 files changed

+436
-21
lines changed

src/runtime/components/InputMenu.vue

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export interface InputMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelValu
8686
* @defaultValue undefined
8787
*/
8888
valueKey?: keyof T
89+
/**
90+
* When `items` is an array of objects, select the field to use as the label.
91+
* @defaultValue 'label'
92+
*/
93+
labelKey?: keyof T
8994
items?: T[] | T[][]
9095
/** Highlight the ring color like a focus state. */
9196
highlight?: boolean
@@ -124,18 +129,19 @@ import { useAppConfig } from '#imports'
124129
import { useButtonGroup } from '../composables/useButtonGroup'
125130
import { useComponentIcons } from '../composables/useComponentIcons'
126131
import { useFormField } from '../composables/useFormField'
132+
import { get, escapeRegExp } from '../utils'
127133
import UIcon from './Icon.vue'
128134
import UAvatar from './Avatar.vue'
129135
import UChip from './Chip.vue'
130-
import { get, escapeRegExp } from '../utils'
131136
132137
defineOptions({ inheritAttrs: false })
133138
134139
const props = withDefaults(defineProps<InputMenuProps<T>>(), {
135140
type: 'text',
136141
autofocusDelay: 0,
137142
portal: true,
138-
filter: () => ['label']
143+
filter: () => ['label'],
144+
labelKey: 'label' as keyof T
139145
})
140146
const emits = defineEmits<InputMenuEmits<T>>()
141147
const slots = defineSlots<InputMenuSlots<T>>()
@@ -164,17 +170,17 @@ const ui = computed(() => inputMenu({
164170
}))
165171
166172
function displayValue(value: AcceptableValue): string {
167-
const item = items.value.find(item => props.valueKey ? isEqual(item[props.valueKey], value) : isEqual(item, value))
173+
const item = items.value.find(item => props.valueKey ? isEqual(get(item as Record<string, any>, props.valueKey as string), value) : isEqual(item, value))
168174
169-
return item && (typeof item === 'object' ? item.label : item)
175+
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
170176
}
171177
172178
function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
173179
if (props.filter === false) {
174180
return items
175181
}
176182
177-
const fields = Array.isArray(props.filter) ? props.filter : ['label']
183+
const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
178184
const escapedSearchTerm = escapeRegExp(searchTerm)
179185
180186
return items.filter((item) => {
@@ -183,7 +189,7 @@ function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: stri
183189
}
184190
185191
return fields.some((field) => {
186-
const child = get(item, field)
192+
const child = get(item, field as string)
187193
188194
return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
189195
})
@@ -325,7 +331,7 @@ defineExpose({
325331
<ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
326332
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
327333
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
328-
{{ item.label }}
334+
{{ get(item, props.labelKey as string) }}
329335
</ComboboxLabel>
330336

331337
<ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
@@ -334,7 +340,7 @@ defineExpose({
334340
v-else
335341
:class="ui.item({ class: props.ui?.item })"
336342
:disabled="item.disabled"
337-
:value="valueKey && typeof item === 'object' ? (item[valueKey as keyof InputMenuItem]) as AcceptableValue : item"
343+
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item"
338344
@select="item.select"
339345
>
340346
<slot name="item" :item="(item as T)" :index="index">
@@ -353,7 +359,7 @@ defineExpose({
353359

354360
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
355361
<slot name="item-label" :item="(item as T)" :index="index">
356-
{{ typeof item === 'object' ? item.label : item }}
362+
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
357363
</slot>
358364
</span>
359365

src/runtime/components/RadioGroup.vue

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ export interface RadioGroupProps<T> extends Pick<RadioGroupRootProps, 'defaultVa
2929
* @defaultValue 'value'
3030
*/
3131
valueKey?: string
32+
/**
33+
* When `items` is an array of objects, select the field to use as the label.
34+
* @defaultValue 'label'
35+
*/
36+
labelKey?: string
37+
/**
38+
* When `items` is an array of objects, select the field to use as the description.
39+
* @defaultValue 'description'
40+
*/
41+
descriptionKey?: string
3242
items?: T[]
3343
size?: RadioGroupVariants['size']
3444
color?: RadioGroupVariants['color']
@@ -59,9 +69,12 @@ import { computed, useId } from 'vue'
5969
import { RadioGroupRoot, RadioGroupItem, RadioGroupIndicator, Label, useForwardPropsEmits } from 'radix-vue'
6070
import { reactivePick } from '@vueuse/core'
6171
import { useFormField } from '../composables/useFormField'
72+
import { get } from '../utils'
6273
6374
const props = withDefaults(defineProps<RadioGroupProps<T>>(), {
6475
valueKey: 'value',
76+
labelKey: 'label',
77+
descriptionKey: 'description',
6578
orientation: 'vertical'
6679
})
6780
const emits = defineEmits<RadioGroupEmits>()
@@ -89,11 +102,15 @@ function normalizeItem(item: any) {
89102
}
90103
}
91104
92-
const value = item[props.valueKey]
105+
const value = get(item, props.valueKey as string)
106+
const label = get(item, props.labelKey as string)
107+
const description = get(item, props.descriptionKey as string)
93108
94109
return {
95110
...item,
96111
value,
112+
label,
113+
description,
97114
id: `${id}:${value}`
98115
}
99116
}

src/runtime/components/Select.vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export interface SelectProps<T> extends Omit<SelectRootProps, 'dir'>, UseCompone
6363
* @defaultValue 'value'
6464
*/
6565
valueKey?: string
66+
/**
67+
* When `items` is an array of objects, select the field to use as the label.
68+
* @defaultValue 'label'
69+
*/
70+
labelKey?: string
6671
items?: T[] | T[][]
6772
/** Highlight the ring color like a focus state. */
6873
highlight?: boolean
@@ -97,12 +102,14 @@ import { useAppConfig } from '#imports'
97102
import { useButtonGroup } from '../composables/useButtonGroup'
98103
import { useComponentIcons } from '../composables/useComponentIcons'
99104
import { useFormField } from '../composables/useFormField'
105+
import { get } from '../utils'
100106
import UIcon from './Icon.vue'
101107
import UAvatar from './Avatar.vue'
102108
import UChip from './Chip.vue'
103109
104110
const props = withDefaults(defineProps<SelectProps<T>>(), {
105111
valueKey: 'value',
112+
labelKey: 'label',
106113
portal: true
107114
})
108115
const emits = defineEmits<SelectEmits>()
@@ -183,14 +190,16 @@ function onUpdateOpen(value: boolean) {
183190
<SelectGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
184191
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
185192
<SelectLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
186-
{{ item.label }}
193+
{{ get(item, props.labelKey as string) }}
187194
</SelectLabel>
195+
188196
<SelectSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
197+
189198
<SelectItem
190199
v-else
191200
:class="ui.item({ class: props.ui?.item })"
192201
:disabled="item.disabled"
193-
:value="typeof item === 'object' ? (item[valueKey as keyof SelectItem] as string) : item"
202+
:value="typeof item === 'object' ? get(item, props.valueKey as string) : item"
194203
>
195204
<slot name="item" :item="(item as T)" :index="index">
196205
<slot name="item-leading" :item="(item as T)" :index="index">
@@ -208,7 +217,7 @@ function onUpdateOpen(value: boolean) {
208217

209218
<SelectItemText :class="ui.itemLabel({ class: props.ui?.itemLabel })">
210219
<slot name="item-label" :item="(item as T)" :index="index">
211-
{{ typeof item === 'object' ? item.label : item }}
220+
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
212221
</slot>
213222
</SelectItemText>
214223

src/runtime/components/SelectMenu.vue

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ export interface SelectMenuProps<T> extends Pick<ComboboxRootProps<T>, 'modelVal
7777
* @defaultValue undefined
7878
*/
7979
valueKey?: keyof T
80+
/**
81+
* When `items` is an array of objects, select the field to use as the label.
82+
* @defaultValue 'label'
83+
*/
84+
labelKey?: keyof T
8085
items?: T[] | T[][]
8186
/** Highlight the ring color like a focus state. */
8287
highlight?: boolean
@@ -124,7 +129,8 @@ const props = withDefaults(defineProps<SelectMenuProps<T>>(), {
124129
portal: true,
125130
autofocusDelay: 0,
126131
searchInput: () => ({ placeholder: 'Search...' }),
127-
filter: () => ['label']
132+
filter: () => ['label'],
133+
labelKey: 'label' as keyof T
128134
})
129135
const emits = defineEmits<SelectMenuEmits<T>>()
130136
const slots = defineSlots<SelectMenuSlots<T>>()
@@ -156,17 +162,17 @@ function displayValue(value: T): string {
156162
return value.map(v => displayValue(v)).join(', ')
157163
}
158164
159-
const item = items.value.find(item => props.valueKey ? isEqual(item[props.valueKey], value) : isEqual(item, value))
165+
const item = items.value.find(item => props.valueKey ? isEqual(get(item as Record<string, any>, props.valueKey as string), value) : isEqual(item, value))
160166
161-
return item && (typeof item === 'object' ? item.label : item)
167+
return item && (typeof item === 'object' ? get(item, props.labelKey as string) : item)
162168
}
163169
164170
function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: string): ArrayOrWrapped<AcceptableValue> {
165171
if (props.filter === false) {
166172
return items
167173
}
168174
169-
const fields = Array.isArray(props.filter) ? props.filter : ['label']
175+
const fields = Array.isArray(props.filter) ? props.filter : [props.labelKey]
170176
const escapedSearchTerm = escapeRegExp(searchTerm)
171177
172178
return items.filter((item) => {
@@ -175,7 +181,7 @@ function filterFunction(items: ArrayOrWrapped<AcceptableValue>, searchTerm: stri
175181
}
176182
177183
return fields.some((field) => {
178-
const child = get(item, field)
184+
const child = get(item, field as string)
179185
180186
return child !== null && child !== undefined && String(child).search(new RegExp(escapedSearchTerm, 'i')) !== -1
181187
})
@@ -267,7 +273,7 @@ function onUpdateOpen(value: boolean) {
267273
<ComboboxGroup v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: props.ui?.group })">
268274
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
269275
<ComboboxLabel v-if="item?.type === 'label'" :class="ui.label({ class: props.ui?.label })">
270-
{{ item.label }}
276+
{{ get(item, props.labelKey as string) }}
271277
</ComboboxLabel>
272278

273279
<ComboboxSeparator v-else-if="item?.type === 'separator'" :class="ui.separator({ class: props.ui?.separator })" />
@@ -276,7 +282,7 @@ function onUpdateOpen(value: boolean) {
276282
v-else
277283
:class="ui.item({ class: props.ui?.item })"
278284
:disabled="item.disabled"
279-
:value="valueKey && typeof item === 'object' ? (item[valueKey as keyof SelectMenuItem]) as AcceptableValue : item"
285+
:value="valueKey && typeof item === 'object' ? get(item, props.valueKey as string) : item"
280286
@select="item.select"
281287
>
282288
<slot name="item" :item="(item as T)" :index="index">
@@ -295,7 +301,7 @@ function onUpdateOpen(value: boolean) {
295301

296302
<span :class="ui.itemLabel({ class: props.ui?.itemLabel })">
297303
<slot name="item-label" :item="(item as T)" :index="index">
298-
{{ typeof item === 'object' ? item.label : item }}
304+
{{ typeof item === 'object' ? get(item, props.labelKey as string) : item }}
299305
</slot>
300306
</span>
301307

test/components/InputMenu.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,23 @@ describe('InputMenu', () => {
1212

1313
const items = [{
1414
label: 'Backlog',
15+
value: 'backlog',
1516
icon: 'i-heroicons-question-mark-circle'
1617
}, {
1718
label: 'Todo',
19+
value: 'todo',
1820
icon: 'i-heroicons-plus-circle'
1921
}, {
2022
label: 'In Progress',
23+
value: 'in_progress',
2124
icon: 'i-heroicons-arrow-up-circle'
2225
}, {
2326
label: 'Done',
27+
value: 'done',
2428
icon: 'i-heroicons-check-circle'
2529
}, {
2630
label: 'Canceled',
31+
value: 'canceled',
2732
icon: 'i-heroicons-x-circle'
2833
}]
2934

@@ -34,6 +39,8 @@ describe('InputMenu', () => {
3439
['with items', { props }],
3540
['with modelValue', { props: { ...props, modelValue: items[0] } }],
3641
['with defaultValue', { props: { ...props, defaultValue: items[0] } }],
42+
['with valueKey', { props: { ...props, valueKey: 'value' } }],
43+
['with labelKey', { props: { ...props, labelKey: 'value' } }],
3744
['with id', { props: { ...props, id: 'id' } }],
3845
['with name', { props: { ...props, name: 'name' } }],
3946
['with placeholder', { props: { ...props, placeholder: 'Search...' } }],

test/components/RadioGroup.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ describe('RadioGroup', () => {
2020
it.each([
2121
['with items', { props }],
2222
['with defaultValue', { props: { ...props, defaultValue: '1' } }],
23+
['with valueKey', { props: { ...props, valueKey: 'label' } }],
24+
['with labelKey', { props: { ...props, labelKey: 'value' } }],
25+
['with descriptionKey', { props: { ...props, descriptionKey: 'value' } }],
2326
['with disabled', { props: { ...props, disabled: true } }],
2427
['with description', { props: { items: items.map((opt, count) => ({ ...opt, description: `Description ${count}` })) } }],
2528
['with required', { props: { ...props, legend: 'Legend', required: true } }],

test/components/Select.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ describe('Select', () => {
3939
['with items', { props }],
4040
['with modelValue', { props: { ...props, modelValue: items[0] } }],
4141
['with defaultValue', { props: { ...props, defaultValue: items[0] } }],
42+
['with valueKey', { props: { ...props, valueKey: 'label' } }],
43+
['with labelKey', { props: { ...props, labelKey: 'value' } }],
4244
['with id', { props: { ...props, id: 'id' } }],
4345
['with name', { props: { ...props, name: 'name' } }],
4446
['with placeholder', { props: { ...props, placeholder: 'Search...' } }],

test/components/SelectMenu.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ describe('SelectMenu', () => {
3939
['with items', { props }],
4040
['with modelValue', { props: { ...props, modelValue: items[0] } }],
4141
['with defaultValue', { props: { ...props, defaultValue: items[0] } }],
42+
['with valueKey', { props: { ...props, valueKey: 'value' } }],
43+
['with labelKey', { props: { ...props, labelKey: 'value' } }],
4244
['with multiple', { props: { ...props, multiple: true } }],
4345
['with multiple and modelValue', { props: { ...props, multiple: true, modelValue: [items[0], items[1]] } }],
4446
['with id', { props: { ...props, id: 'id' } }],

0 commit comments

Comments
 (0)