| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useController } from 'react-hook-form'; |
| import MedicalTooltip from './MedicalTooltip'; |
|
|
| const SLIDER_CLASS = `flex-1 h-2 rounded-full appearance-none cursor-pointer |
| bg-gray-200 accent-blue-600 |
| [&::-webkit-slider-thumb]:appearance-none |
| [&::-webkit-slider-thumb]:w-4 |
| [&::-webkit-slider-thumb]:h-4 |
| [&::-webkit-slider-thumb]:rounded-full |
| [&::-webkit-slider-thumb]:bg-blue-600 |
| [&::-webkit-slider-thumb]:shadow-md`; |
|
|
| function ValueBadge({ value }) { |
| return ( |
| <span className="inline-flex items-center justify-center min-w-[2rem] h-5 px-2 |
| text-xs font-bold text-blue-800 bg-blue-100 |
| border border-blue-200 rounded-md shadow-sm"> |
| {value} |
| </span> |
| ); |
| } |
|
|
| |
| |
| |
| function ToggleField({ field, meta, error }) { |
| const isOn = field.value === 1 || field.value === true; |
|
|
| return ( |
| <div className="flex items-center justify-between"> |
| <label |
| htmlFor={meta.name} |
| className="text-xs font-medium text-gray-700 cursor-pointer select-none" |
| > |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| </label> |
| <button |
| id={meta.name} |
| type="button" |
| role="switch" |
| aria-checked={isOn} |
| onClick={() => field.onChange(isOn ? 0 : 1)} |
| className={` |
| relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 |
| ${isOn ? 'bg-primary-500' : 'bg-gray-200'} |
| focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-1 |
| `} |
| > |
| <span |
| className={` |
| inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform duration-200 |
| ${isOn ? 'translate-x-6' : 'translate-x-1'} |
| `} |
| /> |
| </button> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| function SegmentedField({ field, meta, error }) { |
| const val = field.value ?? 0; |
|
|
| return ( |
| <div> |
| <label className="block text-xs font-medium text-gray-600 mb-2"> |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| </label> |
| <div className="flex rounded-lg border border-gray-300 overflow-hidden shadow-sm" role="radiogroup"> |
| <button |
| type="button" |
| role="radio" |
| aria-checked={val === 1} |
| onClick={() => field.onChange(1)} |
| className={` |
| flex-1 px-4 py-2.5 text-sm font-semibold transition-all duration-200 |
| ${val === 1 |
| ? 'bg-blue-600 text-white shadow-md' |
| : 'bg-white text-gray-700 hover:bg-gray-100' |
| } |
| `} |
| > |
| Male |
| </button> |
| <div className="w-px bg-gray-200" /> |
| <button |
| type="button" |
| role="radio" |
| aria-checked={val === 0} |
| onClick={() => field.onChange(0)} |
| className={` |
| flex-1 px-4 py-2.5 text-sm font-semibold transition-all duration-200 |
| ${val === 0 |
| ? 'bg-blue-600 text-white shadow-md' |
| : 'bg-white text-gray-700 hover:bg-gray-100' |
| } |
| `} |
| > |
| Female |
| </button> |
| </div> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function SelectField({ field, meta, error }) { |
| const options = meta.validation.enum || []; |
|
|
| return ( |
| <div> |
| <label htmlFor={meta.name} className="block text-xs font-medium text-gray-600 mb-1"> |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| </label> |
| <select |
| id={meta.name} |
| {...field} |
| className={`select-field ${error ? 'border-red-400 ring-1 ring-red-400' : ''}`} |
| onChange={(e) => field.onChange(e.target.value)} |
| > |
| {options.map((opt) => ( |
| <option key={String(opt)} value={opt}> |
| {String(opt)} |
| </option> |
| ))} |
| </select> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| function SliderField({ field, meta, error }) { |
| const min = meta.validation.minimum ?? 0; |
| const max = meta.validation.maximum ?? 10; |
| const val = field.value ?? min; |
|
|
| return ( |
| <div> |
| <label htmlFor={meta.name} className="block text-xs font-medium text-gray-600 mb-1"> |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| {' '} |
| <ValueBadge value={val} /> |
| </label> |
| <div className="flex items-center gap-3"> |
| <span className="text-xs text-gray-400 w-6 text-right">{min}</span> |
| <input |
| id={meta.name} |
| type="range" |
| min={min} |
| max={max} |
| step={1} |
| value={val} |
| onChange={(e) => field.onChange(parseInt(e.target.value, 10))} |
| className={SLIDER_CLASS} |
| /> |
| <span className="text-xs font-mono font-semibold text-gray-700 w-6">{val}</span> |
| </div> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function NumberField({ field, meta, error }) { |
| const min = meta.validation.minimum; |
| const max = meta.validation.maximum; |
| const step = meta.validation.step ?? (meta.type === 'number' ? 0.1 : 1); |
|
|
| |
| const showSlider = min !== undefined && max !== undefined && (max - min) <= 500; |
| const currentVal = field.value ?? min ?? 0; |
|
|
| return ( |
| <div> |
| <label htmlFor={meta.name} className="block text-xs font-medium text-gray-600 mb-1"> |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| {showSlider && ( |
| <> |
| {' '} |
| <ValueBadge value={typeof currentVal === 'number' ? currentVal.toFixed(meta.type === 'number' ? 1 : 0) : currentVal} /> |
| </> |
| )} |
| </label> |
| <div className="flex items-center gap-3"> |
| {showSlider && ( |
| <input |
| type="range" |
| min={min} |
| max={max} |
| step={step} |
| value={currentVal} |
| onChange={(e) => field.onChange(parseFloat(e.target.value))} |
| className={SLIDER_CLASS} |
| /> |
| )} |
| <input |
| id={meta.name} |
| type="number" |
| min={min} |
| max={max} |
| step={step} |
| value={currentVal} |
| onChange={(e) => { |
| const raw = e.target.value; |
| if (raw === '') { |
| field.onChange(''); |
| return; |
| } |
| const parsed = Number(raw); |
| if (isNaN(parsed)) return; |
| // Clamp within bounds to match slider behaviour |
| let clamped = parsed; |
| if (min !== undefined) clamped = Math.max(min, clamped); |
| if (max !== undefined) clamped = Math.min(max, clamped); |
| field.onChange(clamped); |
| }} |
| className={`input-field w-24 ${error ? 'border-red-400 ring-1 ring-red-400' : ''}`} |
| /> |
| </div> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| {(min !== undefined || max !== undefined) && ( |
| <p className="text-[10px] text-gray-400 mt-0.5"> |
| {min !== undefined && `Min: ${min}`} |
| {min !== undefined && max !== undefined && ' | '} |
| {max !== undefined && `Max: ${max}`} |
| </p> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| function TextField({ field, meta, error }) { |
| return ( |
| <div> |
| <label htmlFor={meta.name} className="block text-xs font-medium text-gray-600 mb-1"> |
| <MedicalTooltip term={meta.title}>{meta.title}</MedicalTooltip> |
| </label> |
| <input |
| id={meta.name} |
| type="text" |
| {...field} |
| className={`input-field ${error ? 'border-red-400 ring-1 ring-red-400' : ''}`} |
| /> |
| {error && <p className="text-xs text-red-500 mt-0.5">{error}</p>} |
| </div> |
| ); |
| } |
|
|
| |
| const COMPONENT_MAP = { |
| segmented: SegmentedField, |
| toggle: ToggleField, |
| select: SelectField, |
| slider: SliderField, |
| number: NumberField, |
| text: TextField, |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export default function SchemaFieldFactory({ meta, control, errors }) { |
| const { |
| field, |
| fieldState: { error }, |
| } = useController({ |
| name: meta.name, |
| control, |
| defaultValue: meta.default ?? (meta.type === 'integer' || meta.type === 'number' ? 0 : ''), |
| rules: { |
| required: meta.validation.required ? `${meta.title} is required` : false, |
| min: meta.validation.minimum, |
| max: meta.validation.maximum, |
| }, |
| }); |
|
|
| const FieldComponent = COMPONENT_MAP[meta.component] || TextField; |
| const errMsg = error?.message || errors?.[meta.name]; |
|
|
| return ( |
| <FieldComponent |
| field={field} |
| meta={meta} |
| error={errMsg} |
| /> |
| ); |
| } |
|
|