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 ( +