diff --git a/src/npm-fastui-bootstrap/src/Radio.tsx b/src/npm-fastui-bootstrap/src/Radio.tsx new file mode 100644 index 00000000..d0624db3 --- /dev/null +++ b/src/npm-fastui-bootstrap/src/Radio.tsx @@ -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 = (props) => { + return +} diff --git a/src/npm-fastui-bootstrap/src/Toggle.tsx b/src/npm-fastui-bootstrap/src/Toggle.tsx new file mode 100644 index 00000000..f8fda965 --- /dev/null +++ b/src/npm-fastui-bootstrap/src/Toggle.tsx @@ -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 = (props) => { + return +} diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx index 3341cb6a..7106544b 100644 --- a/src/npm-fastui-bootstrap/src/index.tsx +++ b/src/npm-fastui-bootstrap/src/index.tsx @@ -78,6 +78,8 @@ export const classNameGenerator: ClassNameGenerator = ({ case 'FormFieldInput': case 'FormFieldTextarea': case 'FormFieldBoolean': + case 'FormFieldToggle': + case 'FormFieldRadio': case 'FormFieldSelect': case 'FormFieldSelectSearch': case 'FormFieldFile': @@ -85,9 +87,9 @@ export const classNameGenerator: ClassNameGenerator = ({ 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' @@ -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': diff --git a/src/npm-fastui/src/components/FormField.tsx b/src/npm-fastui/src/components/FormField.tsx index 23614570..c0257492 100644 --- a/src/npm-fastui/src/components/FormField.tsx +++ b/src/npm-fastui/src/components/FormField.tsx @@ -6,6 +6,8 @@ import type { FormFieldInput, FormFieldTextarea, FormFieldBoolean, + FormFieldToggle, + FormFieldRadio, FormFieldFile, FormFieldSelect, FormFieldSelectSearch, @@ -100,6 +102,106 @@ export const FormFieldBooleanComp: FC = (props) => { ) } +interface FormFieldToggleProps extends FormFieldToggle { + onChange?: PrivateOnChange +} + +export const FormFieldToggleComp: FC = (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 ( +
+
+ ) +} + +interface FormFieldRadioProps extends FormFieldRadio { + onChange?: PrivateOnChange +} + +export const FormFieldRadioComp: FC = (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 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 ( +
+
+ ) +} + interface FormFieldFileProps extends FormFieldFile { onChange?: PrivateOnChange } @@ -329,6 +431,8 @@ export type FormFieldProps = | FormFieldInputProps | FormFieldTextareaProps | FormFieldBooleanProps + | FormFieldToggleProps + | FormFieldRadioProps | FormFieldFileProps | FormFieldSelectProps | FormFieldSelectSearchProps diff --git a/src/npm-fastui/src/components/index.tsx b/src/npm-fastui/src/components/index.tsx index d82b5754..6f73ca5e 100644 --- a/src/npm-fastui/src/components/index.tsx +++ b/src/npm-fastui/src/components/index.tsx @@ -18,6 +18,8 @@ import { FormFieldInputComp, FormFieldTextareaComp, FormFieldBooleanComp, + FormFieldToggleComp, + FormFieldRadioComp, FormFieldSelectComp, FormFieldSelectSearchComp, FormFieldFileComp, @@ -55,6 +57,8 @@ export { FormComp, FormFieldInputComp, FormFieldBooleanComp, + FormFieldToggleComp, + FormFieldRadioComp, FormFieldSelectComp, FormFieldSelectSearchComp, FormFieldFileComp, @@ -134,6 +138,10 @@ export const AnyComp: FC = (props) => { return case 'FormFieldBoolean': return + case 'FormFieldToggle': + return + case 'FormFieldRadio': + return case 'FormFieldFile': return case 'FormFieldSelect': diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index 1df407b7..22b7a162 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -36,6 +36,8 @@ export type FastProps = | FormFieldInput | FormFieldTextarea | FormFieldBoolean + | FormFieldToggle + | FormFieldRadio | FormFieldFile | FormFieldSelect | FormFieldSelectSearch @@ -559,6 +561,8 @@ export interface Form { | FormFieldInput | FormFieldTextarea | FormFieldBoolean + | FormFieldToggle + | FormFieldRadio | FormFieldFile | FormFieldSelect | FormFieldSelectSearch @@ -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. */ @@ -748,6 +796,8 @@ export interface ModelForm { | FormFieldInput | FormFieldTextarea | FormFieldBoolean + | FormFieldToggle + | FormFieldRadio | FormFieldFile | FormFieldSelect | FormFieldSelectSearch diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 52cec32e..8751547b 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -3,6 +3,7 @@ All CamelCase names in the namespace should be components. """ + import typing as _t import pydantic as _p @@ -21,8 +22,10 @@ FormFieldBoolean, FormFieldFile, FormFieldInput, + FormFieldRadio, FormFieldSelect, FormFieldSelectSearch, + FormFieldToggle, ModelForm, ) from .tables import Pagination, Table @@ -66,8 +69,10 @@ 'FormFieldBoolean', 'FormFieldFile', 'FormFieldInput', + 'FormFieldRadio', 'FormFieldSelect', 'FormFieldSelectSearch', + 'FormFieldToggle', ) @@ -396,16 +401,19 @@ class Image(BaseModel, extra='forbid'): height: str | int | None = None """Optional height used to display the image.""" - referrer_policy: _t.Literal[ - 'no-referrer', - 'no-referrer-when-downgrade', - 'origin', - 'origin-when-cross-origin', - 'same-origin', - 'strict-origin', - 'strict-origin-when-cross-origin', - 'unsafe-url', - ] | None = None + referrer_policy: ( + _t.Literal[ + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url', + ] + | None + ) = None """Optional referrer policy for the image. Specifies what information to send when fetching the image. For more info, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy.""" @@ -550,17 +558,20 @@ class Toast(BaseModel, defer_build=True, extra='forbid'): """List of components to render in the toast body.""" # TODO: change these before the release (top left, center, end, etc). Can be done with the toast bug fix. - position: _t.Literal[ - 'top-start', - 'top-center', - 'top-end', - 'middle-start', - 'middle-center', - 'middle-end', - 'bottom-start', - 'bottom-center', - 'bottom-end', - ] | None = None + position: ( + _t.Literal[ + 'top-start', + 'top-center', + 'top-end', + 'middle-start', + 'middle-center', + 'middle-end', + 'bottom-start', + 'bottom-center', + 'bottom-end', + ] + | None + ) = None """Optional position of the toast.""" open_trigger: events.PageEvent | None = None diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index 18926d6b..98e1f1c7 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -97,6 +97,48 @@ class FormFieldBoolean(BaseFormField): """The type of the component. Always 'FormFieldBoolean'.""" +class FormFieldToggle(BaseFormField): + """Form field for an on/off toggle (switch) input. + + `FormFieldToggle` is a thin wrapper around the boolean form field that always + renders as a switch. It is provided so backends and templates that want a + dedicated toggle component do not need to set `mode='switch'` on every field. + """ + + initial: bool | None = None + """Initial value for the field.""" + + on_label: str | None = None + """Optional label to show next to the toggle when it is on.""" + + off_label: str | None = None + """Optional label to show next to the toggle when it is off.""" + + type: _t.Literal['FormFieldToggle'] = 'FormFieldToggle' + """The type of the component. Always 'FormFieldToggle'.""" + + +class FormFieldRadio(BaseFormField): + """Form field for a radio button group. + + Renders as a list of mutually-exclusive radio buttons rather than a `