From 1e51411d32166a50c0d761c87ef8ab2021ac6bbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:04:53 +0000 Subject: [PATCH 01/14] Initial plan From d6a44bae30123780cf77b8a25bc05f8d8e8aefba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:11:27 +0000 Subject: [PATCH 02/14] Add support for label/value objects in Select options Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- .../leva/src/plugins/Select/select-plugin.ts | 10 ++++-- .../leva/src/plugins/Select/select-types.ts | 3 +- packages/leva/src/types/public.test.ts | 10 ++++++ packages/leva/src/types/public.ts | 7 ++-- .../leva/stories/inputs/Select.stories.tsx | 33 +++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index a0e89d02..db17158e 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -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) diff --git a/packages/leva/src/plugins/Select/select-types.ts b/packages/leva/src/plugins/Select/select-types.ts index 619c8bcf..bef759ec 100644 --- a/packages/leva/src/plugins/Select/select-types.ts +++ b/packages/leva/src/plugins/Select/select-types.ts @@ -1,6 +1,7 @@ import type { LevaInputProps } from '../../types' -export type SelectSettings = { options: Record | U[] } +export type SelectOption = { value: T; label?: string } +export type SelectSettings = { options: Record | U[] | SelectOption[] } export type InternalSelectSettings = { keys: string[]; values: any[] } export type SelectInput

