Skip to content

Commit 81c6fd5

Browse files
committed
feat: add support for optgroups in SelectWidget and related documentation updates
Closes #4374 Fixes #1813, #580
1 parent d9f62aa commit 81c6fd5

6 files changed

Lines changed: 305 additions & 21 deletions

File tree

packages/core/src/components/widgets/SelectWidget.tsx

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChangeEvent, FocusEvent, SyntheticEvent, useCallback } from 'react';
1+
import { ChangeEvent, FocusEvent, ReactNode, SyntheticEvent, useCallback } from 'react';
22
import {
33
ariaDescribedByIds,
44
enumOptionsIndexForValue,
@@ -40,7 +40,7 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
4040
placeholder,
4141
htmlName,
4242
}: WidgetProps<T, S, F>) {
43-
const { enumOptions, enumDisabled, emptyValue: optEmptyVal } = options;
43+
const { enumOptions, enumDisabled, emptyValue: optEmptyVal, optgroups } = options;
4444
const emptyValue = multiple ? [] : '';
4545

4646
const handleFocus = useCallback(
@@ -70,6 +70,76 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
7070
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
7171
const showPlaceholderOption = !multiple && schema.default === undefined;
7272

73+
function renderOption(i: number): ReactNode {
74+
if (!Array.isArray(enumOptions) || !enumOptions[i]) {
75+
return null;
76+
}
77+
const { value, label } = enumOptions[i];
78+
const isDisabled = Array.isArray(enumDisabled) && enumDisabled.indexOf(value) !== -1;
79+
return (
80+
<option key={i} value={String(i)} disabled={isDisabled}>
81+
{label}
82+
</option>
83+
);
84+
}
85+
86+
function renderOptions(): ReactNode {
87+
if (!Array.isArray(enumOptions)) {
88+
return null;
89+
}
90+
91+
if (optgroups && typeof optgroups === 'object') {
92+
// Build a map from enum value to its index in enumOptions
93+
const valueToIndex = new Map<any, number>();
94+
enumOptions.forEach(({ value }, i) => {
95+
valueToIndex.set(value, i);
96+
});
97+
98+
// Track which indices are used in groups
99+
const groupedIndices = new Set<number>();
100+
101+
// Render optgroups
102+
const groups = Object.entries(optgroups).map(([label, values]) => {
103+
const groupOptions = (values as any[])
104+
.map((val) => {
105+
const idx = valueToIndex.get(val);
106+
if (idx === undefined) {
107+
return null;
108+
}
109+
groupedIndices.add(idx);
110+
return renderOption(idx);
111+
})
112+
.filter(Boolean);
113+
114+
return (
115+
<optgroup key={label} label={label}>
116+
{groupOptions}
117+
</optgroup>
118+
);
119+
});
120+
121+
// Render ungrouped options
122+
const ungrouped = enumOptions
123+
.map((_, i) => {
124+
if (groupedIndices.has(i)) {
125+
return null;
126+
}
127+
return renderOption(i);
128+
})
129+
.filter(Boolean);
130+
131+
return (
132+
<>
133+
{groups}
134+
{ungrouped}
135+
</>
136+
);
137+
}
138+
139+
// Default: flat list
140+
return enumOptions.map((_, i) => renderOption(i));
141+
}
142+
73143
return (
74144
<select
75145
id={id}
@@ -87,15 +157,7 @@ function SelectWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extend
87157
aria-describedby={ariaDescribedByIds(id)}
88158
>
89159
{showPlaceholderOption && <option value=''>{placeholder}</option>}
90-
{Array.isArray(enumOptions) &&
91-
enumOptions.map(({ value, label }, i) => {
92-
const disabled = enumDisabled && enumDisabled.indexOf(value) !== -1;
93-
return (
94-
<option key={i} value={String(i)} disabled={disabled}>
95-
{label}
96-
</option>
97-
);
98-
})}
160+
{renderOptions()}
99161
</select>
100162
);
101163
}

packages/core/test/StringField.test.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,125 @@ describe('StringField', () => {
682682
expect(options[0]).toHaveTextContent('');
683683
expect(options).toHaveLength(1);
684684
});
685+
686+
it('should render optgroups when ui:options.optgroups is provided', () => {
687+
const { node } = createFormComponent({
688+
schema: {
689+
type: 'string',
690+
enum: ['foo', 'bar', 'baz', 'qux'],
691+
},
692+
uiSchema: {
693+
'ui:options': {
694+
optgroups: {
695+
'Group A': ['foo', 'bar'],
696+
'Group B': ['baz', 'qux'],
697+
},
698+
},
699+
},
700+
});
701+
702+
const optgroups = node.querySelectorAll('optgroup');
703+
expect(optgroups).toHaveLength(2);
704+
expect(optgroups[0]).toHaveAttribute('label', 'Group A');
705+
expect(optgroups[1]).toHaveAttribute('label', 'Group B');
706+
expect(optgroups[0].querySelectorAll('option')).toHaveLength(2);
707+
expect(optgroups[1].querySelectorAll('option')).toHaveLength(2);
708+
});
709+
710+
it('should render ungrouped options after optgroups', () => {
711+
const { node } = createFormComponent({
712+
schema: {
713+
type: 'string',
714+
enum: ['foo', 'bar', 'baz', 'qux'],
715+
},
716+
uiSchema: {
717+
'ui:options': {
718+
optgroups: {
719+
'Group A': ['foo', 'bar'],
720+
},
721+
},
722+
},
723+
});
724+
725+
const select = node.querySelector('select')!;
726+
const optgroups = select.querySelectorAll('optgroup');
727+
expect(optgroups).toHaveLength(1);
728+
expect(optgroups[0]).toHaveAttribute('label', 'Group A');
729+
730+
// Ungrouped options (baz, qux) should be direct children of select, not inside optgroup
731+
const directOptions = Array.from(select.children).filter((child) => child.tagName === 'OPTION');
732+
// placeholder + baz + qux = 3 direct option children
733+
expect(directOptions).toHaveLength(3);
734+
});
735+
736+
it('should handle enumDisabled within optgroups', () => {
737+
const { node } = createFormComponent({
738+
schema: {
739+
type: 'string',
740+
enum: ['foo', 'bar', 'baz'],
741+
},
742+
uiSchema: {
743+
'ui:options': {
744+
enumDisabled: ['bar'],
745+
optgroups: {
746+
'Group A': ['foo', 'bar'],
747+
'Group B': ['baz'],
748+
},
749+
},
750+
},
751+
});
752+
753+
const optgroups = node.querySelectorAll('optgroup');
754+
const groupAOptions = optgroups[0].querySelectorAll('option');
755+
expect(groupAOptions[1]).toBeDisabled();
756+
});
757+
758+
it('should reflect the change event with optgroups', () => {
759+
const { node, onChange } = createFormComponent({
760+
schema: {
761+
type: 'string',
762+
enum: ['foo', 'bar', 'baz'],
763+
},
764+
uiSchema: {
765+
'ui:options': {
766+
optgroups: {
767+
'Group A': ['foo', 'bar'],
768+
'Group B': ['baz'],
769+
},
770+
},
771+
},
772+
});
773+
774+
act(() => {
775+
fireEvent.change(node.querySelector('select')!, {
776+
target: { value: '2' }, // index of 'baz'
777+
});
778+
});
779+
780+
expectToHaveBeenCalledWithFormData(onChange, 'baz', 'root');
781+
});
782+
783+
it('should render placeholder with optgroups', () => {
784+
const { node } = createFormComponent({
785+
schema: {
786+
type: 'string',
787+
enum: ['foo', 'bar'],
788+
},
789+
uiSchema: {
790+
'ui:options': {
791+
placeholder: 'Select one',
792+
optgroups: {
793+
'Group A': ['foo', 'bar'],
794+
},
795+
},
796+
},
797+
});
798+
799+
const select = node.querySelector('select')!;
800+
const firstOption = select.querySelector('option');
801+
expect(firstOption).toHaveTextContent('Select one');
802+
expect(firstOption).toHaveValue('');
803+
});
685804
});
686805

687806
describe('TextareaWidget', () => {

packages/docs/docs/api-reference/uiSchema.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,34 @@ render(<Form schema={schema} uiSchema={uiSchema} validator={validator} />, docum
644644

645645
This property allows you to reorder the properties that are shown for a particular object. See [Objects](../json-schema/objects.md) for more information.
646646

647+
### optgroups
648+
649+
To group `<option>` elements inside a `<select>` using `<optgroup>`, specify the grouping via the `optgroups` key in `ui:options`. Keys are the group labels, values are arrays of enum values belonging to that group. Any enum values not listed in a group are rendered ungrouped after the groups.
650+
651+
```tsx
652+
import { Form } from '@rjsf/core';
653+
import { RJSFSchema, UiSchema } from '@rjsf/utils';
654+
import validator from '@rjsf/validator-ajv8';
655+
656+
const schema: RJSFSchema = {
657+
type: 'string',
658+
enum: ['lorem', 'ipsum', 'dolorem', 'alpha', 'beta', 'gamma'],
659+
};
660+
661+
const uiSchema: UiSchema = {
662+
'ui:options': {
663+
optgroups: {
664+
Latin: ['lorem', 'ipsum', 'dolorem'],
665+
Greek: ['alpha', 'beta', 'gamma'],
666+
},
667+
},
668+
};
669+
670+
render(<Form schema={schema} uiSchema={uiSchema} validator={validator} />, document.getElementById('app'));
671+
```
672+
673+
Currently supported by the `core` and `react-bootstrap` theme packages.
674+
647675
### placeholder
648676

649677
You can add placeholder text to an input by using the `ui:placeholder` uiSchema directive:

packages/playground/src/samples/widgets.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ const widgets: Sample = {
168168
},
169169
],
170170
},
171+
selectWidgetOptions3: {
172+
title: 'Custom select widget with options grouped by optgroups',
173+
type: 'string',
174+
enum: ['lorem', 'ipsum', 'dolorem', 'alpha', 'beta', 'gamma'],
175+
},
171176
},
172177
},
173178
uiSchema: {
@@ -284,6 +289,14 @@ const widgets: Sample = {
284289
backgroundColor: 'pink',
285290
},
286291
},
292+
selectWidgetOptions3: {
293+
'ui:options': {
294+
optgroups: {
295+
Latin: ['lorem', 'ipsum', 'dolorem'],
296+
Greek: ['alpha', 'beta', 'gamma'],
297+
},
298+
},
299+
},
287300
},
288301
formData: {
289302
stringFormats: {

packages/react-bootstrap/src/SelectWidget/SelectWidget.tsx

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ChangeEvent, FocusEvent } from 'react';
1+
import { ChangeEvent, FocusEvent, ReactNode } from 'react';
22
import FormSelect from 'react-bootstrap/FormSelect';
33
import {
44
ariaDescribedByIds,
@@ -31,7 +31,7 @@ export default function SelectWidget<
3131
placeholder,
3232
rawErrors = [],
3333
}: WidgetProps<T, S, F>) {
34-
const { enumOptions, enumDisabled, emptyValue: optEmptyValue } = options;
34+
const { enumOptions, enumDisabled, emptyValue: optEmptyValue, optgroups } = options;
3535

3636
const emptyValue = multiple ? [] : '';
3737

@@ -48,6 +48,71 @@ export default function SelectWidget<
4848
const selectedIndexes = enumOptionsIndexForValue<S>(value, enumOptions, multiple);
4949
const showPlaceholderOption = !multiple && schema.default === undefined;
5050

51+
function renderOption(i: number): ReactNode {
52+
if (!Array.isArray(enumOptions) || !enumOptions[i]) {
53+
return null;
54+
}
55+
const { value, label } = enumOptions[i];
56+
const isDisabled = Array.isArray(enumDisabled) && enumDisabled.indexOf(value) !== -1;
57+
return (
58+
<option key={i} id={label} value={String(i)} disabled={isDisabled}>
59+
{label}
60+
</option>
61+
);
62+
}
63+
64+
function renderOptions(): ReactNode {
65+
if (!Array.isArray(enumOptions)) {
66+
return null;
67+
}
68+
69+
if (optgroups && typeof optgroups === 'object') {
70+
const valueToIndex = new Map<any, number>();
71+
enumOptions.forEach(({ value }, i) => {
72+
valueToIndex.set(value, i);
73+
});
74+
75+
const groupedIndices = new Set<number>();
76+
77+
const groups = Object.entries(optgroups).map(([label, values]) => {
78+
const groupOptions = (values as any[])
79+
.map((val) => {
80+
const idx = valueToIndex.get(val);
81+
if (idx === undefined) {
82+
return null;
83+
}
84+
groupedIndices.add(idx);
85+
return renderOption(idx);
86+
})
87+
.filter(Boolean);
88+
89+
return (
90+
<optgroup key={label} label={label}>
91+
{groupOptions}
92+
</optgroup>
93+
);
94+
});
95+
96+
const ungrouped = enumOptions
97+
.map((_, i) => {
98+
if (groupedIndices.has(i)) {
99+
return null;
100+
}
101+
return renderOption(i);
102+
})
103+
.filter(Boolean);
104+
105+
return (
106+
<>
107+
{groups}
108+
{ungrouped}
109+
</>
110+
);
111+
}
112+
113+
return enumOptions.map((_, i) => renderOption(i));
114+
}
115+
51116
return (
52117
<FormSelect
53118
id={id}
@@ -79,14 +144,7 @@ export default function SelectWidget<
79144
aria-describedby={ariaDescribedByIds(id)}
80145
>
81146
{showPlaceholderOption && <option value=''>{placeholder}</option>}
82-
{(enumOptions as any).map(({ value, label }: any, i: number) => {
83-
const disabled: any = Array.isArray(enumDisabled) && (enumDisabled as any).indexOf(value) != -1;
84-
return (
85-
<option key={i} id={label} value={String(i)} disabled={disabled}>
86-
{label}
87-
</option>
88-
);
89-
})}
147+
{renderOptions()}
90148
</FormSelect>
91149
);
92150
}

0 commit comments

Comments
 (0)