Skip to content
This repository was archived by the owner on Jun 7, 2026. It is now read-only.

Commit 4efd145

Browse files
author
Mukunda Katta
committed
Add Radio and Toggle form fields
Closes #160. Adds two new form field components: - FormFieldRadio: renders a string-Enum/Literal field as a radio button group. Triggered by `Radio()` (or json_schema_extra={"format": "radio"}) on the Pydantic field. Supports `inline` for horizontal layout. - FormFieldToggle: dedicated on/off switch component. Triggered by `Toggle()` (or json_schema_extra={"format": "toggle"}). Optional on_label / off_label render next to the switch. Multi-value fields (list[Enum]) annotated with Radio() fall back to the existing select component, since radios can't represent multi-select. Frontend: - src/npm-fastui/src/components/FormField.tsx: new FormFieldRadioComp and FormFieldToggleComp following the existing FormFieldBooleanComp / FormFieldSelectVanillaComp patterns. Pre-computes useClassName values to satisfy react-hooks/rules-of-hooks. - src/npm-fastui/src/components/index.tsx: wires the new types into the AnyComp switch. - src/npm-fastui/src/models.d.ts: matching TypeScript interfaces. - src/npm-fastui-bootstrap/src/index.tsx: Bootstrap classNameGenerator cases for `form-check`, `form-switch`, radio sub-elements. - src/npm-fastui-bootstrap/src/Radio.tsx, Toggle.tsx: thin re-export wrappers (matches the layout of modal.tsx / navbar.tsx). Python: - src/python-fastui/fastui/components/forms.py: FormFieldRadio / FormFieldToggle Pydantic models with `type` discriminator. - src/python-fastui/fastui/forms.py: Radio() / Toggle() helper functions that mirror the existing Textarea() helper. - src/python-fastui/fastui/json_schema.py: dispatch on format='radio' (string + enum) and format='toggle' (boolean). Also preserve outer `format` keys when dereferencing $ref so per-field overrides survive. Tests: - src/python-fastui/tests/test_forms.py: 5 new tests covering radio field schema output, radio submit, invalid value, toggle schema output, and the multi-radio fallback to select.
1 parent b5a33bc commit 4efd145

13 files changed

