Skip to content
Open
15 changes: 12 additions & 3 deletions packages/leva/src/plugins/Select/select-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ export const normalize = (input: SelectInput) => {
let values

if (Array.isArray(options)) {
values = options
keys = options.map((o) => String(o))
// Check if this is an array of {value, label} objects
if (options.length > 0 && typeof options[0] === 'object' && options[0] !== null && 'value' in options[0]) {
values = options.map((o: any) => o.value)
keys = options.map((o: any) => ('label' in o ? String(o.label) : String(o.value)))
} else {
values = options
keys = options.map((o) => String(o))
}
} else {
values = Object.values(options)
keys = Object.keys(options)
Expand All @@ -37,6 +43,9 @@ export const normalize = (input: SelectInput) => {
values.unshift(value)
}

if (!Object.values(options).includes(value)) (options as any)[String(value)] = value
// Only modify options object for backward compatibility when it's actually an object
if (!Array.isArray(options) && !Object.values(options).includes(value)) {
(options as any)[String(value)] = value
}
return { value, settings: { keys, values } }
}
3 changes: 2 additions & 1 deletion packages/leva/src/plugins/Select/select-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LevaInputProps } from '../../types'

export type SelectSettings<U = unknown> = { options: Record<string, U> | U[] }
export type SelectOption<T = unknown> = { value: T; label?: string }
export type SelectSettings<U = unknown> = { options: Record<string, U> | U[] | SelectOption<U>[] }
export type InternalSelectSettings = { keys: string[]; values: any[] }

export type SelectInput<P = unknown, U = unknown> = { value?: P } & SelectSettings<U>
Expand Down
10 changes: 10 additions & 0 deletions packages/leva/src/types/public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ expectType<{ a: number | string }>(useControls({ a: { options: [1, 'bar'] } }))
expectType<{ a: string | number | Array<string | number> }>(useControls({ a: { options: ['foo', 1, ['foo', 'bar']] } }))
expectType<{ a: boolean | number }>(useControls({ a: { options: { foo: 1, bar: true } } }))
expectType<{ a: number | string | string[] }>(useControls({ a: { value: 3, options: ['foo', ['foo', 'bar']] } }))
expectType<{ a: string }>(
useControls({
a: {
options: [
{ value: '#f00', label: 'red' },
{ value: '#0f0', label: 'green' },
],
},
})
)

/**
* images
Expand Down
7 changes: 4 additions & 3 deletions packages/leva/src/types/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ export type IntervalInput = { value: [number, number]; min: number; max: number

export type ImageInput = { image: undefined | string }

type SelectInput = { options: any[] | Record<string, any>; value?: any }
type SelectOption<T = any> = { value: T; label?: string }
type SelectInput = { options: any[] | Record<string, any> | SelectOption[]; value?: any }

type SelectWithValueInput<T, K> = { options: T[] | Record<string, T>; value: K }
type SelectWithoutValueInput<T> = { options: T[] | Record<string, T> }
type SelectWithValueInput<T, K> = { options: T[] | Record<string, T> | SelectOption<T>[]; value: K }
type SelectWithoutValueInput<T> = { options: T[] | Record<string, T> | SelectOption<T>[] }

type ColorRgbaInput = { r: number; g: number; b: number; a?: number }
type ColorHslaInput = { h: number; s: number; l: number; a?: number }
Expand Down
33 changes: 33 additions & 0 deletions packages/leva/stories/inputs/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,36 @@ export const FunctionAsOptions = () => {
</div>
)
}

export const ValueLabelObjects = Template.bind({})
ValueLabelObjects.args = {
value: '#f00',
options: [
{ value: '#f00', label: 'red' },
{ value: '#0f0', label: 'green' },
{ value: '#00f', label: 'blue' },
],
}

export const ValueLabelObjectsWithFunctions = () => {
const fn1 = () => console.log('Function 1')
const fn2 = () => console.log('Function 2')
const fn3 = () => console.log('Function 3')

const values = useControls({
myFunction: {
options: [
{ value: fn1, label: 'First Function' },
{ value: fn2, label: 'Second Function' },
{ value: fn3, label: 'Third Function' },
],
},
})

return (
<div>
<pre>Selected: {values.myFunction.name || 'anonymous function'}</pre>
<button onClick={values.myFunction}>Call selected function</button>
</div>
)
}
Copy link
Member

@gsimone gsimone Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot This is out of scope

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the ValueLabelObjectsWithFunctions story in commit 5579ffe. The ValueLabelObjects story remains to demonstrate the new API.