Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions src/npm-fastui-bootstrap/src/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FC } from 'react'
import { components, models } from 'fastui'

/**
* Bootstrap-flavoured wrapper around the core `FormFieldRadioComp`.
*
* The radio button group itself is rendered by the core npm-fastui package; the
* styling for Bootstrap is supplied via the `classNameGenerator` in this
* package's `index.tsx`. This wrapper exists so consumers can plug a Radio in
* via `customRender` (or import it directly) without having to reach into
* `fastui/components`. Matches the file layout of `modal.tsx` / `navbar.tsx`.
*/
export const Radio: FC<models.FormFieldRadio> = (props) => {
return <components.FormFieldRadioComp {...props} />
}
15 changes: 15 additions & 0 deletions src/npm-fastui-bootstrap/src/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { FC } from 'react'
import { components, models } from 'fastui'

/**
* Bootstrap-flavoured wrapper around the core `FormFieldToggleComp`.
*
* The toggle (on/off switch) itself is rendered by the core npm-fastui package;
* Bootstrap styling is supplied via the `classNameGenerator` in this package's
* `index.tsx`. The wrapper exists so consumers can plug a Toggle in via
* `customRender` (or import it directly) without having to reach into
* `fastui/components`. Matches the file layout of `modal.tsx` / `navbar.tsx`.
*/
export const Toggle: FC<models.FormFieldToggle> = (props) => {
return <components.FormFieldToggleComp {...props} />
}
30 changes: 25 additions & 5 deletions src/npm-fastui-bootstrap/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,18 @@ export const classNameGenerator: ClassNameGenerator = ({
case 'FormFieldInput':
case 'FormFieldTextarea':
case 'FormFieldBoolean':
case 'FormFieldToggle':
case 'FormFieldRadio':
case 'FormFieldSelect':
case 'FormFieldSelectSearch':
case 'FormFieldFile':
switch (subElement) {
case 'textarea':
case 'input':
return {
'form-control': type !== 'FormFieldBoolean',
'form-control': type !== 'FormFieldBoolean' && type !== 'FormFieldToggle' && type !== 'FormFieldRadio',
'is-invalid': props.error != null,
'form-check-input': type === 'FormFieldBoolean',
'form-check-input': type === 'FormFieldBoolean' || type === 'FormFieldToggle' || type === 'FormFieldRadio',
}
case 'select':
return 'form-select'
Expand All @@ -97,17 +99,35 @@ export const classNameGenerator: ClassNameGenerator = ({
if (props.displayMode === 'inline') {
return 'visually-hidden'
} else {
return { 'form-label': true, 'fw-bold': !!props.required, 'form-check-label': type === 'FormFieldBoolean' }
return {
'form-label': true,
'fw-bold': !!props.required,
'form-check-label': type === 'FormFieldBoolean' || type === 'FormFieldToggle',
}
}
case 'error':
return 'invalid-feedback'
case 'description':
return 'form-text'
case 'radio':
return 'form-check'
case 'radio-group':
return ''
case 'radio-group-inline':
return 'd-flex gap-3 flex-wrap'
case 'radio-label':
return 'form-check-label'
case 'toggle-labels':
return 'ms-2 small text-muted'
case 'toggle-on':
return 'ms-1'
case 'toggle-off':
return 'me-1'
default:
return {
'mb-3': true,
'form-check': type === 'FormFieldBoolean',
'form-switch': type === 'FormFieldBoolean' && props.mode === 'switch',
'form-check': type === 'FormFieldBoolean' || type === 'FormFieldToggle',
'form-switch': type === 'FormFieldToggle' || (type === 'FormFieldBoolean' && props.mode === 'switch'),
}
}
case 'Navbar':
Expand Down
104 changes: 104 additions & 0 deletions src/npm-fastui/src/components/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
FormFieldInput,
FormFieldTextarea,
FormFieldBoolean,
FormFieldToggle,
FormFieldRadio,
FormFieldFile,
FormFieldSelect,
FormFieldSelectSearch,
Expand Down Expand Up @@ -100,6 +102,106 @@ export const FormFieldBooleanComp: FC<FormFieldBooleanProps> = (props) => {
)
}

interface FormFieldToggleProps extends FormFieldToggle {
onChange?: PrivateOnChange
}

export const FormFieldToggleComp: FC<FormFieldToggleProps> = (props) => {
const { name, required, locked, onChange, onLabel, offLabel } = props
// hooks must be called unconditionally; precompute every class name before render.
const containerClass = useClassName(props)
const inputClass = useClassName(props, { el: 'input' })
const labelsClass = useClassName(props, { el: 'toggle-labels' })
const onClass = useClassName(props, { el: 'toggle-on' })
const offClass = useClassName(props, { el: 'toggle-off' })

return (
<div className={containerClass}>
<Label {...props} />
<input
type="checkbox"
role="switch"
className={inputClass}
defaultChecked={!!props.initial}
id={inputId(props)}
name={name}
required={required}
disabled={locked}
aria-describedby={descId(props)}
onChange={onChange}
/>
{/* Optional on/off labels render after the switch so screen readers announce them
alongside the standard label. They are visual hints, not separate inputs. */}
{(onLabel || offLabel) && (
<span className={labelsClass}>
{offLabel && <span className={offClass}>{offLabel}</span>}
{onLabel && <span className={onClass}>{onLabel}</span>}
</span>
)}
<ErrorDescription {...props} />
</div>
)
}

interface FormFieldRadioProps extends FormFieldRadio {
onChange?: PrivateOnChange
}

export const FormFieldRadioComp: FC<FormFieldRadioProps> = (props) => {
const { name, required, locked, options, initial, onChange, inline } = props
const groupId = inputId(props)
// hooks must be called unconditionally; precompute every class name before render.
const containerClass = useClassName(props)
const radioGroupClass = useClassName(props, { el: 'radio-group' })
const radioGroupInlineClass = useClassName(props, { el: 'radio-group-inline' })
const radioClass = useClassName(props, { el: 'radio' })
const inputClass = useClassName(props, { el: 'input' })
const radioLabelClass = useClassName(props, { el: 'radio-label' })

// Flatten any select groups so we can render a flat list of <input type="radio"> rows.
// We don't currently render group separators because the radio control isn't a great
// place to display section headings — the maintainer can add that later if needed.
const flatOptions: SelectOption[] = []
for (const opt of options) {
if ('options' in opt) {
flatOptions.push(...opt.options)
} else {
flatOptions.push(opt)
}
}

return (
<div className={containerClass} role="radiogroup" aria-labelledby={`${groupId}-label`}>
<Label {...props} />
<div className={inline ? radioGroupInlineClass : radioGroupClass}>
{flatOptions.map((opt, i) => {
const optionId = `${groupId}-${i}`
return (
<div key={opt.value} className={radioClass}>
<input
type="radio"
className={inputClass}
id={optionId}
name={name}
value={opt.value}
defaultChecked={initial === opt.value}
required={required && i === 0}
disabled={locked}
aria-describedby={descId(props)}
onChange={onChange}
/>
<label htmlFor={optionId} className={radioLabelClass}>
{opt.label}
</label>
</div>
)
})}
</div>
<ErrorDescription {...props} />
</div>
)
}

interface FormFieldFileProps extends FormFieldFile {
onChange?: PrivateOnChange
}
Expand Down Expand Up @@ -329,6 +431,8 @@ export type FormFieldProps =
| FormFieldInputProps
| FormFieldTextareaProps
| FormFieldBooleanProps
| FormFieldToggleProps
| FormFieldRadioProps
| FormFieldFileProps
| FormFieldSelectProps
| FormFieldSelectSearchProps
Expand Down
8 changes: 8 additions & 0 deletions src/npm-fastui/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
FormFieldInputComp,
FormFieldTextareaComp,
FormFieldBooleanComp,
FormFieldToggleComp,
FormFieldRadioComp,
FormFieldSelectComp,
FormFieldSelectSearchComp,
FormFieldFileComp,
Expand Down Expand Up @@ -55,6 +57,8 @@ export {
FormComp,
FormFieldInputComp,
FormFieldBooleanComp,
FormFieldToggleComp,
FormFieldRadioComp,
FormFieldSelectComp,
FormFieldSelectSearchComp,
FormFieldFileComp,
Expand Down Expand Up @@ -134,6 +138,10 @@ export const AnyComp: FC<FastProps> = (props) => {
return <FormFieldTextareaComp {...props} />
case 'FormFieldBoolean':
return <FormFieldBooleanComp {...props} />
case 'FormFieldToggle':
return <FormFieldToggleComp {...props} />
case 'FormFieldRadio':
return <FormFieldRadioComp {...props} />
case 'FormFieldFile':
return <FormFieldFileComp {...props} />
case 'FormFieldSelect':
Expand Down
50 changes: 50 additions & 0 deletions src/npm-fastui/src/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type FastProps =
| FormFieldInput
| FormFieldTextarea
| FormFieldBoolean
| FormFieldToggle
| FormFieldRadio
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
Expand Down Expand Up @@ -559,6 +561,8 @@ export interface Form {
| FormFieldInput
| FormFieldTextarea
| FormFieldBoolean
| FormFieldToggle
| FormFieldRadio
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
Expand Down Expand Up @@ -641,6 +645,50 @@ export interface FormFieldBoolean {
mode?: 'checkbox' | 'switch'
type: 'FormFieldBoolean'
}
/**
* Form field for an on/off toggle (switch) input.
*/
export interface FormFieldToggle {
name: string
title: string[] | string
required?: boolean
error?: string
locked?: boolean
description?: string
displayMode?: 'default' | 'inline'
className?:
| string
| ClassName[]
| {
[k: string]: boolean
}
initial?: boolean
onLabel?: string
offLabel?: string
type: 'FormFieldToggle'
}
/**
* Form field for a radio button group.
*/
export interface FormFieldRadio {
name: string
title: string[] | string
required?: boolean
error?: string
locked?: boolean
description?: string
displayMode?: 'default' | 'inline'
className?:
| string
| ClassName[]
| {
[k: string]: boolean
}
options: SelectOptions
initial?: string
inline?: boolean
type: 'FormFieldRadio'
}
/**
* Form field for file input.
*/
Expand Down Expand Up @@ -748,6 +796,8 @@ export interface ModelForm {
| FormFieldInput
| FormFieldTextarea
| FormFieldBoolean
| FormFieldToggle
| FormFieldRadio
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
Expand Down
Loading
Loading