= { value?: P } & SelectSettings diff --git a/packages/leva/src/types/public.test.ts b/packages/leva/src/types/public.test.ts index ca6e9777..561d95ce 100644 --- a/packages/leva/src/types/public.test.ts +++ b/packages/leva/src/types/public.test.ts @@ -62,6 +62,16 @@ expectType<{ a: number | string }>(useControls({ a: { options: [1, 'bar'] } })) expectType<{ a: string | number | Array }>(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 diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 254b678d..3b1383a0 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -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; value?: any } +type SelectOption = { value: T; label?: string } +type SelectInput = { options: any[] | Record | SelectOption[]; value?: any } -type SelectWithValueInput = { options: T[] | Record; value: K } -type SelectWithoutValueInput = { options: T[] | Record } +type SelectWithValueInput = { options: T[] | Record | SelectOption[]; value: K } +type SelectWithoutValueInput = { options: T[] | Record | SelectOption[] } type ColorRgbaInput = { r: number; g: number; b: number; a?: number } type ColorHslaInput = { h: number; s: number; l: number; a?: number } diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index 5c18e36b..af716d52 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -63,3 +63,36 @@ export const FunctionAsOptions = () => { ) } + +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 ( +

+
Selected: {values.myFunction.name || 'anonymous function'}
+ +
+ ) +} From 2eabe18becc59292f7c287b406c9a1a6d83ced3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:18:25 +0000 Subject: [PATCH 03/14] Fix: Prevent array mutation and improve type safety Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- packages/leva/src/plugins/Select/select-plugin.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index db17158e..0cd4d0ea 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -22,10 +22,12 @@ export const normalize = (input: SelectInput) => { let { value, options } = input let keys let values + let isValueLabelArray = false if (Array.isArray(options)) { // 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]) { + isValueLabelArray = true values = options.map((o: any) => o.value) keys = options.map((o: any) => ('label' in o ? String(o.label) : String(o.value))) } else { @@ -43,6 +45,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 } } } From fab7c546207a6aa637c8df022d14f138e8ab63b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:19:48 +0000 Subject: [PATCH 04/14] Remove unused variable isValueLabelArray Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- packages/leva/src/plugins/Select/select-plugin.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 0cd4d0ea..21cf15dd 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -22,12 +22,10 @@ export const normalize = (input: SelectInput) => { let { value, options } = input let keys let values - let isValueLabelArray = false if (Array.isArray(options)) { // 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]) { - isValueLabelArray = true values = options.map((o: any) => o.value) keys = options.map((o: any) => ('label' in o ? String(o.label) : String(o.value))) } else { From 5579ffecece94fcfbcd3742ef35bbfc9eed3ffb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:51:30 +0000 Subject: [PATCH 05/14] Fix comment and remove out-of-scope function story Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- .../leva/src/plugins/Select/select-plugin.ts | 2 +- .../leva/stories/inputs/Select.stories.tsx | 23 ------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 21cf15dd..be063003 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -1,7 +1,7 @@ import v8n from 'v8n' import type { SelectInput, InternalSelectSettings } from './select-types' -// the options attribute is either an key value object or an array +// the options attribute is either a key value object, an array, or an array of {value, label} objects export const schema = (_o: any, s: any) => v8n() .schema({ diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index af716d52..8785e7bd 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -73,26 +73,3 @@ ValueLabelObjects.args = { { 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 ( -
-
Selected: {values.myFunction.name || 'anonymous function'}
- -
- ) -} From 5a17d5b2e698ebc7b2620e54f168a12a0360925e Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Mon, 3 Nov 2025 22:33:40 +0100 Subject: [PATCH 06/14] bigger refactor of select-plugin and related tests --- package.json | 3 +- packages/leva/package.json | 1 + .../src/plugins/Select/select-plugin.test.ts | 306 ++++++++++++++++++ .../leva/src/plugins/Select/select-plugin.ts | 111 +++++-- .../leva/stories/inputs/Select.stories.tsx | 36 ++- pnpm-lock.yaml | 218 ++++++++++++- 6 files changed, 637 insertions(+), 38 deletions(-) create mode 100644 packages/leva/src/plugins/Select/select-plugin.test.ts diff --git a/package.json b/package.json index 9a63362c..97f400c5 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,8 @@ "start-server-and-test": "^1.15.2", "storybook": "^10.0.2", "tsd": "^0.25.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vitest": "^4.0.6" }, "prettier": { "bracketSameLine": true, diff --git a/packages/leva/package.json b/packages/leva/package.json index c8af3bd2..f3d7e5a9 100644 --- a/packages/leva/package.json +++ b/packages/leva/package.json @@ -32,6 +32,7 @@ "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", + "zod": "^4.1.12", "zustand": "^3.6.9" }, "devDependencies": { diff --git a/packages/leva/src/plugins/Select/select-plugin.test.ts b/packages/leva/src/plugins/Select/select-plugin.test.ts new file mode 100644 index 00000000..3d2e1eb0 --- /dev/null +++ b/packages/leva/src/plugins/Select/select-plugin.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, vi } from 'vitest' +import { normalize, schema, sanitize, format } from './select-plugin' + +describe('Select Plugin - normalize function', () => { + describe('Array of primitives', () => { + it('should normalize array of strings', () => { + const input = { options: ['x', 'y', 'z'] } + const result = normalize(input) + + expect(result.value).toBe('x') + expect(result.settings.keys).toEqual(['x', 'y', 'z']) + expect(result.settings.values).toEqual(['x', 'y', 'z']) + }) + + it('should normalize array of numbers', () => { + const input = { options: [1, 2, 3] } + const result = normalize(input) + + expect(result.value).toBe(1) + expect(result.settings.keys).toEqual(['1', '2', '3']) + expect(result.settings.values).toEqual([1, 2, 3]) + }) + + it('should normalize array of booleans', () => { + const input = { options: [true, false] } + const result = normalize(input) + + expect(result.value).toBe(true) + expect(result.settings.keys).toEqual(['true', 'false']) + expect(result.settings.values).toEqual([true, false]) + }) + + it('should normalize array of mixed primitive types', () => { + const input = { options: ['x', 1, true] } + const result = normalize(input) + + expect(result.value).toBe('x') + expect(result.settings.keys).toEqual(['x', '1', 'true']) + expect(result.settings.values).toEqual(['x', 1, true]) + }) + + it('should use provided value if it exists in options', () => { + const input = { value: 'y', options: ['x', 'y', 'z'] } + const result = normalize(input) + + expect(result.value).toBe('y') + expect(result.settings.keys).toEqual(['x', 'y', 'z']) + expect(result.settings.values).toEqual(['x', 'y', 'z']) + }) + }) + + describe('Object with key-value pairs (key as label)', () => { + it('should normalize object with string values', () => { + const input = { options: { foo: 'bar', baz: 'qux' } } + const result = normalize(input) + + expect(result.value).toBe('bar') + expect(result.settings.keys).toEqual(['foo', 'baz']) + expect(result.settings.values).toEqual(['bar', 'qux']) + }) + + it('should normalize object with number values', () => { + const input = { options: { small: 10, medium: 20, large: 30 } } + const result = normalize(input) + + expect(result.value).toBe(10) + expect(result.settings.keys).toEqual(['small', 'medium', 'large']) + expect(result.settings.values).toEqual([10, 20, 30]) + }) + + it('should normalize object with boolean values', () => { + const input = { options: { yes: true, no: false } } + const result = normalize(input) + + expect(result.value).toBe(true) + expect(result.settings.keys).toEqual(['yes', 'no']) + expect(result.settings.values).toEqual([true, false]) + }) + + it('should normalize object with mixed value types', () => { + const input = { options: { x: 1, foo: 'bar', z: true } } + const result = normalize(input) + + expect(result.value).toBe(1) + expect(result.settings.keys).toEqual(['x', 'foo', 'z']) + expect(result.settings.values).toEqual([1, 'bar', true]) + }) + + it('should use provided value if it exists in options', () => { + const input = { value: 'qux', options: { foo: 'bar', baz: 'qux' } } + const result = normalize(input) + + expect(result.value).toBe('qux') + expect(result.settings.keys).toEqual(['foo', 'baz']) + expect(result.settings.values).toEqual(['bar', 'qux']) + }) + }) + + describe('Array of {value, label} objects', () => { + it('should normalize array of value/label objects with all labels', () => { + const input = { + options: [ + { value: '#f00', label: 'Red' }, + { value: '#0f0', label: 'Green' }, + { value: '#00f', label: 'Blue' }, + ], + } + const result = normalize(input) + + expect(result.value).toBe('#f00') + expect(result.settings.keys).toEqual(['Red', 'Green', 'Blue']) + expect(result.settings.values).toEqual(['#f00', '#0f0', '#00f']) + }) + + it('should normalize array of value/label objects with some labels missing', () => { + const input = { + options: [{ value: '#f00', label: 'Red' }, { value: '#0f0' }, { value: '#00f', label: 'Blue' }], + } + const result = normalize(input) + + expect(result.value).toBe('#f00') + expect(result.settings.keys).toEqual(['Red', '#0f0', 'Blue']) + expect(result.settings.values).toEqual(['#f00', '#0f0', '#00f']) + }) + + it('should normalize array of value/label objects with no labels', () => { + const input = { + options: [{ value: 'x' }, { value: 'y' }, { value: 'z' }], + } + const result = normalize(input) + + expect(result.value).toBe('x') + expect(result.settings.keys).toEqual(['x', 'y', 'z']) + expect(result.settings.values).toEqual(['x', 'y', 'z']) + }) + + it('should normalize with number values', () => { + const input = { + options: [ + { value: 1, label: 'One' }, + { value: 2, label: 'Two' }, + ], + } + const result = normalize(input) + + expect(result.value).toBe(1) + expect(result.settings.keys).toEqual(['One', 'Two']) + expect(result.settings.values).toEqual([1, 2]) + }) + + it('should normalize with boolean values', () => { + const input = { + options: [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' }, + ], + } + const result = normalize(input) + + expect(result.value).toBe(true) + expect(result.settings.keys).toEqual(['Yes', 'No']) + expect(result.settings.values).toEqual([true, false]) + }) + + it('should use provided value if it exists in options', () => { + const input = { + value: '#0f0', + options: [ + { value: '#f00', label: 'Red' }, + { value: '#0f0', label: 'Green' }, + { value: '#00f', label: 'Blue' }, + ], + } + const result = normalize(input) + + expect(result.value).toBe('#0f0') + expect(result.settings.keys).toEqual(['Red', 'Green', 'Blue']) + expect(result.settings.values).toEqual(['#f00', '#0f0', '#00f']) + }) + }) + + describe('Edge cases and backward compatibility', () => { + it('should default to first value when no value is provided', () => { + const input = { options: ['a', 'b', 'c'] } + const result = normalize(input) + + expect(result.value).toBe('a') + }) + + it('should warn and return undefined when value does not exist in options', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const input = { value: true, options: [false] } + const result = normalize(input) + + expect(result.value).toBe(undefined) + expect(result.settings.keys).toEqual(['false']) + expect(result.settings.values).toEqual([false]) + expect(consoleWarnSpy).toHaveBeenCalledWith("[Leva] Selected value doesn't exist in Select options ", input) + + consoleWarnSpy.mockRestore() + }) + + it('should handle empty array options', () => { + const input = { options: [] } + const result = normalize(input) + + expect(result.value).toBe(undefined) + expect(result.settings.keys).toEqual([]) + expect(result.settings.values).toEqual([]) + }) + + it('should handle empty object options', () => { + const input = { options: {} } + const result = normalize(input) + + expect(result.value).toBe(undefined) + expect(result.settings.keys).toEqual([]) + expect(result.settings.values).toEqual([]) + }) + + it('should handle single option array', () => { + const input = { options: ['only-one'] } + const result = normalize(input) + + expect(result.value).toBe('only-one') + expect(result.settings.keys).toEqual(['only-one']) + expect(result.settings.values).toEqual(['only-one']) + }) + + it('should handle value of 0 correctly', () => { + const input = { value: 0, options: [0, 1, 2] } + const result = normalize(input) + + expect(result.value).toBe(0) + expect(result.settings.keys).toEqual(['0', '1', '2']) + expect(result.settings.values).toEqual([0, 1, 2]) + }) + + it('should handle empty string value correctly', () => { + const input = { value: '', options: ['', 'a', 'b'] } + const result = normalize(input) + + expect(result.value).toBe('') + expect(result.settings.keys).toEqual(['', 'a', 'b']) + expect(result.settings.values).toEqual(['', 'a', 'b']) + }) + }) +}) + +describe('Select Plugin - schema validation', () => { + it('should accept array of primitives', () => { + const result = schema(null, ['x', 'y', 'z']) + expect(result.success).toBe(true) + }) + + it('should accept object with primitive values', () => { + const result = schema(null, { foo: 'bar', baz: 1 }) + expect(result.success).toBe(true) + }) + + it('should accept array of value/label objects', () => { + const result = schema(null, [{ value: 'x', label: 'X' }, { value: 'y' }]) + expect(result.success).toBe(true) + }) + + it('should reject invalid input', () => { + const result = schema(null, null) + expect(result.success).toBe(false) + }) + + it('should reject array with non-primitive values', () => { + const result = schema(null, [{ nested: 'object' }, 'string']) + expect(result.success).toBe(false) + }) +}) + +describe('Select Plugin - sanitize function', () => { + it('should pass when value exists in values', () => { + const result = sanitize('x', { keys: ['x', 'y'], values: ['x', 'y'] }) + expect(result).toBe('x') + }) + + it('should throw error when value does not exist in values', () => { + expect(() => { + sanitize('z', { keys: ['x', 'y'], values: ['x', 'y'] }) + }).toThrow("Selected value doesn't match Select options") + }) +}) + +describe('Select Plugin - format function', () => { + it('should return the index of the value', () => { + const result = format('y', { keys: ['x', 'y', 'z'], values: ['x', 'y', 'z'] }) + expect(result).toBe(1) + }) + + it('should return 0 for first value', () => { + const result = format('x', { keys: ['x', 'y', 'z'], values: ['x', 'y', 'z'] }) + expect(result).toBe(0) + }) + + it('should return -1 when value not found', () => { + const result = format('notfound', { keys: ['x', 'y', 'z'], values: ['x', 'y', 'z'] }) + expect(result).toBe(-1) + }) +}) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index be063003..4d887a4e 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -1,13 +1,44 @@ -import v8n from 'v8n' import type { SelectInput, InternalSelectSettings } from './select-types' +import { z } from 'zod' + +const zValidPrimitive = z.union([z.string(), z.number(), z.boolean()]) + +/** + * Schema for the usecase + * + * ```ts + * ['x', 'y', 1, true] + * ``` + */ +const arrayOfPrimitivesSchema = z.array(zValidPrimitive) + +/** + * Schema for the usecase + * + * ```ts + * { x: 1, foo: 'bar', z: true } + * ``` + */ +const keyAsLabelObjectSchema = z.record(z.string(), zValidPrimitive) + +/** + * Schema for the usecase + * + * ```ts + * [{ value: 'x', label: 'X' }, { value: 'y', label: 'Y' }] + * ``` + */ +const valueLabelObjectSchema = z.object({ + value: zValidPrimitive, + label: z.string().optional(), +}) + +const arrayOfValueLabelObjectsSchema = z.array(valueLabelObjectSchema) + +const allUsecases = z.union([arrayOfPrimitivesSchema, keyAsLabelObjectSchema, arrayOfValueLabelObjectsSchema]) // the options attribute is either a key value object, an array, or an array of {value, label} objects -export const schema = (_o: any, s: any) => - v8n() - .schema({ - options: v8n().passesAnyOf(v8n().object(), v8n().array()), - }) - .test(s) +export const schema = (_o: any, s: any) => allUsecases.safeParse(s) export const sanitize = (value: any, { values }: InternalSelectSettings) => { if (values.indexOf(value) < 0) throw Error(`Selected value doesn't match Select options`) @@ -20,32 +51,54 @@ export const format = (value: any, { values }: InternalSelectSettings) => { export const normalize = (input: SelectInput) => { let { value, options } = input - let keys - let values - - if (Array.isArray(options)) { - // 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))) + + let gatheredKeys: string[] + let gatheredValues: unknown[] + + // Use schemas to identify and handle each use case + const isArrayOfValueLabelObjects = arrayOfValueLabelObjectsSchema.safeParse(options) + if (isArrayOfValueLabelObjects.success) { + // Array of {value, label} objects + gatheredValues = isArrayOfValueLabelObjects.data.map((o) => o.value) + gatheredKeys = isArrayOfValueLabelObjects.data.map((o) => + o.label !== undefined ? String(o.label) : String(o.value) + ) + } else { + const isArrayOfPrimitives = arrayOfPrimitivesSchema.safeParse(options) + if (isArrayOfPrimitives.success) { + // Array of primitives + gatheredValues = isArrayOfPrimitives.data + gatheredKeys = isArrayOfPrimitives.data.map((o) => String(o)) } else { - values = options - keys = options.map((o) => String(o)) + const isKeyAsLabelObject = keyAsLabelObjectSchema.safeParse(options) + if (isKeyAsLabelObject.success) { + // Record/object of key-value pairs + gatheredValues = Object.values(isKeyAsLabelObject.data) + gatheredKeys = Object.keys(isKeyAsLabelObject.data) + } else { + // Fallback (shouldn't happen if schema validation is correct) + gatheredValues = [] + gatheredKeys = [] + } } - } else { - values = Object.values(options) - keys = Object.keys(options) } - if (!('value' in input)) value = values[0] - else if (!values.includes(value)) { - keys.unshift(String(value)) - values.unshift(value) + /** + * If no value is passed, we use the first value found while gathering the keys and values. + */ + if (!('value' in input)) value = gatheredValues[0] + /** + * Supports this weird usecase for backward compatibility: + * + * ```ts + * { value: true, options: [false] } + * + * // notice how the value is NOT in the options array. + * ``` + */ else if (value !== undefined && !gatheredValues.includes(value)) { + console.warn("[Leva] Selected value doesn't exist in Select options ", input) + return { value: undefined, settings: { keys: gatheredKeys, values: gatheredValues } } } - // 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 } } + return { value, settings: { keys: gatheredKeys, values: gatheredValues } } } diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index 8785e7bd..a25652f1 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -22,12 +22,26 @@ const Template: StoryFn = (args) => { ) } +/** + * Passes a list of values. The value will be used as both value AND label. + */ export const Simple = Template.bind({}) Simple.args = { value: 'x', options: ['x', 'y'], } +/** + * No value is passed, so the first option will be selected as the default. + */ +export const NoValue = Template.bind({}) +NoValue.args = { + options: ['x', 'y'], +} + +/** + * Passes an object of values. The key will be used as label and the value will be used as value. + */ export const CustomLabels = Template.bind({}) CustomLabels.args = { value: 'helloWorld', @@ -43,27 +57,41 @@ InferredValueAsOption.args = { options: [false], } +/** + * Unsupported/deprecated use case, instead use consistent format for options + */ export const DifferentOptionTypes = Template.bind({}) DifferentOptionTypes.args = { value: undefined, options: ['x', 'y', ['x', 'y']], } -const IconA = () => IconA -const IconB = () => IconB +const ComponentA = () => Component A +const ComponentB = () => Component B +/** + * Shows passing functions as the option values. + */ export const FunctionAsOptions = () => { const values = useControls({ - foo: { options: { none: '', IconA, IconB } }, + foo: { options: { none: '', ComponentA, ComponentB } }, }) + if (!values.foo) return null + + // render value.foo as a react component + const Component = values.foo as React.ComponentType + return (
-
{values.foo.toString()}
+
) } +/** + * Shows passing a value/label records array. + */ export const ValueLabelObjects = Template.bind({}) ValueLabelObjects.args = { value: '#f00', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03714ea1..c6c09bea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.9.3 + vitest: + specifier: ^4.0.6 + version: 4.0.6(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0) demo: dependencies: @@ -273,6 +276,9 @@ importers: v8n: specifier: ^1.3.3 version: 1.5.1 + zod: + specifier: ^4.1.12 + version: 4.1.12 zustand: specifier: ^3.6.9 version: 3.7.2(react@18.3.1) @@ -1905,6 +1911,9 @@ packages: peerDependencies: size-limit: 8.2.6 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stitches/react@1.2.8': resolution: {integrity: sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==} peerDependencies: @@ -2318,6 +2327,9 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.6': + resolution: {integrity: sha512-5j8UUlBVhOjhj4lR2Nt9sEV8b4WtbcYh8vnfhTNA2Kn5+smtevzjNq+xlBuVhnFGXiyPPNzGrOVvmyHWkS5QGg==} + '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: @@ -2329,6 +2341,17 @@ packages: vite: optional: true + '@vitest/mocker@4.0.6': + resolution: {integrity: sha512-3COEIew5HqdzBFEYN9+u0dT3i/NCwppLnO1HkjGfAP1Vs3vti1Hxm/MvcbC4DAn3Szo1M7M3otiAaT83jvqIjA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@2.0.5': resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} @@ -2338,12 +2361,24 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.6': + resolution: {integrity: sha512-4vptgNkLIA1W1Nn5X4x8rLJBzPiJwnPc+awKtfBE5hNMVsoAl/JCCPPzNrbf+L4NKgklsis5Yp2gYa+XAS442g==} + + '@vitest/runner@4.0.6': + resolution: {integrity: sha512-trPk5qpd7Jj+AiLZbV/e+KiiaGXZ8ECsRxtnPnCrJr9OW2mLB72Cb824IXgxVz/mVU3Aj4VebY+tDTPn++j1Og==} + + '@vitest/snapshot@4.0.6': + resolution: {integrity: sha512-PaYLt7n2YzuvxhulDDu6c9EosiRuIE+FI2ECKs6yvHyhoga+2TBWI8dwBjs+IeuQaMtZTfioa9tj3uZb7nev1g==} + '@vitest/spy@2.0.5': resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.6': + resolution: {integrity: sha512-g9jTUYPV1LtRPRCQfhbMintW7BTQz1n6WXYQYRQ25qkyffA4bjVXjkROokZnv7t07OqfaFKw1lPzqKGk1hmNuQ==} + '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} @@ -2353,6 +2388,9 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.6': + resolution: {integrity: sha512-bG43VS3iYKrMIZXBo+y8Pti0O7uNju3KvNn6DrQWhQQKcLavMB+0NZfO1/QBAEbq0MaQ3QjNsnnXlGQvsh0Z6A==} + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2795,6 +2833,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.0: + resolution: {integrity: sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==} + engines: {node: '>=18'} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -3606,6 +3648,10 @@ packages: resolution: {integrity: sha512-MsG3prOVw1WtLXAZbM3KiYtooKR1LvxHh3VHsVtIy0uiUu8usxgB/94DP2HxtD/661lLdB6yzQ09lGJSQr6nkg==} engines: {node: '>=0.10.0'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -4854,6 +4900,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -5416,6 +5465,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -5492,6 +5544,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -5510,6 +5565,9 @@ packages: stats.js@0.17.0: resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5718,6 +5776,12 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -5730,6 +5794,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + tinyspy@3.0.2: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} @@ -6032,6 +6100,40 @@ packages: yaml: optional: true + vitest@4.0.6: + resolution: {integrity: sha512-gR7INfiVRwnEOkCk47faros/9McCZMp5LM+OMNWGLaDBSvJxIzwjgNFufkuePBNaesGRnLmNfW+ddbUJRZn0nQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.6 + '@vitest/browser-preview': 4.0.6 + '@vitest/browser-webdriverio': 4.0.6 + '@vitest/ui': 4.0.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + wait-on@7.0.1: resolution: {integrity: sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==} engines: {node: '>=12.0.0'} @@ -6106,6 +6208,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -6185,6 +6292,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zustand@3.7.2: resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==} engines: {node: '>=12.7.0'} @@ -8029,6 +8139,8 @@ snapshots: - uglify-js - webpack-cli + '@standard-schema/spec@1.0.0': {} + '@stitches/react@1.2.8(react@18.3.1)': dependencies: react: 18.3.1 @@ -8570,6 +8682,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.0.6': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.6 + '@vitest/utils': 4.0.6 + chai: 6.2.0 + tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0))': dependencies: '@vitest/spy': 3.2.4 @@ -8578,6 +8699,14 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0) + '@vitest/mocker@4.0.6(vite@7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0))': + dependencies: + '@vitest/spy': 4.0.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -8590,6 +8719,21 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.6': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.6': + dependencies: + '@vitest/utils': 4.0.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.6': + dependencies: + '@vitest/pretty-format': 4.0.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@2.0.5': dependencies: tinyspy: 3.0.2 @@ -8598,6 +8742,8 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.0.6': {} + '@vitest/utils@2.0.5': dependencies: '@vitest/pretty-format': 2.0.5 @@ -8617,6 +8763,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.0.6': + dependencies: + '@vitest/pretty-format': 4.0.6 + tinyrainbow: 3.0.3 + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -9116,6 +9267,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.0: {} + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -10148,6 +10301,8 @@ snapshots: exit-hook@1.1.1: {} + expect-type@1.2.2: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -10223,7 +10378,6 @@ snapshots: fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 - optional: true fflate@0.6.10: {} @@ -11413,6 +11567,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pathval@2.0.1: {} pause-stream@0.0.11: @@ -11894,7 +12050,6 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.52.5 '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 - optional: true rtl-css-js@1.16.1: dependencies: @@ -12046,6 +12201,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -12123,6 +12280,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -12151,6 +12310,8 @@ snapshots: stats.js@0.17.0: {} + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -12387,16 +12548,21 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - optional: true tinyrainbow@1.2.0: {} tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@3.0.2: {} tinyspy@4.0.4: {} @@ -12652,7 +12818,44 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 terser: 5.44.0 - optional: true + + vitest@4.0.6(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0): + dependencies: + '@vitest/expect': 4.0.6 + '@vitest/mocker': 4.0.6(vite@7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0)) + '@vitest/pretty-format': 4.0.6 + '@vitest/runner': 4.0.6 + '@vitest/snapshot': 4.0.6 + '@vitest/spy': 4.0.6 + '@vitest/utils': 4.0.6 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.1.12(@types/node@18.19.130)(jiti@1.21.7)(terser@5.44.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.130 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml wait-on@7.0.1(debug@4.3.4): dependencies: @@ -12779,6 +12982,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wouter@2.12.1(react@18.3.1): @@ -12841,6 +13049,8 @@ snapshots: zod@3.25.76: {} + zod@4.1.12: {} + zustand@3.7.2(react@18.3.1): optionalDependencies: react: 18.3.1 From ba226284fe6c8e83d3b07528ffc933360717b082 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Mon, 3 Nov 2025 22:57:59 +0100 Subject: [PATCH 07/14] more progress, needs more reviewing --- .../src/plugins/Select/select-plugin.test.ts | 75 ++++++++++++++++--- .../leva/src/plugins/Select/select-plugin.ts | 24 +++++- .../leva/stories/advanced/Busy.stories.tsx | 2 +- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.test.ts b/packages/leva/src/plugins/Select/select-plugin.test.ts index 3d2e1eb0..b1589e21 100644 --- a/packages/leva/src/plugins/Select/select-plugin.test.ts +++ b/packages/leva/src/plugins/Select/select-plugin.test.ts @@ -245,33 +245,88 @@ describe('Select Plugin - normalize function', () => { expect(result.settings.keys).toEqual(['', 'a', 'b']) expect(result.settings.values).toEqual(['', 'a', 'b']) }) + + it('should handle invalid mixed-type arrays (fallback scenario)', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // This tests the fallback case where options contain invalid types like nested arrays + const input = { options: ['x', 'y', ['x', 'y']] as any } + const result = normalize(input) + + // Falls through to empty fallback + expect(result.value).toBe(undefined) + expect(result.settings.keys).toEqual([]) + expect(result.settings.values).toEqual([]) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[Leva] Invalid Select options format'), + input.options + ) + + consoleWarnSpy.mockRestore() + }) + + it('should handle completely invalid options (not array or object)', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const input = { options: null as any } + const result = normalize(input) + + expect(result.value).toBe(undefined) + expect(result.settings.keys).toEqual([]) + expect(result.settings.values).toEqual([]) + expect(consoleWarnSpy).toHaveBeenCalled() + + consoleWarnSpy.mockRestore() + }) }) }) describe('Select Plugin - schema validation', () => { it('should accept array of primitives', () => { - const result = schema(null, ['x', 'y', 'z']) - expect(result.success).toBe(true) + const result = schema(null, { options: ['x', 'y', 'z'] }) + expect(result).toBe(true) }) it('should accept object with primitive values', () => { - const result = schema(null, { foo: 'bar', baz: 1 }) - expect(result.success).toBe(true) + const result = schema(null, { options: { foo: 'bar', baz: 1 } }) + expect(result).toBe(true) }) it('should accept array of value/label objects', () => { - const result = schema(null, [{ value: 'x', label: 'X' }, { value: 'y' }]) - expect(result.success).toBe(true) + const result = schema(null, { options: [{ value: 'x', label: 'X' }, { value: 'y' }] }) + expect(result).toBe(true) }) - it('should reject invalid input', () => { + it('should reject invalid input (missing options)', () => { + const result = schema(null, {}) + expect(result).toBe(false) + }) + + it('should reject null', () => { const result = schema(null, null) - expect(result.success).toBe(false) + expect(result).toBe(false) }) it('should reject array with non-primitive values', () => { - const result = schema(null, [{ nested: 'object' }, 'string']) - expect(result.success).toBe(false) + // Schema now properly validates that options are in one of the three valid formats + // This prevents invalid SELECT inputs from being recognized as SELECT at all + const result = schema(null, { options: [{ nested: 'object' }, 'string'] }) + expect(result).toBe(false) + }) + + it('should reject boolean primitives', () => { + const result = schema(null, true) + expect(result).toBe(false) + }) + + it('should reject number primitives', () => { + const result = schema(null, 10) + expect(result).toBe(false) + }) + + it('should reject settings without options key', () => { + const result = schema(null, { value: 'x' }) + expect(result).toBe(false) }) }) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 4d887a4e..5d15ca20 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -37,8 +37,22 @@ const arrayOfValueLabelObjectsSchema = z.array(valueLabelObjectSchema) const allUsecases = z.union([arrayOfPrimitivesSchema, keyAsLabelObjectSchema, arrayOfValueLabelObjectsSchema]) +/** + * Schema for the settings object - checks if it has an 'options' key + * We accept the three valid SELECT formats: + * 1. Array of primitives: ['x', 'y', 1] + * 2. Array of {value, label} objects: [{ value: 'x', label: 'X' }] + * 3. Object with key-value pairs: { x: 1, y: 2 } + * + * Note: We use allUsecases which handles detailed validation, so invalid formats + * will be caught and warned about in normalize() + */ +const selectInputSchema = z.object({ + options: allUsecases, +}) + // the options attribute is either a key value object, an array, or an array of {value, label} objects -export const schema = (_o: any, s: any) => allUsecases.safeParse(s) +export const schema = (_o: any, s: any) => selectInputSchema.safeParse(s).success export const sanitize = (value: any, { values }: InternalSelectSettings) => { if (values.indexOf(value) < 0) throw Error(`Selected value doesn't match Select options`) @@ -77,6 +91,14 @@ export const normalize = (input: SelectInput) => { gatheredKeys = Object.keys(isKeyAsLabelObject.data) } else { // Fallback (shouldn't happen if schema validation is correct) + console.warn( + '[Leva] Invalid Select options format. Expected one of:\n' + + ' - Array of primitives: ["x", "y", 1, true]\n' + + ' - Object with key-value pairs: { x: 1, foo: "bar" }\n' + + ' - Array of {value, label} objects: [{ value: "x", label: "X" }]\n' + + 'Received:', + options + ) gatheredValues = [] gatheredKeys = [] } diff --git a/packages/leva/stories/advanced/Busy.stories.tsx b/packages/leva/stories/advanced/Busy.stories.tsx index c83ecbf4..210a5f22 100644 --- a/packages/leva/stories/advanced/Busy.stories.tsx +++ b/packages/leva/stories/advanced/Busy.stories.tsx @@ -55,7 +55,7 @@ function BusyControls() { string: { value: 'something', optional: true, order: -2 }, range: { value: 0, min: -10, max: 10, order: -3 }, image: { image: undefined }, - select: { options: ['x', 'y', ['x', 'y']] }, + select: { options: ['x', 'y', 'x,y'] }, interval: { min: -100, max: 100, value: [-10, 10] }, color: '#ffffff', refMonitor: monitor(noise ? frame : () => 0, { graph: true, interval: 30 }), From 02d365f06ea883577c64e65ec35154d54e52ed95 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sat, 8 Nov 2025 19:17:22 +0100 Subject: [PATCH 08/14] changeset --- .changeset/fair-donkeys-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fair-donkeys-cheer.md diff --git a/.changeset/fair-donkeys-cheer.md b/.changeset/fair-donkeys-cheer.md new file mode 100644 index 00000000..82b1314d --- /dev/null +++ b/.changeset/fair-donkeys-cheer.md @@ -0,0 +1,5 @@ +--- +"leva": patch +--- + +feat: add label/value object API for Select options From f82ca3f8c0f9c95cc4be35c59c1bd6d633bdeb2c Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sat, 8 Nov 2025 23:45:11 +0100 Subject: [PATCH 09/14] . --- packages/leva/src/plugins/Select/select-types.ts | 2 +- packages/leva/src/types/public.ts | 3 ++- packages/leva/stories/inputs/Select.stories.tsx | 9 --------- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-types.ts b/packages/leva/src/plugins/Select/select-types.ts index bef759ec..3229ec63 100644 --- a/packages/leva/src/plugins/Select/select-types.ts +++ b/packages/leva/src/plugins/Select/select-types.ts @@ -1,6 +1,6 @@ import type { LevaInputProps } from '../../types' +import type { SelectOption } from '../../types/public' -export type SelectOption = { value: T; label?: string } export type SelectSettings = { options: Record | U[] | SelectOption[] } export type InternalSelectSettings = { keys: string[]; values: any[] } diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 3b1383a0..0d091ef1 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -104,7 +104,8 @@ export type IntervalInput = { value: [number, number]; min: number; max: number export type ImageInput = { image: undefined | string } -type SelectOption = { value: T; label?: string } +export type SelectOption = { value: T; label?: string } + type SelectInput = { options: any[] | Record | SelectOption[]; value?: any } type SelectWithValueInput = { options: T[] | Record | SelectOption[]; value: K } diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index a25652f1..434b0ca8 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -57,15 +57,6 @@ InferredValueAsOption.args = { options: [false], } -/** - * Unsupported/deprecated use case, instead use consistent format for options - */ -export const DifferentOptionTypes = Template.bind({}) -DifferentOptionTypes.args = { - value: undefined, - options: ['x', 'y', ['x', 'y']], -} - const ComponentA = () => Component A const ComponentB = () => Component B From 5c7bc8988ab56dcb422858f4fbb28bf4d6b9bb94 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sat, 8 Nov 2025 23:54:26 +0100 Subject: [PATCH 10/14] type fixes --- packages/leva/src/types/public.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 0d091ef1..a48a9583 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -108,8 +108,14 @@ export type SelectOption = { value: T; label?: string } type SelectInput = { options: any[] | Record | SelectOption[]; value?: any } -type SelectWithValueInput = { options: T[] | Record | SelectOption[]; value: K } -type SelectWithoutValueInput = { options: T[] | Record | SelectOption[] } +// Union branches prevent SelectOption objects from appearing in inferred value types. +// SelectOption[] branch extracts T; Exclude branch handles primitives. +type SelectWithValueInput = + | { options: SelectOption[]; value: K } + | { options: Exclude>[] | Record>>; value: K } +type SelectWithoutValueInput = + | { options: SelectOption[] } + | { options: Exclude>[] | Record>> } type ColorRgbaInput = { r: number; g: number; b: number; a?: number } type ColorHslaInput = { h: number; s: number; l: number; a?: number } From cea5ac2cc0a192afd82ba4f0de6fada6b70ab0cd Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sun, 9 Nov 2025 00:35:53 +0100 Subject: [PATCH 11/14] type changes --- .../leva/src/plugins/Select/select-plugin.ts | 14 ++- .../leva/src/plugins/Select/select-types.ts | 7 +- packages/leva/src/types/public.test.ts | 2 - packages/leva/src/types/public.ts | 12 ++- .../leva/stories/inputs/Select.stories.tsx | 91 +++++++++++-------- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 5d15ca20..488202bd 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -1,7 +1,7 @@ import type { SelectInput, InternalSelectSettings } from './select-types' import { z } from 'zod' -const zValidPrimitive = z.union([z.string(), z.number(), z.boolean()]) +const zValidPrimitive = z.union([z.string(), z.number(), z.boolean(), z.function()]) /** * Schema for the usecase @@ -28,14 +28,18 @@ const keyAsLabelObjectSchema = z.record(z.string(), zValidPrimitive) * [{ value: 'x', label: 'X' }, { value: 'y', label: 'Y' }] * ``` */ -const valueLabelObjectSchema = z.object({ +export const valueLabelObjectSchema = z.object({ value: zValidPrimitive, label: z.string().optional(), }) const arrayOfValueLabelObjectsSchema = z.array(valueLabelObjectSchema) -const allUsecases = z.union([arrayOfPrimitivesSchema, keyAsLabelObjectSchema, arrayOfValueLabelObjectsSchema]) +export const selectOptionsSchema = z.union([ + arrayOfPrimitivesSchema, + keyAsLabelObjectSchema, + arrayOfValueLabelObjectsSchema, +]) /** * Schema for the settings object - checks if it has an 'options' key @@ -44,11 +48,11 @@ const allUsecases = z.union([arrayOfPrimitivesSchema, keyAsLabelObjectSchema, ar * 2. Array of {value, label} objects: [{ value: 'x', label: 'X' }] * 3. Object with key-value pairs: { x: 1, y: 2 } * - * Note: We use allUsecases which handles detailed validation, so invalid formats + * Note: We use selectOptionsSchema which handles detailed validation, so invalid formats * will be caught and warned about in normalize() */ const selectInputSchema = z.object({ - options: allUsecases, + options: selectOptionsSchema, }) // the options attribute is either a key value object, an array, or an array of {value, label} objects diff --git a/packages/leva/src/plugins/Select/select-types.ts b/packages/leva/src/plugins/Select/select-types.ts index 3229ec63..0c084fa0 100644 --- a/packages/leva/src/plugins/Select/select-types.ts +++ b/packages/leva/src/plugins/Select/select-types.ts @@ -1,9 +1,10 @@ import type { LevaInputProps } from '../../types' -import type { SelectOption } from '../../types/public' +import type { z } from 'zod' +import type { selectOptionsSchema } from './select-plugin' -export type SelectSettings = { options: Record | U[] | SelectOption[] } +export type SelectSettings = { options: z.infer } export type InternalSelectSettings = { keys: string[]; values: any[] } -export type SelectInput

= { value?: P } & SelectSettings +export type SelectInput

= { value?: P } & SelectSettings export type SelectProps = LevaInputProps diff --git a/packages/leva/src/types/public.test.ts b/packages/leva/src/types/public.test.ts index 561d95ce..5f1e7ff7 100644 --- a/packages/leva/src/types/public.test.ts +++ b/packages/leva/src/types/public.test.ts @@ -59,9 +59,7 @@ expectType<{ a: string }>(useControls({ a: 'some string' })) */ expectType<{ a: string }>(useControls({ a: { options: ['foo', 'bar'] } })) expectType<{ a: number | string }>(useControls({ a: { options: [1, 'bar'] } })) -expectType<{ a: string | number | Array }>(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: { diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index a48a9583..004e2927 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -4,6 +4,8 @@ import type { VectorSettings } from '../plugins/Vector/vector-types' import { StoreType, Data, DataInput } from './internal' import type { BeautifyUnionType, UnionToIntersection } from './utils' +import type { z } from 'zod' +import type { valueLabelObjectSchema, selectOptionsSchema } from '../plugins/Select/select-plugin' export type RenderFn = (get: (key: string) => any) => boolean @@ -104,12 +106,12 @@ export type IntervalInput = { value: [number, number]; min: number; max: number export type ImageInput = { image: undefined | string } -export type SelectOption = { value: T; label?: string } +// Infer valid select types from Zod schemas to ensure runtime validation and types stay in sync +export type SelectOption = z.infer & { value: T } +export type SelectOptionsType = z.infer +export type SelectInput = { options: SelectOptionsType; value?: any } -type SelectInput = { options: any[] | Record | SelectOption[]; value?: any } - -// Union branches prevent SelectOption objects from appearing in inferred value types. -// SelectOption[] branch extracts T; Exclude branch handles primitives. +// Type inference helpers that extract value types from different option formats type SelectWithValueInput = | { options: SelectOption[]; value: K } | { options: Exclude>[] | Record>>; value: K } diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index 434b0ca8..ee777379 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -1,16 +1,19 @@ import React from 'react' -import { StoryFn, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' import Reset from '../components/decorator-reset' import { useControls } from '../../src' -export default { +const meta = { title: 'Inputs/Select', decorators: [Reset], -} as Meta +} satisfies Meta -const Template: StoryFn = (args) => { +export default meta +type Story = StoryObj + +const Render = (args: any) => { const values = useControls({ foo: args, }) @@ -25,37 +28,45 @@ const Template: StoryFn = (args) => { /** * Passes a list of values. The value will be used as both value AND label. */ -export const Simple = Template.bind({}) -Simple.args = { - value: 'x', - options: ['x', 'y'], -} +export const Simple: Story = { + args: { + value: 'x', + options: ['x', 'y'], + }, + render: Render, +} as Story /** * No value is passed, so the first option will be selected as the default. */ -export const NoValue = Template.bind({}) -NoValue.args = { - options: ['x', 'y'], -} +export const NoValue: Story = { + args: { + options: ['x', 'y'], + }, + render: Render, +} as Story /** * Passes an object of values. The key will be used as label and the value will be used as value. */ -export const CustomLabels = Template.bind({}) -CustomLabels.args = { - value: 'helloWorld', - options: { - 'Hello World': 'helloWorld', - 'Leva is awesome!': 'leva', +export const CustomLabels: Story = { + args: { + value: 'helloWorld', + options: { + 'Hello World': 'helloWorld', + 'Leva is awesome!': 'leva', + }, }, -} + render: Render, +} as Story -export const InferredValueAsOption = Template.bind({}) -InferredValueAsOption.args = { - value: true, - options: [false], -} +export const InferredValueAsOption: Story = { + args: { + value: true, + options: [false], + }, + render: Render, +} as Story const ComponentA = () => Component A const ComponentB = () => Component B @@ -63,12 +74,14 @@ const ComponentB = () => Component B /** * Shows passing functions as the option values. */ -export const FunctionAsOptions = () => { +const FunctionAsOptionsRender = () => { const values = useControls({ foo: { options: { none: '', ComponentA, ComponentB } }, }) - if (!values.foo) return null + if (!values.foo) { + return

No component selected
+ } // render value.foo as a react component const Component = values.foo as React.ComponentType @@ -80,15 +93,21 @@ export const FunctionAsOptions = () => { ) } +export const FunctionAsOptions: Story = { + render: FunctionAsOptionsRender, +} as Story + /** * Shows passing a value/label records array. */ -export const ValueLabelObjects = Template.bind({}) -ValueLabelObjects.args = { - value: '#f00', - options: [ - { value: '#f00', label: 'red' }, - { value: '#0f0', label: 'green' }, - { value: '#00f', label: 'blue' }, - ], -} +export const ValueLabelObjects: Story = { + args: { + value: '#f00', + options: [ + { value: '#f00', label: 'red' }, + { value: '#0f0', label: 'green' }, + { value: '#00f', label: 'blue' }, + ], + }, + render: Render, +} as Story From 13340b111ccf9e47a12d2c10d2ebd38217ab8c82 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sun, 9 Nov 2025 00:55:08 +0100 Subject: [PATCH 12/14] . --- .../leva/src/plugins/Select/select-plugin.ts | 5 + .../leva/src/plugins/Select/select-types.ts | 5 +- packages/leva/src/types/public.ts | 7 +- .../leva/stories/inputs/Select.stories.tsx | 165 +++++++++++------- 4 files changed, 109 insertions(+), 73 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 488202bd..9200c853 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -41,6 +41,11 @@ export const selectOptionsSchema = z.union([ arrayOfValueLabelObjectsSchema, ]) +export type SelectOptionSchema = typeof valueLabelObjectSchema +export type SelectOptionsSchemaType = typeof selectOptionsSchema +export type SelectOptionsType = z.infer +export type ValueLabelObjectType = z.infer + /** * Schema for the settings object - checks if it has an 'options' key * We accept the three valid SELECT formats: diff --git a/packages/leva/src/plugins/Select/select-types.ts b/packages/leva/src/plugins/Select/select-types.ts index 0c084fa0..531a1f5d 100644 --- a/packages/leva/src/plugins/Select/select-types.ts +++ b/packages/leva/src/plugins/Select/select-types.ts @@ -1,8 +1,7 @@ import type { LevaInputProps } from '../../types' -import type { z } from 'zod' -import type { selectOptionsSchema } from './select-plugin' +import type { SelectOptionsType } from './select-plugin' -export type SelectSettings = { options: z.infer } +export type SelectSettings = { options: SelectOptionsType } export type InternalSelectSettings = { keys: string[]; values: any[] } export type SelectInput

= { value?: P } & SelectSettings diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 004e2927..ebb7c758 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -4,8 +4,7 @@ import type { VectorSettings } from '../plugins/Vector/vector-types' import { StoreType, Data, DataInput } from './internal' import type { BeautifyUnionType, UnionToIntersection } from './utils' -import type { z } from 'zod' -import type { valueLabelObjectSchema, selectOptionsSchema } from '../plugins/Select/select-plugin' +import type { SelectOptionsType, ValueLabelObjectType } from '../plugins/Select/select-plugin' export type RenderFn = (get: (key: string) => any) => boolean @@ -107,8 +106,8 @@ export type IntervalInput = { value: [number, number]; min: number; max: number export type ImageInput = { image: undefined | string } // Infer valid select types from Zod schemas to ensure runtime validation and types stay in sync -export type SelectOption = z.infer & { value: T } -export type SelectOptionsType = z.infer +export type SelectOption = ValueLabelObjectType & { value: T } +export type { SelectOptionsType } export type SelectInput = { options: SelectOptionsType; value?: any } // Type inference helpers that extract value types from different option formats diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index ee777379..ef46625e 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -5,68 +5,92 @@ import Reset from '../components/decorator-reset' import { useControls } from '../../src' -const meta = { +const meta: Meta = { title: 'Inputs/Select', decorators: [Reset], -} satisfies Meta +} export default meta type Story = StoryObj -const Render = (args: any) => { - const values = useControls({ - foo: args, - }) - - return ( -

-
{JSON.stringify(values, null, '  ')}
-
- ) -} - /** * Passes a list of values. The value will be used as both value AND label. */ export const Simple: Story = { - args: { - value: 'x', - options: ['x', 'y'], + render: function Simple() { + const values = useControls({ + foo: { + value: 'x', + options: ['x', 'y'], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) }, - render: Render, -} as Story +} /** * No value is passed, so the first option will be selected as the default. */ export const NoValue: Story = { - args: { - options: ['x', 'y'], + render: function NoValue() { + const values = useControls({ + foo: { + options: ['x', 'y'], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) }, - render: Render, -} as Story +} /** * Passes an object of values. The key will be used as label and the value will be used as value. */ export const CustomLabels: Story = { - args: { - value: 'helloWorld', - options: { - 'Hello World': 'helloWorld', - 'Leva is awesome!': 'leva', - }, + render: function CustomLabels() { + const values = useControls({ + foo: { + value: 'helloWorld', + options: { + 'Hello World': 'helloWorld', + 'Leva is awesome!': 'leva', + }, + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) }, - render: Render, -} as Story +} export const InferredValueAsOption: Story = { - args: { - value: true, - options: [false], + render: function InferredValueAsOption() { + const values = useControls({ + foo: { + value: true, + options: [false], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) }, - render: Render, -} as Story +} const ComponentA = () => Component A const ComponentB = () => Component B @@ -74,40 +98,49 @@ const ComponentB = () => Component B /** * Shows passing functions as the option values. */ -const FunctionAsOptionsRender = () => { - const values = useControls({ - foo: { options: { none: '', ComponentA, ComponentB } }, - }) - - if (!values.foo) { - return
No component selected
- } - - // render value.foo as a react component - const Component = values.foo as React.ComponentType - - return ( -
- -
- ) -} - export const FunctionAsOptions: Story = { - render: FunctionAsOptionsRender, -} as Story + render: function FunctionAsOptions() { + const values = useControls({ + foo: { + options: { none: '', ComponentA, ComponentB }, + }, + }) + + if (!values.foo) { + return
No component selected
+ } + + // render value.foo as a react component + const Component = values.foo as React.ComponentType + + return ( +
+ +
+ ) + }, +} /** * Shows passing a value/label records array. */ export const ValueLabelObjects: Story = { - args: { - value: '#f00', - options: [ - { value: '#f00', label: 'red' }, - { value: '#0f0', label: 'green' }, - { value: '#00f', label: 'blue' }, - ], + render: function ValueLabelObjects() { + const values = useControls({ + foo: { + value: '#f00', + options: [ + { value: '#f00', label: 'red' }, + { value: '#0f0', label: 'green' }, + { value: '#00f', label: 'blue' }, + ], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) }, - render: Render, -} as Story +} From e5c74de239ba777eff5bfc125a122859b46764b3 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sun, 9 Nov 2025 01:00:40 +0100 Subject: [PATCH 13/14] . --- .../leva/stories/inputs/Select.stories.tsx | 161 ++++++++++++++++-- 1 file changed, 148 insertions(+), 13 deletions(-) diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index ef46625e..9965f177 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -1,5 +1,7 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' +import { expect, within, userEvent, waitFor } from 'storybook/test' +import { vi } from 'vitest' import Reset from '../components/decorator-reset' @@ -31,6 +33,36 @@ export const Simple: Story = { ) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for initial render + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() + }) + + // Verify initial value is 'x' + await expect(canvas.getByText(/"foo":\s*"x"/)).toBeInTheDocument() + + // Find the native select element by label (rendered in document.body) + const selectElement = within(document.body).getByLabelText('foo') as HTMLSelectElement + + // Change to 'y' + await userEvent.selectOptions(selectElement, 'y') + + // Verify value changed to 'y' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"y"/)).toBeInTheDocument() + }) + + // Change back to 'x' + await userEvent.selectOptions(selectElement, 'x') + + // Verify value is back to 'x' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"x"/)).toBeInTheDocument() + }) + }, } /** @@ -50,6 +82,36 @@ export const NoValue: Story = { ) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for initial render - default should be first option 'x' + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() + }) + + // Verify default value is 'x' + await expect(canvas.getByText(/"foo":\s*"x"/)).toBeInTheDocument() + + // Find the native select element by label + const selectElement = within(document.body).getByLabelText('foo') as HTMLSelectElement + + // Change to 'y' + await userEvent.selectOptions(selectElement, 'y') + + // Verify value changed to 'y' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"y"/)).toBeInTheDocument() + }) + + // Change back to 'x' + await userEvent.selectOptions(selectElement, 'x') + + // Verify value is back to 'x' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"x"/)).toBeInTheDocument() + }) + }, } /** @@ -73,22 +135,35 @@ export const CustomLabels: Story = { ) }, -} + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) -export const InferredValueAsOption: Story = { - render: function InferredValueAsOption() { - const values = useControls({ - foo: { - value: true, - options: [false], - }, + // Wait for initial render + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() }) - return ( -
-
{JSON.stringify(values, null, '  ')}
-
- ) + // Verify initial value is 'helloWorld' + await expect(canvas.getByText(/"foo":\s*"helloWorld"/)).toBeInTheDocument() + + // Find the native select element by label + const selectElement = within(document.body).getByLabelText('foo') as HTMLSelectElement + + // Change to 'leva' (labeled as 'Leva is awesome!') + await userEvent.selectOptions(selectElement, 'Leva is awesome!') + + // Verify value changed to 'leva' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"leva"/)).toBeInTheDocument() + }) + + // Change back to 'helloWorld' (labeled as 'Hello World') + await userEvent.selectOptions(selectElement, 'Hello World') + + // Verify value is back to 'helloWorld' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"helloWorld"/)).toBeInTheDocument() + }) }, } @@ -119,6 +194,36 @@ export const FunctionAsOptions: Story = { ) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for initial render + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() + }) + + // Verify initial state (default is first option 'none', so no component) + await expect(canvas.getByText('No component selected')).toBeInTheDocument() + + // Find the native select element by label + const selectElement = within(document.body).getByLabelText('foo') as HTMLSelectElement + + // Change to 'ComponentA' + await userEvent.selectOptions(selectElement, 'ComponentA') + + // Verify ComponentA is rendered + await waitFor(() => { + expect(canvas.getByText('Component A')).toBeInTheDocument() + }) + + // Change back to 'none' + await userEvent.selectOptions(selectElement, 'none') + + // Verify back to no component + await waitFor(() => { + expect(canvas.getByText('No component selected')).toBeInTheDocument() + }) + }, } /** @@ -143,4 +248,34 @@ export const ValueLabelObjects: Story = { ) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for initial render + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() + }) + + // Verify initial value is '#f00' + await expect(canvas.getByText(/"foo":\s*"#f00"/)).toBeInTheDocument() + + // Find the native select element by label + const selectElement = within(document.body).getByLabelText('foo') as HTMLSelectElement + + // Change to 'green' (value '#0f0') + await userEvent.selectOptions(selectElement, 'green') + + // Verify value changed to '#0f0' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"#0f0"/)).toBeInTheDocument() + }) + + // Change back to 'red' (value '#f00') + await userEvent.selectOptions(selectElement, 'red') + + // Verify value is back to '#f00' + await waitFor(() => { + expect(canvas.getByText(/"foo":\s*"#f00"/)).toBeInTheDocument() + }) + }, } From 5011336473dc64f45d29c379c8c20e63908b46c7 Mon Sep 17 00:00:00 2001 From: Gianmarco Date: Sun, 9 Nov 2025 01:16:44 +0100 Subject: [PATCH 14/14] . --- packages/leva/src/plugins/Select/select-plugin.ts | 9 ++++++++- packages/leva/stories/inputs/Select.stories.tsx | 1 - 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/leva/src/plugins/Select/select-plugin.ts b/packages/leva/src/plugins/Select/select-plugin.ts index 9200c853..29822c99 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -1,7 +1,14 @@ import type { SelectInput, InternalSelectSettings } from './select-types' import { z } from 'zod' -const zValidPrimitive = z.union([z.string(), z.number(), z.boolean(), z.function()]) +// Use z.custom() for functions instead of z.function() to preserve function identity. +// z.function() wraps values in zod proxies, changing their references. +const zValidPrimitive = z.union([ + z.string(), + z.number(), + z.boolean(), + z.custom((v) => typeof v === 'function'), +]) /** * Schema for the usecase diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index 9965f177..a9c80cae 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -1,7 +1,6 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' import { expect, within, userEvent, waitFor } from 'storybook/test' -import { vi } from 'vitest' import Reset from '../components/decorator-reset'