omnidiag / frontend /src /components /SchemaFieldFactory.jsx
yahyoha's picture
refactor(frontend): update components, hooks, utils for v2 platform
93a6b8c
Raw
History Blame Contribute Delete
10.7 kB
/**
* SchemaFieldFactory.jsx
* =======================
* Renders a single form field based on its FieldMetadata.
* Maps component types to actual React input elements.
*
* Supported components:
* segmented → Segmented radio-group (Male=1 / Female=0)
* toggle → Styled checkbox/switch (binary 0/1)
* select → Native <select> dropdown (enum)
* slider → Range slider with live value badge (small-range integer)
* number → Twin-bound slider + <input type="number"> with min/max/step
* text → <input type="text"> (fallback)
*/
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>
);
}
/**
* Toggle/Switch — binary 0/1 field rendered as a styled checkbox.
*/
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>
);
}
/**
* Segmented Button Group — premium radio-group for distinct binary states (e.g., Sex: Male/Female).
* Active state uses bg-blue-600 with shadow for clear visual distinction.
* Smooth 200ms transitions on all interactive elements.
*/
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>
);
}
/**
* Dropdown — renders a native <select> for enum string/number fields.
*/
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>
);
}
/**
* Slider — range input for small-range integer fields.
* Renders the current value as a prominent live badge next to the label.
*/
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>
);
}
/**
* Number Input — for float or wide-range integer fields.
* Shows a twin-bound slider alongside the number input when min/max bounds exist.
* Both controls share the exact same field.value — moving the slider updates the
* number box instantly, and typing in the box moves the slider instantly.
* When slider is visible, a live value badge is shown next to the label.
*/
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);
// Show a slider when we have finite numeric bounds and the range isn't excessive
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>
);
}
/**
* Text Input — fallback for string fields without enum.
*/
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>
);
}
// ── Component Registry ──
const COMPONENT_MAP = {
segmented: SegmentedField,
toggle: ToggleField,
select: SelectField,
slider: SliderField,
number: NumberField,
text: TextField,
};
/**
* SchemaFieldFactory — renders a single dynamic form field.
*
* @param {{
* meta: import('../utils/schemaFieldParser').FieldMetadata,
* control: import('react-hook-form').Control,
* errors: object
* }} props
*/
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}
/>
);
}