diff --git a/src/ui/components/FieldControl.jsx b/src/ui/components/FieldControl.jsx
new file mode 100644
index 0000000..cf51748
--- /dev/null
+++ b/src/ui/components/FieldControl.jsx
@@ -0,0 +1,929 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Adapt,
+ Button,
+ Input,
+ Paragraph,
+ Select,
+ Separator,
+ Sheet,
+ Switch,
+ Text,
+ TextArea,
+ XStack,
+ YStack
+} from 'tamagui';
+import { getIcon } from './IconMapper.jsx';
+import { ColorPickerPopup, DateTimePickerPopup } from './FieldControlPickers.jsx';
+import { getTypographyRoleProps } from '../styles/index.js';
+import { pickFile } from '../../platform/compat.js';
+
+const ChevronDown = getIcon('chevron-down');
+const ChevronUp = getIcon('chevron-up');
+const CheckIcon = getIcon('check');
+
+function helperTextColor(error) {
+ return error ? '$danger' : '$textMuted';
+}
+
+function fieldChrome(orientation, error, border) {
+ const embedded = orientation === 'embedded';
+ const borderless = border === 0;
+ return {
+ position: embedded ? 'relative' : 'static',
+ borderWidth: embedded && !borderless ? 1 : 0,
+ borderColor: error ? '$danger' : '$lineSubtle',
+ borderRadius: embedded ? '$4' : '$0',
+ backgroundColor: embedded && !borderless ? '$bgPanel' : 'transparent',
+ paddingTop: embedded && !borderless ? '$3.5' : '$0',
+ paddingRight: embedded && !borderless ? '$3' : '$0',
+ paddingBottom: embedded && !borderless ? '$3' : '$0',
+ paddingLeft: embedded && !borderless ? '$3' : '$0',
+ gap: embedded ? '$2' : '$1.5'
+ };
+}
+
+function inputChrome(orientation, error, border) {
+ const embedded = orientation === 'embedded';
+ const borderless = border === 0;
+ return {
+ backgroundColor: embedded || borderless ? 'transparent' : '$bgPanel',
+ borderColor: embedded || borderless ? 'transparent' : (error ? '$danger' : '$lineSubtle'),
+ borderWidth: embedded || borderless ? 0 : 1,
+ paddingHorizontal: embedded ? '$0' : undefined,
+ paddingVertical: embedded ? '$0' : undefined,
+ focusStyle: embedded
+ ? { borderColor: 'transparent', outlineWidth: 0 }
+ : { borderColor: error ? '$danger' : '$accent' }
+ };
+}
+
+function renderHelper(error, hint) {
+ if (!error && !hint) {
+ return null;
+ }
+ return (
+
+ {error || hint}
+
+ );
+}
+
+function parseAreaPlacement(value, fallback = 'inside') {
+ const raw = String(value || fallback)
+ .split(',')
+ .map((part) => part.trim())
+ .filter(Boolean);
+ const hints = new Set(raw.length ? raw : [fallback]);
+ return {
+ mode: hints.has('outside') ? 'outside' : 'inside',
+ hints,
+ };
+}
+
+function renderActionNode(action, context, fallbackColor = '$textMuted') {
+ if (!action) {
+ return null;
+ }
+
+ if (React.isValidElement(action)) {
+ return action;
+ }
+
+ if (typeof action === 'function') {
+ const rendered = action(context);
+ if (React.isValidElement(rendered)) {
+ return rendered;
+ }
+ const ActionComponent = action;
+ return ;
+ }
+
+ if (typeof action === 'string') {
+ const IconComponent = getIcon(action);
+ return IconComponent ? : null;
+ }
+
+ if (action && typeof action === 'object' && action.icon) {
+ const IconComponent = typeof action.icon === 'string' ? getIcon(action.icon) : action.icon;
+ return IconComponent ? : null;
+ }
+
+ return null;
+}
+
+function chipProps(selected, disabled) {
+ return selected
+ ? {
+ backgroundColor: '$accentBg',
+ color: '$accent',
+ borderColor: '$accent',
+ borderWidth: 1,
+ hoverStyle: { backgroundColor: '$accentBgHover' },
+ pressStyle: { backgroundColor: '$accentBgHover' },
+ disabled,
+ }
+ : {
+ backgroundColor: '$bgPanel',
+ color: '$textPrimary',
+ borderColor: '$lineSubtle',
+ borderWidth: 1,
+ hoverStyle: { backgroundColor: '$bgPage', borderColor: '$lineStrong' },
+ pressStyle: { backgroundColor: '$bgPage' },
+ disabled,
+ };
+}
+
+function DropdownControl({ value, options, onValueChange, placeholder, disabled, error, inputProps = {}, fieldProps = {} }) {
+ return (
+
+ );
+}
+
+function ColorSwatch({ value }) {
+ const normalized = String(value || '').trim();
+ const isHex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized);
+ return (
+
+ );
+}
+
+function EmbeddedLabel({ label, required = false }) {
+ if (!label) return null;
+ return (
+
+
+ {label}{required ? ' *' : ''}
+
+
+ );
+}
+
+function resolveOptionLabel(options, value) {
+ const match = (options || []).find((option) => String(option.value) === String(value ?? ''));
+ return match ? match.label : String(value ?? '');
+}
+
+function StaticTextValue({ value, fieldProps = {}, multiline = false }) {
+ const textValue = value == null || value === '' ? '—' : String(value);
+ return (
+
+ {textValue}
+
+ );
+}
+
+async function handleFilePick(controlId, emitValue, props = {}) {
+ const selection = await pickFile({
+ accept: props.accept || '*',
+ readAs: props.readAs || null,
+ });
+
+ if (!selection?.file) {
+ return;
+ }
+
+ emitValue(selection.file, selection);
+}
+
+export function FieldControl({
+ name,
+ id,
+ label,
+ value,
+ onChange,
+ onValueChange,
+ placeholder = '',
+ hint = '',
+ helperText = '',
+ error = '',
+ orientation = 'vertical',
+ type = 'text',
+ format = '',
+ options = [],
+ rows = 4,
+ cols = -1,
+ readOnly = false,
+ locked = false,
+ disabled = false,
+ active = true,
+ visible = true,
+ border,
+ render = null,
+ fieldProps = {},
+ required = false,
+ children = null,
+ accept,
+ readAs,
+ leftAction = null,
+ rightAction = null,
+ leftAreaPlacement = 'inside',
+ rightAreaPlacement = 'inside',
+ leftActionOnPress = null,
+ rightActionOnPress = null,
+ leftIcon = null,
+ rightIcon = null,
+ leftIconPlacement = 'inside',
+ rightIconPlacement = 'inside',
+ leftIconOnPress = null,
+ rightIconOnPress = null
+}) {
+ if (!visible) {
+ return null;
+ }
+
+ if (type === 'select') {
+ type = 'dropdown';
+ }
+
+ const controlId = id || name || label || 'field';
+ const chrome = fieldChrome(orientation, error, border);
+ const commonInputProps = inputChrome(orientation, error, border);
+ const normalizedValue = value ?? '';
+ const embedded = orientation === 'embedded';
+ const borderless = border === 0;
+ const staticMode = readOnly || locked || !active;
+ const [showPassword, setShowPassword] = useState(false);
+ const [autocompleteOpen, setAutocompleteOpen] = useState(false);
+ const [colorPickerOpen, setColorPickerOpen] = useState(false);
+ const [dateTimePickerOpen, setDateTimePickerOpen] = useState(false);
+ const isDateLike = type === 'date' || type === 'time' || type === 'date-time';
+ const effectiveLeftAction = leftAction || leftIcon;
+ const effectiveRightAction = type === 'password'
+ ? (rightAction || rightIcon || (showPassword ? 'visibility-off' : 'visibility'))
+ : (isDateLike
+ ? (rightAction || rightIcon || (type === 'time' ? 'clock' : 'calendar'))
+ : (rightAction || rightIcon));
+ const effectiveLeftActionOnPress = leftActionOnPress || leftIconOnPress;
+ const effectiveRightActionOnPress = type === 'password'
+ ? (rightActionOnPress || rightIconOnPress || (() => setShowPassword((state) => !state)))
+ : (isDateLike
+ ? (rightActionOnPress || rightIconOnPress || (() => setDateTimePickerOpen((open) => !open)))
+ : (rightActionOnPress || rightIconOnPress));
+ const normalizedLeftPlacement = parseAreaPlacement(leftAreaPlacement || leftIconPlacement, 'inside');
+ const normalizedRightPlacement = parseAreaPlacement(rightAreaPlacement || rightIconPlacement, 'inside');
+ const hasInsideLeftAction = Boolean(effectiveLeftAction) && normalizedLeftPlacement.mode === 'inside';
+ const hasInsideRightAction = Boolean(effectiveRightAction) && normalizedRightPlacement.mode === 'inside';
+ const emitValue = (nextValue, eventLike = undefined) => {
+ if (typeof onChange === 'function') {
+ onChange(controlId, nextValue, eventLike);
+ return;
+ }
+ if (typeof onValueChange === 'function') {
+ onValueChange(nextValue, eventLike);
+ }
+ };
+ const decoratedInputProps = {
+ ...commonInputProps,
+ paddingLeft: hasInsideLeftAction ? '$8' : commonInputProps.paddingLeft,
+ paddingRight: hasInsideRightAction ? '$8' : commonInputProps.paddingRight,
+ };
+
+ const renderActionArea = (action, side, placement, onPress) => {
+ const actionContext = {
+ id: controlId,
+ name: controlId,
+ side,
+ placement: placement.mode,
+ placementHints: placement.hints,
+ value: normalizedValue,
+ disabled,
+ readOnly,
+ locked,
+ active,
+ error,
+ orientation,
+ onPress,
+ showPassword,
+ setShowPassword,
+ };
+ const actionNode = renderActionNode(action, actionContext);
+ if (!actionNode) {
+ return null;
+ }
+
+ const button = typeof onPress === 'function' ? (
+
+ ) : actionNode;
+
+ if (placement.mode === 'outside') {
+ return button;
+ }
+
+ return (
+
+ {button}
+
+ );
+ };
+
+ const wrapControlWithIcons = (node, multiline = false) => {
+ const leftOutside = effectiveLeftAction && normalizedLeftPlacement.mode === 'outside'
+ ? renderActionArea(effectiveLeftAction, 'left', normalizedLeftPlacement, effectiveLeftActionOnPress)
+ : null;
+ const rightOutside = effectiveRightAction && normalizedRightPlacement.mode === 'outside'
+ ? renderActionArea(effectiveRightAction, 'right', normalizedRightPlacement, effectiveRightActionOnPress)
+ : null;
+ const leftInside = effectiveLeftAction && normalizedLeftPlacement.mode === 'inside'
+ ? renderActionArea(effectiveLeftAction, 'left', normalizedLeftPlacement, effectiveLeftActionOnPress)
+ : null;
+ const rightInside = effectiveRightAction && normalizedRightPlacement.mode === 'inside'
+ ? renderActionArea(effectiveRightAction, 'right', normalizedRightPlacement, effectiveRightActionOnPress)
+ : null;
+
+ const inner = leftInside || rightInside ? (
+
+ {node}
+ {leftInside}
+ {rightInside}
+
+ ) : node;
+
+ if (leftOutside || rightOutside) {
+ return (
+
+ {leftOutside}
+ {inner}
+ {rightOutside}
+
+ );
+ }
+
+ return inner;
+ };
+
+ if (type === 'divider') {
+ return ;
+ }
+
+ if (type === 'title') {
+ return (
+
+
+ {label}
+
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+ }
+
+ const control = useMemo(() => {
+ if (type === 'custom') {
+ return children || null;
+ }
+
+ if (typeof render === 'function') {
+ return render({
+ id: controlId,
+ name: controlId,
+ value: normalizedValue,
+ placeholder,
+ disabled,
+ readOnly,
+ locked,
+ active,
+ error,
+ orientation,
+ inputProps: commonInputProps,
+ onChange: emitValue,
+ onValueChange: emitValue
+ });
+ }
+
+ if (type === 'dropdown') {
+ if (staticMode) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ if (type === 'autocomplete') {
+ if (staticMode) {
+ return ;
+ }
+ const filteredOptions = String(normalizedValue || '').trim()
+ ? options.filter((option) => option.label.toLowerCase().includes(String(normalizedValue).toLowerCase()))
+ : options;
+ return (
+
+ {wrapControlWithIcons(
+ setAutocompleteOpen(true)}
+ onBlur={() => {
+ setTimeout(() => {
+ setAutocompleteOpen(false);
+ }, 120);
+ }}
+ onChangeText={emitValue}
+ />
+ )}
+ {autocompleteOpen && filteredOptions.length ? (
+
+ {filteredOptions.slice(0, 8).map((option) => (
+
+ ))}
+
+ ) : null}
+
+ );
+ }
+
+ if (type === 'number') {
+ if (staticMode) {
+ return ;
+ }
+ return (
+ emitValue(nextValue === '' ? '' : Number(nextValue))}
+ />
+ );
+ }
+
+ if (type === 'email') {
+ if (staticMode) {
+ return ;
+ }
+ return (
+
+ );
+ }
+
+ if (type === 'password') {
+ if (staticMode) {
+ return ;
+ }
+ return wrapControlWithIcons(
+
+ );
+ }
+
+ if (type === 'date' || type === 'time' || type === 'date-time') {
+ if (staticMode) {
+ return ;
+ }
+ const defaultPlaceholder = type === 'time'
+ ? 'HH:MM'
+ : (type === 'date-time' ? 'YYYY-MM-DDTHH:MM' : 'YYYY-MM-DD');
+ return (
+
+ {wrapControlWithIcons(
+
+ )}
+ {dateTimePickerOpen ? (
+ emitValue(nextValue)}
+ onClose={() => setDateTimePickerOpen(false)}
+ />
+ ) : null}
+
+ );
+ }
+
+ if (type === 'color') {
+ if (staticMode) {
+ return (
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ {colorPickerOpen ? (
+ emitValue(String(nextValue).toUpperCase())}
+ onClose={() => setColorPickerOpen(false)}
+ />
+ ) : null}
+
+ );
+ }
+
+ if (type === 'checkbox') {
+ return (
+
+ emitValue(next)}
+ backgroundColor={normalizedValue ? '$accent' : '$lineStrong'}
+ borderColor={error ? '$danger' : 'transparent'}
+ borderWidth={error ? 1 : 0}
+ >
+
+
+
+ {normalizedValue ? 'Enabled' : 'Disabled'}
+
+
+ );
+ }
+
+ if (type === 'radio') {
+ const selectedValue = String(normalizedValue ?? '');
+ return (
+
+ {options.map((option) => {
+ const selected = selectedValue === String(option.value);
+ return (
+
+ );
+ })}
+
+ );
+ }
+
+ if (type === 'multiselect') {
+ const selectedValues = Array.isArray(normalizedValue) ? normalizedValue.map(String) : [];
+ return (
+
+ {options.map((option) => {
+ const selected = selectedValues.includes(String(option.value));
+ return (
+
+ );
+ })}
+
+ );
+ }
+
+ if (type === 'file') {
+ return (
+
+
+
+ {normalizedValue?.name || 'No file selected'}
+
+
+ );
+ }
+
+ if (type === 'textarea') {
+ if (staticMode) {
+ return ;
+ }
+ return (
+