From 81c6fd58132357b8a44c04d76d97af3f7b52310e Mon Sep 17 00:00:00 2001 From: Nicolas Lemoine Date: Tue, 24 Mar 2026 08:38:22 +0100 Subject: [PATCH 1/2] feat: add support for optgroups in SelectWidget and related documentation updates Closes https://github.com/rjsf-team/react-jsonschema-form/pull/4374 Fixes #1813, #580 --- .../src/components/widgets/SelectWidget.tsx | 84 +++++++++++-- packages/core/test/StringField.test.tsx | 119 ++++++++++++++++++ packages/docs/docs/api-reference/uiSchema.md | 28 +++++ packages/playground/src/samples/widgets.tsx | 13 ++ .../src/SelectWidget/SelectWidget.tsx | 78 ++++++++++-- packages/utils/src/types.ts | 4 + 6 files changed, 305 insertions(+), 21 deletions(-) diff --git a/packages/core/src/components/widgets/SelectWidget.tsx b/packages/core/src/components/widgets/SelectWidget.tsx index d4922b7952..2d8c19d262 100644 --- a/packages/core/src/components/widgets/SelectWidget.tsx +++ b/packages/core/src/components/widgets/SelectWidget.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FocusEvent, SyntheticEvent, useCallback } from 'react'; +import { ChangeEvent, FocusEvent, ReactNode, SyntheticEvent, useCallback } from 'react'; import { ariaDescribedByIds, enumOptionsIndexForValue, @@ -40,7 +40,7 @@ function SelectWidget) { - const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options; + const { enumOptions, enumDisabled, emptyValue: optEmptyVal, optgroups } = options; const emptyValue = multiple ? [] : ''; const handleFocus = useCallback( @@ -70,6 +70,76 @@ function SelectWidget(value, enumOptions, multiple); const showPlaceholderOption = !multiple && schema.default === undefined; + function renderOption(i: number): ReactNode { + if (!Array.isArray(enumOptions) || !enumOptions[i]) { + return null; + } + const { value, label } = enumOptions[i]; + const isDisabled = Array.isArray(enumDisabled) && enumDisabled.indexOf(value) !== -1; + return ( + + ); + } + + function renderOptions(): ReactNode { + if (!Array.isArray(enumOptions)) { + return null; + } + + if (optgroups && typeof optgroups === 'object') { + // Build a map from enum value to its index in enumOptions + const valueToIndex = new Map(); + enumOptions.forEach(({ value }, i) => { + valueToIndex.set(value, i); + }); + + // Track which indices are used in groups + const groupedIndices = new Set(); + + // Render optgroups + const groups = Object.entries(optgroups).map(([label, values]) => { + const groupOptions = (values as any[]) + .map((val) => { + const idx = valueToIndex.get(val); + if (idx === undefined) { + return null; + } + groupedIndices.add(idx); + return renderOption(idx); + }) + .filter(Boolean); + + return ( + + {groupOptions} + + ); + }); + + // Render ungrouped options + const ungrouped = enumOptions + .map((_, i) => { + if (groupedIndices.has(i)) { + return null; + } + return renderOption(i); + }) + .filter(Boolean); + + return ( + <> + {groups} + {ungrouped} + + ); + } + + // Default: flat list + return enumOptions.map((_, i) => renderOption(i)); + } + return ( ); } diff --git a/packages/core/test/StringField.test.tsx b/packages/core/test/StringField.test.tsx index c8cd0e6d45..2d5711f603 100644 --- a/packages/core/test/StringField.test.tsx +++ b/packages/core/test/StringField.test.tsx @@ -682,6 +682,125 @@ describe('StringField', () => { expect(options[0]).toHaveTextContent(''); expect(options).toHaveLength(1); }); + + it('should render optgroups when ui:options.optgroups is provided', () => { + const { node } = createFormComponent({ + schema: { + type: 'string', + enum: ['foo', 'bar', 'baz', 'qux'], + }, + uiSchema: { + 'ui:options': { + optgroups: { + 'Group A': ['foo', 'bar'], + 'Group B': ['baz', 'qux'], + }, + }, + }, + }); + + const optgroups = node.querySelectorAll('optgroup'); + expect(optgroups).toHaveLength(2); + expect(optgroups[0]).toHaveAttribute('label', 'Group A'); + expect(optgroups[1]).toHaveAttribute('label', 'Group B'); + expect(optgroups[0].querySelectorAll('option')).toHaveLength(2); + expect(optgroups[1].querySelectorAll('option')).toHaveLength(2); + }); + + it('should render ungrouped options after optgroups', () => { + const { node } = createFormComponent({ + schema: { + type: 'string', + enum: ['foo', 'bar', 'baz', 'qux'], + }, + uiSchema: { + 'ui:options': { + optgroups: { + 'Group A': ['foo', 'bar'], + }, + }, + }, + }); + + const select = node.querySelector('select')!; + const optgroups = select.querySelectorAll('optgroup'); + expect(optgroups).toHaveLength(1); + expect(optgroups[0]).toHaveAttribute('label', 'Group A'); + + // Ungrouped options (baz, qux) should be direct children of select, not inside optgroup + const directOptions = Array.from(select.children).filter((child) => child.tagName === 'OPTION'); + // placeholder + baz + qux = 3 direct option children + expect(directOptions).toHaveLength(3); + }); + + it('should handle enumDisabled within optgroups', () => { + const { node } = createFormComponent({ + schema: { + type: 'string', + enum: ['foo', 'bar', 'baz'], + }, + uiSchema: { + 'ui:options': { + enumDisabled: ['bar'], + optgroups: { + 'Group A': ['foo', 'bar'], + 'Group B': ['baz'], + }, + }, + }, + }); + + const optgroups = node.querySelectorAll('optgroup'); + const groupAOptions = optgroups[0].querySelectorAll('option'); + expect(groupAOptions[1]).toBeDisabled(); + }); + + it('should reflect the change event with optgroups', () => { + const { node, onChange } = createFormComponent({ + schema: { + type: 'string', + enum: ['foo', 'bar', 'baz'], + }, + uiSchema: { + 'ui:options': { + optgroups: { + 'Group A': ['foo', 'bar'], + 'Group B': ['baz'], + }, + }, + }, + }); + + act(() => { + fireEvent.change(node.querySelector('select')!, { + target: { value: '2' }, // index of 'baz' + }); + }); + + expectToHaveBeenCalledWithFormData(onChange, 'baz', 'root'); + }); + + it('should render placeholder with optgroups', () => { + const { node } = createFormComponent({ + schema: { + type: 'string', + enum: ['foo', 'bar'], + }, + uiSchema: { + 'ui:options': { + placeholder: 'Select one', + optgroups: { + 'Group A': ['foo', 'bar'], + }, + }, + }, + }); + + const select = node.querySelector('select')!; + const firstOption = select.querySelector('option'); + expect(firstOption).toHaveTextContent('Select one'); + expect(firstOption).toHaveValue(''); + }); }); describe('TextareaWidget', () => { diff --git a/packages/docs/docs/api-reference/uiSchema.md b/packages/docs/docs/api-reference/uiSchema.md index 1e58a6f947..29c0a85f41 100644 --- a/packages/docs/docs/api-reference/uiSchema.md +++ b/packages/docs/docs/api-reference/uiSchema.md @@ -644,6 +644,34 @@ render(
, docum This property allows you to reorder the properties that are shown for a particular object. See [Objects](../json-schema/objects.md) for more information. +### optgroups + +To group `