diff --git a/code/core/src/docs-tools/argTypes/convert/flow/convert.test.ts b/code/core/src/docs-tools/argTypes/convert/flow/convert.test.ts new file mode 100644 index 000000000000..d1c6b017f2c3 --- /dev/null +++ b/code/core/src/docs-tools/argTypes/convert/flow/convert.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { convert } from './convert.ts'; + +describe('Flow convert', () => { + describe('union', () => { + it('converts a union of literals to an enum', () => { + expect( + convert({ + name: 'union', + elements: [ + { name: 'literal', value: "'sm'" }, + { name: 'literal', value: "'md'" }, + { name: 'literal', value: "'lg'" }, + ], + }) + ).toEqual({ name: 'enum', value: ['sm', 'md', 'lg'] }); + }); + + it('converts an optional literal union (?type) to an enum by filtering void', () => { + // Flow represents `?('sm' | 'md' | 'lg')` as a union containing void and null + expect( + convert({ + name: 'union', + elements: [ + { name: 'literal', value: "'sm'" }, + { name: 'literal', value: "'md'" }, + { name: 'literal', value: "'lg'" }, + { name: 'void' }, + { name: 'null' }, + ], + }) + ).toEqual({ name: 'enum', value: ['sm', 'md', 'lg'] }); + }); + + it('falls back to union when elements are not all literals after filtering', () => { + expect( + convert({ + name: 'union', + elements: [ + { name: 'literal', value: "'sm'" }, + { name: 'string' }, + ], + }) + ).toMatchObject({ name: 'union' }); + }); + + it('falls back to union when only void/null remain after filtering', () => { + expect( + convert({ + name: 'union', + elements: [{ name: 'void' }, { name: 'null' }], + }) + ).toMatchObject({ name: 'union' }); + }); + }); +}); diff --git a/code/core/src/docs-tools/argTypes/convert/flow/convert.ts b/code/core/src/docs-tools/argTypes/convert/flow/convert.ts index 6a00519b7005..beb4d90c6e70 100644 --- a/code/core/src/docs-tools/argTypes/convert/flow/convert.ts +++ b/code/core/src/docs-tools/argTypes/convert/flow/convert.ts @@ -45,11 +45,17 @@ export const convert = (type: FlowType): SBType | void => { } case 'signature': return { ...base, ...convertSig(type) }; - case 'union': - if (type.elements?.every(isLiteral)) { - return { ...base, name: 'enum', value: type.elements?.map(toEnumOption) }; + case 'union': { + // Filter out Flow's nullable markers (void, null) so optional unions like + // ?('sm' | 'md' | 'lg') still resolve to an enum control. + const meaningfulElements = type.elements?.filter( + (element) => element.name !== 'void' && element.name !== 'null' + ); + if (meaningfulElements && meaningfulElements.length > 0 && meaningfulElements.every(isLiteral)) { + return { ...base, name: 'enum', value: meaningfulElements.map(toEnumOption) }; } return { ...base, name, value: type.elements?.map(convert) }; + } case 'intersection': return { ...base, name, value: type.elements?.map(convert) }; diff --git a/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.test.ts b/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.test.ts index 29deabc5ebdd..a10efa0c5cfe 100644 --- a/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.test.ts +++ b/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.test.ts @@ -108,6 +108,37 @@ describe('normalizeInputType', () => { defaultValue: 'defaultValue', }); }); + + it('hoists options nested inside control object to top level', () => { + expect( + normalizeInputType( + { + control: { type: 'select', options: ['sm', 'md', 'lg'] } as any, + }, + 'arg' + ) + ).toEqual({ + name: 'arg', + options: ['sm', 'md', 'lg'], + control: { type: 'select', disable: false }, + }); + }); + + it('preserves top-level options when control also has options', () => { + expect( + normalizeInputType( + { + options: ['xs', 'sm'], + control: { type: 'select', options: ['sm', 'md', 'lg'] } as any, + }, + 'arg' + ) + ).toEqual({ + name: 'arg', + options: ['xs', 'sm'], + control: { type: 'select', options: ['sm', 'md', 'lg'], disable: false }, + }); + }); }); describe('normalizeInputTypes', () => { diff --git a/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.ts b/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.ts index 9426b0c2333f..4425aaa14903 100644 --- a/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.ts +++ b/code/core/src/preview-api/modules/store/csf/normalizeInputTypes.ts @@ -27,17 +27,35 @@ const normalizeControl = (control: InputType['control']): StrictInputType['contr export const normalizeInputType = (inputType: InputType, key: string): StrictInputType => { const { type, control, ...rest } = inputType; + + // Hoist options nested inside the control object to the top level when the + // user writes { control: { type: 'select', options: [...] } } instead of the + // documented { control: { type: 'select' }, options: [...] } form. + let effectiveControl = control; + const effectiveRest = { ...rest }; + if ( + !effectiveRest.options && + control && + typeof control === 'object' && + !Array.isArray(control) && + 'options' in control + ) { + const { options, ...controlWithoutOptions } = control as Record; + effectiveRest.options = options as InputType['options']; + effectiveControl = controlWithoutOptions as InputType['control']; + } + const normalized: StrictInputType = { name: key, - ...rest, + ...effectiveRest, }; if (type) { normalized.type = normalizeType(type); } - if (control) { - normalized.control = normalizeControl(control); - } else if (control === false) { + if (effectiveControl) { + normalized.control = normalizeControl(effectiveControl); + } else if (effectiveControl === false) { normalized.control = { disable: true }; } return normalized;