Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-donkeys-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"leva": patch
---

feat: add label/value object API for Select options
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/leva/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
361 changes: 361 additions & 0 deletions packages/leva/src/plugins/Select/select-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading