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 diff --git a/packages/leva/package.json b/packages/leva/package.json index 813d500a..1f751be3 100644 --- a/packages/leva/package.json +++ b/packages/leva/package.json @@ -33,6 +33,7 @@ "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "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..b1589e21 --- /dev/null +++ b/packages/leva/src/plugins/Select/select-plugin.test.ts @@ -0,0 +1,361 @@ +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']) + }) + + 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, { options: ['x', 'y', 'z'] }) + expect(result).toBe(true) + }) + + it('should accept object with primitive values', () => { + 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, { options: [{ value: 'x', label: 'X' }, { value: 'y' }] }) + expect(result).toBe(true) + }) + + 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).toBe(false) + }) + + it('should reject array with non-primitive values', () => { + // 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) + }) +}) + +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 a0e89d02..29822c99 100644 --- a/packages/leva/src/plugins/Select/select-plugin.ts +++ b/packages/leva/src/plugins/Select/select-plugin.ts @@ -1,13 +1,74 @@ -import v8n from 'v8n' import type { SelectInput, InternalSelectSettings } from './select-types' +import { z } from 'zod' -// the options attribute is either an key value object or an array -export const schema = (_o: any, s: any) => - v8n() - .schema({ - options: v8n().passesAnyOf(v8n().object(), v8n().array()), - }) - .test(s) +// 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 + * + * ```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' }] + * ``` + */ +export const valueLabelObjectSchema = z.object({ + value: zValidPrimitive, + label: z.string().optional(), +}) + +const arrayOfValueLabelObjectsSchema = z.array(valueLabelObjectSchema) + +export const selectOptionsSchema = z.union([ + arrayOfPrimitivesSchema, + keyAsLabelObjectSchema, + 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: + * 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 selectOptionsSchema which handles detailed validation, so invalid formats + * will be caught and warned about in normalize() + */ +const selectInputSchema = z.object({ + options: selectOptionsSchema, +}) + +// 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) => 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`) @@ -20,23 +81,62 @@ export const format = (value: any, { values }: InternalSelectSettings) => { export const normalize = (input: SelectInput) => { let { value, options } = input - let keys - let values - if (Array.isArray(options)) { - values = options - keys = options.map((o) => String(o)) + 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 { - values = Object.values(options) - keys = Object.keys(options) + const isArrayOfPrimitives = arrayOfPrimitivesSchema.safeParse(options) + if (isArrayOfPrimitives.success) { + // Array of primitives + gatheredValues = isArrayOfPrimitives.data + gatheredKeys = isArrayOfPrimitives.data.map((o) => String(o)) + } else { + 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) + 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 = [] + } + } } - 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 } } } - if (!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/src/plugins/Select/select-types.ts b/packages/leva/src/plugins/Select/select-types.ts index 619c8bcf..531a1f5d 100644 --- a/packages/leva/src/plugins/Select/select-types.ts +++ b/packages/leva/src/plugins/Select/select-types.ts @@ -1,8 +1,9 @@ import type { LevaInputProps } from '../../types' +import type { SelectOptionsType } from './select-plugin' -export type SelectSettings = { options: Record | U[] } +export type SelectSettings = { options: SelectOptionsType } 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 ca6e9777..5f1e7ff7 100644 --- a/packages/leva/src/types/public.test.ts +++ b/packages/leva/src/types/public.test.ts @@ -59,9 +59,17 @@ 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: { + 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..ebb7c758 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -4,6 +4,7 @@ import type { VectorSettings } from '../plugins/Vector/vector-types' import { StoreType, Data, DataInput } from './internal' import type { BeautifyUnionType, UnionToIntersection } from './utils' +import type { SelectOptionsType, ValueLabelObjectType } from '../plugins/Select/select-plugin' export type RenderFn = (get: (key: string) => any) => boolean @@ -104,10 +105,18 @@ export type IntervalInput = { value: [number, number]; min: number; max: number export type ImageInput = { image: undefined | string } -type SelectInput = { options: any[] | Record; value?: any } - -type SelectWithValueInput = { options: T[] | Record; value: K } -type SelectWithoutValueInput = { options: T[] | Record } +// Infer valid select types from Zod schemas to ensure runtime validation and types stay in sync +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 +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 } 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 }), diff --git a/packages/leva/stories/inputs/Select.stories.tsx b/packages/leva/stories/inputs/Select.stories.tsx index 5c18e36b..a9c80cae 100644 --- a/packages/leva/stories/inputs/Select.stories.tsx +++ b/packages/leva/stories/inputs/Select.stories.tsx @@ -1,65 +1,280 @@ import React from 'react' -import { StoryFn, Meta } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' +import { expect, within, userEvent, waitFor } from 'storybook/test' import Reset from '../components/decorator-reset' import { useControls } from '../../src' -export default { +const meta: Meta = { title: 'Inputs/Select', decorators: [Reset], -} as Meta - -const Template: StoryFn = (args) => { - const values = useControls({ - foo: args, - }) - - return ( -

-
{JSON.stringify(values, null, '  ')}
-
- ) } -export const Simple = Template.bind({}) -Simple.args = { - value: 'x', - options: ['x', 'y'], +export default meta +type Story = StoryObj + +/** + * Passes a list of values. The value will be used as both value AND label. + */ +export const Simple: Story = { + render: function Simple() { + const values = useControls({ + foo: { + value: 'x', + options: ['x', 'y'], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) + }, + 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() + }) + }, } -export const CustomLabels = Template.bind({}) -CustomLabels.args = { - value: 'helloWorld', - options: { - 'Hello World': 'helloWorld', - 'Leva is awesome!': 'leva', +/** + * No value is passed, so the first option will be selected as the default. + */ +export const NoValue: Story = { + render: function NoValue() { + const values = useControls({ + foo: { + options: ['x', 'y'], + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) + }, + 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() + }) }, } -export const InferredValueAsOption = Template.bind({}) -InferredValueAsOption.args = { - value: true, - options: [false], +/** + * Passes an object of values. The key will be used as label and the value will be used as value. + */ +export const CustomLabels: Story = { + render: function CustomLabels() { + const values = useControls({ + foo: { + value: 'helloWorld', + options: { + 'Hello World': 'helloWorld', + 'Leva is awesome!': 'leva', + }, + }, + }) + + return ( +
+
{JSON.stringify(values, null, '  ')}
+
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for initial render + await waitFor(() => { + expect(within(document.body).getByText('foo')).toBeInTheDocument() + }) + + // 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() + }) + }, } -export const DifferentOptionTypes = Template.bind({}) -DifferentOptionTypes.args = { - value: undefined, - options: ['x', 'y', ['x', 'y']], +const ComponentA = () => Component A +const ComponentB = () => Component B + +/** + * Shows passing functions as the option values. + */ +export const FunctionAsOptions: 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 ( +
+ +
+ ) + }, + 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() + }) + }, } -const IconA = () => IconA -const IconB = () => IconB +/** + * Shows passing a value/label records array. + */ +export const ValueLabelObjects: Story = { + 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, '  ')}
+
+ ) + }, + 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 -export const FunctionAsOptions = () => { - const values = useControls({ - foo: { options: { none: '', IconA, IconB } }, - }) + // Change to 'green' (value '#0f0') + await userEvent.selectOptions(selectElement, 'green') - return ( -
-
{values.foo.toString()}
-
- ) + // 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() + }) + }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7d0afdb..260c41ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,9 @@ importers: v8n: specifier: ^1.3.3 version: 1.5.1 + zod: + specifier: ^4.1.12 + version: 4.1.12 zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@18.3.26)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) @@ -5786,6 +5789,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'} @@ -11974,6 +11980,8 @@ snapshots: zod@3.25.76: {} + zod@4.1.12: {} + zustand@3.7.2(react@18.3.1): optionalDependencies: react: 18.3.1