Lines changed: 493 additions & 36 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { FC } from 'react'
2+
import { components, models } from 'fastui'
3+
4+
/**
5+
* Bootstrap-flavoured wrapper around the core `FormFieldRadioComp`.
6+
*
7+
* The radio button group itself is rendered by the core npm-fastui package; the
8+
* styling for Bootstrap is supplied via the `classNameGenerator` in this
9+
* package's `index.tsx`. This wrapper exists so consumers can plug a Radio in
10+
* via `customRender` (or import it directly) without having to reach into
11+
* `fastui/components`. Matches the file layout of `modal.tsx` / `navbar.tsx`.
12+
*/
13+
export const Radio: FC<models.FormFieldRadio> = (props) => {
14+
return <components.FormFieldRadioComp {...props} />
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { FC } from 'react'
2+
import { components, models } from 'fastui'
3+
4+
/**
5+
* Bootstrap-flavoured wrapper around the core `FormFieldToggleComp`.
6+
*
7+
* The toggle (on/off switch) itself is rendered by the core npm-fastui package;
8+
* Bootstrap styling is supplied via the `classNameGenerator` in this package's
9+
* `index.tsx`. The wrapper exists so consumers can plug a Toggle in via
10+
* `customRender` (or import it directly) without having to reach into
11+
* `fastui/components`. Matches the file layout of `modal.tsx` / `navbar.tsx`.
12+
*/
13+
export const Toggle: FC<models.FormFieldToggle> = (props) => {
14+
return <components.FormFieldToggleComp {...props} />
15+
}

src/npm-fastui-bootstrap/src/index.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,18 @@ export const classNameGenerator: ClassNameGenerator = ({
7878
case 'FormFieldInput':
7979
case 'FormFieldTextarea':
8080
case 'FormFieldBoolean':
81+
case 'FormFieldToggle':
82+
case 'FormFieldRadio':
8183
case 'FormFieldSelect':
8284
case 'FormFieldSelectSearch':
8385
case 'FormFieldFile':
8486
switch (subElement) {
8587
case 'textarea':
8688
case 'input':
8789
return {
88-
'form-control': type !== 'FormFieldBoolean',
90+
'form-control': type !== 'FormFieldBoolean' && type !== 'FormFieldToggle' && type !== 'FormFieldRadio',
8991
'is-invalid': props.error != null,
90-
'form-check-input': type === 'FormFieldBoolean',
92+
'form-check-input': type === 'FormFieldBoolean' || type === 'FormFieldToggle' || type === 'FormFieldRadio',
9193
}
9294
case 'select':
9395
return 'form-select'
@@ -97,17 +99,35 @@ export const classNameGenerator: ClassNameGenerator = ({
9799
if (props.displayMode === 'inline') {
98100
return 'visually-hidden'
99101
} else {
100-
return { 'form-label': true, 'fw-bold': !!props.required, 'form-check-label': type === 'FormFieldBoolean' }
102+
return {
103+
'form-label': true,
104+
'fw-bold': !!props.required,
105+
'form-check-label': type === 'FormFieldBoolean' || type === 'FormFieldToggle',
106+
}
101107
}
102108
case 'error':
103109
return 'invalid-feedback'
104110
case 'description':
105111
return 'form-text'
112+
case 'radio':
113+
return 'form-check'
114+
case 'radio-group':
115+
return ''
116+
case 'radio-group-inline':
117+
return 'd-flex gap-3 flex-wrap'
118+
case 'radio-label':
119+
return 'form-check-label'
120+
case 'toggle-labels':
121+
return 'ms-2 small text-muted'
122+
case 'toggle-on':
123+
return 'ms-1'
124+
case 'toggle-off':
125+
return 'me-1'
106126
default:
107127
return {
108128
'mb-3': true,
109-
'form-check': type === 'FormFieldBoolean',
110-
'form-switch': type === 'FormFieldBoolean' && props.mode === 'switch',
129+
'form-check': type === 'FormFieldBoolean' || type === 'FormFieldToggle',
130+
'form-switch': type === 'FormFieldToggle' || (type === 'FormFieldBoolean' && props.mode === 'switch'),
111131
}
112132
}
113133
case 'Navbar':

src/npm-fastui/src/components/FormField.tsx

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
FormFieldInput,
77
FormFieldTextarea,
88
FormFieldBoolean,
9+
FormFieldToggle,
10+
FormFieldRadio,
911
FormFieldFile,
1012
FormFieldSelect,
1113
FormFieldSelectSearch,
@@ -100,6 +102,106 @@ export const FormFieldBooleanComp: FC<FormFieldBooleanProps> = (props) => {
100102
)
101103
}
102104

105+
interface FormFieldToggleProps extends FormFieldToggle {
106+
onChange?: PrivateOnChange
107+
}
108+
109+
export const FormFieldToggleComp: FC<FormFieldToggleProps> = (props) => {
110+
const { name, required, locked, onChange, onLabel, offLabel } = props
111+
// hooks must be called unconditionally; precompute every class name before render.
112+
const containerClass = useClassName(props)
113+
const inputClass = useClassName(props, { el: 'input' })
114+
const labelsClass = useClassName(props, { el: 'toggle-labels' })
115+
const onClass = useClassName(props, { el: 'toggle-on' })
116+
const offClass = useClassName(props, { el: 'toggle-off' })
117+
118+
return (
119+
<div className={containerClass}>
120+
<Label {...props} />
121+
<input
122+
type="checkbox"
123+
role="switch"
124+
className={inputClass}
125+
defaultChecked={!!props.initial}
126+
id={inputId(props)}
127+
name={name}
128+
required={required}
129+
disabled={locked}
130+
aria-describedby={descId(props)}
131+
onChange={onChange}
132+
/>
133+
{/* Optional on/off labels render after the switch so screen readers announce them
134+
alongside the standard label. They are visual hints, not separate inputs. */}
135+
{(onLabel || offLabel) && (
136+
<span className={labelsClass}>
137+
{offLabel && <span className={offClass}>{offLabel}</span>}
138+
{onLabel && <span className={onClass}>{onLabel}</span>}
139+
</span>
140+
)}
141+
<ErrorDescription {...props} />
142+
</div>
143+
)
144+
}
145+
146+
interface FormFieldRadioProps extends FormFieldRadio {
147+
onChange?: PrivateOnChange
148+
}
149+
150+
export const FormFieldRadioComp: FC<FormFieldRadioProps> = (props) => {
151+
const { name, required, locked, options, initial, onChange, inline } = props
152+
const groupId = inputId(props)
153+
// hooks must be called unconditionally; precompute every class name before render.
154+
const containerClass = useClassName(props)
155+
const radioGroupClass = useClassName(props, { el: 'radio-group' })
156+
const radioGroupInlineClass = useClassName(props, { el: 'radio-group-inline' })
157+
const radioClass = useClassName(props, { el: 'radio' })
158+
const inputClass = useClassName(props, { el: 'input' })
159+
const radioLabelClass = useClassName(props, { el: 'radio-label' })
160+
161+
// Flatten any select groups so we can render a flat list of <input type="radio"> rows.
162+
// We don't currently render group separators because the radio control isn't a great
163+
// place to display section headings — the maintainer can add that later if needed.
164+
const flatOptions: SelectOption[] = []
165+
for (const opt of options) {
166+
if ('options' in opt) {
167+
flatOptions.push(...opt.options)
168+
} else {
169+
flatOptions.push(opt)
170+
}
171+
}
172+
173+
return (
174+
<div className={containerClass} role="radiogroup" aria-labelledby={`${groupId}-label`}>
175+
<Label {...props} />
176+
<div className={inline ? radioGroupInlineClass : radioGroupClass}>
177+
{flatOptions.map((opt, i) => {
178+
const optionId = `${groupId}-${i}`
179+
return (
180+
<div key={opt.value} className={radioClass}>
181+
<input
182+
type="radio"
183+
className={inputClass}
184+
id={optionId}
185+
name={name}
186+
value={opt.value}
187+
defaultChecked={initial === opt.value}
188+
required={required && i === 0}
189+
disabled={locked}
190+
aria-describedby={descId(props)}
191+
onChange={onChange}
192+
/>
193+
<label htmlFor={optionId} className={radioLabelClass}>
194+
{opt.label}
195+
</label>
196+
</div>
197+
)
198+
})}
199+
</div>
200+
<ErrorDescription {...props} />
201+
</div>
202+
)
203+
}
204+
103205
interface FormFieldFileProps extends FormFieldFile {
104206
onChange?: PrivateOnChange
105207
}
@@ -329,6 +431,8 @@ export type FormFieldProps =
329431
| FormFieldInputProps
330432
| FormFieldTextareaProps
331433
| FormFieldBooleanProps
434+
| FormFieldToggleProps
435+
| FormFieldRadioProps
332436
| FormFieldFileProps
333437
| FormFieldSelectProps
334438
| FormFieldSelectSearchProps

src/npm-fastui/src/components/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
FormFieldInputComp,
1919
FormFieldTextareaComp,
2020
FormFieldBooleanComp,
21+
FormFieldToggleComp,
22+
FormFieldRadioComp,
2123
FormFieldSelectComp,
2224
FormFieldSelectSearchComp,
2325
FormFieldFileComp,
@@ -55,6 +57,8 @@ export {
5557
FormComp,
5658
FormFieldInputComp,
5759
FormFieldBooleanComp,
60+
FormFieldToggleComp,
61+
FormFieldRadioComp,
5862
FormFieldSelectComp,
5963
FormFieldSelectSearchComp,
6064
FormFieldFileComp,
@@ -134,6 +138,10 @@ export const AnyComp: FC<FastProps> = (props) => {
134138
return <FormFieldTextareaComp {...props} />
135139
case 'FormFieldBoolean':
136140
return <FormFieldBooleanComp {...props} />
141+
case 'FormFieldToggle':
142+
return <FormFieldToggleComp {...props} />
143+
case 'FormFieldRadio':
144+
return <FormFieldRadioComp {...props} />
137145
case 'FormFieldFile':
138146
return <FormFieldFileComp {...props} />
139147
case 'FormFieldSelect':

src/npm-fastui/src/models.d.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export type FastProps =
3636
| FormFieldInput
3737
| FormFieldTextarea
3838
| FormFieldBoolean
39+
| FormFieldToggle
40+
| FormFieldRadio
3941
| FormFieldFile
4042
| FormFieldSelect
4143
| FormFieldSelectSearch
@@ -559,6 +561,8 @@ export interface Form {
559561
| FormFieldInput
560562
| FormFieldTextarea
561563
| FormFieldBoolean
564+
| FormFieldToggle
565+
| FormFieldRadio
562566
| FormFieldFile
563567
| FormFieldSelect
564568
| FormFieldSelectSearch
@@ -641,6 +645,50 @@ export interface FormFieldBoolean {
641645
mode?: 'checkbox' | 'switch'
642646
type: 'FormFieldBoolean'
643647
}
648+
/**
649+
* Form field for an on/off toggle (switch) input.
650+
*/
651+
export interface FormFieldToggle {
652+
name: string
653+
title: string[] | string
654+
required?: boolean
655+
error?: string
656+
locked?: boolean
657+
description?: string
658+
displayMode?: 'default' | 'inline'
659+
className?:
660+
| string
661+
| ClassName[]
662+
| {
663+
[k: string]: boolean
664+
}
665+
initial?: boolean
666+
onLabel?: string
667+
offLabel?: string
668+
type: 'FormFieldToggle'
669+
}
670+
/**
671+
* Form field for a radio button group.
672+
*/
673+
export interface FormFieldRadio {
674+
name: string
675+
title: string[] | string
676+
required?: boolean
677+
error?: string
678+
locked?: boolean
679+
description?: string
680+
displayMode?: 'default' | 'inline'
681+
className?:
682+
| string
683+
| ClassName[]
684+
| {
685+
[k: string]: boolean
686+
}
687+
options: SelectOptions
688+
initial?: string
689+
inline?: boolean
690+
type: 'FormFieldRadio'
691+
}
644692
/**
645693
* Form field for file input.
646694
*/
@@ -748,6 +796,8 @@ export interface ModelForm {
748796
| FormFieldInput
749797
| FormFieldTextarea
750798
| FormFieldBoolean
799+
| FormFieldToggle
800+
| FormFieldRadio
751801
| FormFieldFile
752802
| FormFieldSelect
753803
| FormFieldSelectSearch

0 commit comments

Comments
 (0)