Files
bface/src/ui/components/FieldControl.jsx
2026-05-31 12:30:02 -05:00

930 lines
28 KiB
JavaScript

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 ? '$radiusMd' : '$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 (
<Paragraph color={helperTextColor(error)} fontSize="$3">
{error || hint}
</Paragraph>
);
}
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 <ActionComponent size="sm" color={fallbackColor} {...context} />;
}
if (typeof action === 'string') {
const IconComponent = getIcon(action);
return IconComponent ? <IconComponent size="sm" color={fallbackColor} /> : null;
}
if (action && typeof action === 'object' && action.icon) {
const IconComponent = typeof action.icon === 'string' ? getIcon(action.icon) : action.icon;
return IconComponent ? <IconComponent size="sm" color={action.color || fallbackColor} weight={action.weight} /> : 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 (
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
<Select.Trigger
{...inputProps}
{...fieldProps}
iconAfter={ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : undefined}
backgroundColor={inputProps.backgroundColor ?? '$bgPanel'}
borderColor={inputProps.borderColor ?? (error ? '$danger' : '$lineSubtle')}
borderWidth={inputProps.borderWidth ?? 1}
focusStyle={inputProps.focusStyle ?? { borderColor: error ? '$danger' : '$accent' }}
>
<Select.Value placeholder={placeholder || 'Select'} />
</Select.Trigger>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
<Sheet.Frame backgroundColor="$bgPanel">
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay backgroundColor="$scrim" />
</Sheet>
</Adapt>
<Select.Content zIndex={200000}>
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
{ChevronUp ? <ChevronUp size="sm" color="$textSecondary" /> : null}
</YStack>
</Select.ScrollUpButton>
<Select.Viewport minWidth={220}>
<Select.Group>
{options.map((option, index) => (
<Select.Item key={String(option.value)} index={index} value={String(option.value)}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
{ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : null}
</YStack>
</Select.ScrollDownButton>
</Select.Content>
</Select>
);
}
function ColorSwatch({ value }) {
const normalized = String(value || '').trim();
const isHex = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized);
return (
<YStack
width={28}
height={28}
borderRadius="$radiusSm"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor={isHex ? normalized : '$bgPanelElev'}
/>
);
}
function EmbeddedLabel({ label, required = false }) {
if (!label) return null;
return (
<YStack
position="absolute"
top={-9}
left="$3"
zIndex={2}
paddingHorizontal="$1.5"
backgroundColor="$bgPanel"
pointerEvents="none"
>
<Text {...getTypographyRoleProps('fieldLabel', { fontSize: '$2', color: '$textSecondary' })}>
{label}{required ? ' *' : ''}
</Text>
</YStack>
);
}
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 (
<Text color="$textPrimary" whiteSpace={multiline ? 'pre-wrap' : undefined} {...fieldProps}>
{textValue}
</Text>
);
}
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' ? (
<Button
chromeless
size="$2"
paddingHorizontal="$1.5"
paddingVertical="$1"
backgroundColor="transparent"
color="$textMuted"
hoverStyle={{ backgroundColor: '$accentBg', color: '$textSecondary' }}
pressStyle={{ backgroundColor: '$accentBgHover', color: '$textPrimary' }}
disabled={disabled}
onPress={onPress}
>
{actionNode}
</Button>
) : actionNode;
if (placement.mode === 'outside') {
return button;
}
return (
<YStack
position="absolute"
top={0}
bottom={0}
justifyContent="center"
{...(side === 'left' ? { left: '$1.5' } : { right: '$1.5' })}
>
{button}
</YStack>
);
};
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 ? (
<YStack position="relative" flex={1} minWidth={0}>
{node}
{leftInside}
{rightInside}
</YStack>
) : node;
if (leftOutside || rightOutside) {
return (
<XStack gap="$2" alignItems={multiline ? 'flex-start' : 'center'}>
{leftOutside}
{inner}
{rightOutside}
</XStack>
);
}
return inner;
};
if (type === 'divider') {
return <Separator borderColor="$lineSubtle" />;
}
if (type === 'title') {
return (
<YStack gap="$1">
<Text {...getTypographyRoleProps('pageTitle', { color: '$textPrimary' })}>
{label}
</Text>
{hint ? (
<Paragraph color="$textMuted">
{hint}
</Paragraph>
) : null}
</YStack>
);
}
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 (
<StaticTextValue
value={normalizedValue == null || normalizedValue === '' ? '—' : resolveOptionLabel(options, normalizedValue)}
fieldProps={fieldProps}
/>
);
}
return (
<DropdownControl
value={String(normalizedValue)}
options={options}
onValueChange={emitValue}
placeholder={placeholder}
disabled={disabled}
error={error}
inputProps={decoratedInputProps}
fieldProps={fieldProps}
/>
);
}
if (type === 'autocomplete') {
if (staticMode) {
return <StaticTextValue value={normalizedValue} fieldProps={fieldProps} />;
}
const filteredOptions = String(normalizedValue || '').trim()
? options.filter((option) => option.label.toLowerCase().includes(String(normalizedValue).toLowerCase()))
: options;
return (
<YStack position="relative" minWidth={0}>
{wrapControlWithIcons(
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
required={required}
onFocus={() => setAutocompleteOpen(true)}
onBlur={() => {
setTimeout(() => {
setAutocompleteOpen(false);
}, 120);
}}
onChangeText={emitValue}
/>
)}
{autocompleteOpen && filteredOptions.length ? (
<YStack
position="absolute"
top="100%"
left={0}
right={0}
zIndex={30}
marginTop="$1.5"
padding="$1.5"
gap="$1"
backgroundColor="$bgPanel"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$radiusMd"
elevation="$2"
shadowColor="$shadowColor"
>
{filteredOptions.slice(0, 8).map((option) => (
<Button
key={String(option.value)}
size="$2"
chromeless
justifyContent="flex-start"
onPress={() => {
emitValue(String(option.value));
setAutocompleteOpen(false);
}}
>
{option.label}
</Button>
))}
</YStack>
) : null}
</YStack>
);
}
if (type === 'number') {
if (staticMode) {
return <StaticTextValue value={normalizedValue} fieldProps={fieldProps} />;
}
return (
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
keyboardType="number-pad"
required={required}
onChangeText={(nextValue) => emitValue(nextValue === '' ? '' : Number(nextValue))}
/>
);
}
if (type === 'email') {
if (staticMode) {
return <StaticTextValue value={normalizedValue} fieldProps={fieldProps} />;
}
return (
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
autoCapitalize="none"
required={required}
onChangeText={emitValue}
/>
);
}
if (type === 'password') {
if (staticMode) {
return <StaticTextValue value={showPassword ? normalizedValue : '********'} fieldProps={fieldProps} />;
}
return wrapControlWithIcons(
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
autoCapitalize="none"
required={required}
secureTextEntry={!showPassword}
type={showPassword ? 'text' : 'password'}
onChangeText={emitValue}
/>
);
}
if (type === 'date' || type === 'time' || type === 'date-time') {
if (staticMode) {
return <StaticTextValue value={normalizedValue} fieldProps={fieldProps} />;
}
const defaultPlaceholder = type === 'time'
? 'HH:MM'
: (type === 'date-time' ? 'YYYY-MM-DDTHH:MM' : 'YYYY-MM-DD');
return (
<YStack position="relative" minWidth={0}>
{wrapControlWithIcons(
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder || defaultPlaceholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
required={required}
autoCapitalize="none"
onChangeText={emitValue}
/>
)}
{dateTimePickerOpen ? (
<DateTimePickerPopup
type={type}
value={String(normalizedValue)}
onChange={(nextValue) => emitValue(nextValue)}
onClose={() => setDateTimePickerOpen(false)}
/>
) : null}
</YStack>
);
}
if (type === 'color') {
if (staticMode) {
return (
<YStack position="relative" flex={1} minWidth={0}>
<StaticTextValue value={normalizedValue} fieldProps={{ ...fieldProps, paddingRight: '$9' }} />
<YStack
position="absolute"
top={0}
bottom={0}
right="$1.5"
justifyContent="center"
pointerEvents="none"
>
<ColorSwatch value={normalizedValue} />
</YStack>
</YStack>
);
}
return (
<YStack position="relative" flex={1} minWidth={0}>
<Input
flex={1}
{...decoratedInputProps}
{...fieldProps}
paddingRight="$9"
placeholder={placeholder || '#RRGGBB'}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
autoCapitalize="none"
required={required}
onChangeText={emitValue}
/>
<Button
chromeless
position="absolute"
top={0}
bottom={0}
right="$1"
justifyContent="center"
paddingHorizontal="$1"
backgroundColor="transparent"
hoverStyle={{ backgroundColor: '$accentBg' }}
pressStyle={{ backgroundColor: '$accentBgHover' }}
onPress={() => setColorPickerOpen((open) => !open)}
>
<ColorSwatch value={normalizedValue} />
</Button>
{colorPickerOpen ? (
<ColorPickerPopup
value={String(normalizedValue || '#000000')}
onChange={(nextValue) => emitValue(String(nextValue).toUpperCase())}
onClose={() => setColorPickerOpen(false)}
/>
) : null}
</YStack>
);
}
if (type === 'checkbox') {
return (
<XStack alignItems="center" gap="$3">
<Switch
id={controlId}
size="$3"
checked={Boolean(normalizedValue)}
disabled={disabled || staticMode}
onCheckedChange={(next) => emitValue(next)}
backgroundColor={normalizedValue ? '$accent' : '$lineStrong'}
borderColor={error ? '$danger' : 'transparent'}
borderWidth={error ? 1 : 0}
>
<Switch.Thumb animation="quick" backgroundColor="$bgPanel" />
</Switch>
<Text color="$textSecondary" fontSize="$3">
{normalizedValue ? 'Enabled' : 'Disabled'}
</Text>
</XStack>
);
}
if (type === 'radio') {
const selectedValue = String(normalizedValue ?? '');
return (
<XStack gap="$2" flexWrap="wrap">
{options.map((option) => {
const selected = selectedValue === String(option.value);
return (
<Button
key={String(option.value)}
size="$3"
{...chipProps(selected, disabled || staticMode)}
onPress={() => emitValue(option.value)}
>
{option.label}
</Button>
);
})}
</XStack>
);
}
if (type === 'multiselect') {
const selectedValues = Array.isArray(normalizedValue) ? normalizedValue.map(String) : [];
return (
<XStack gap="$2" flexWrap="wrap">
{options.map((option) => {
const selected = selectedValues.includes(String(option.value));
return (
<Button
key={String(option.value)}
size="$3"
{...chipProps(selected, disabled || staticMode)}
onPress={() => {
const nextValues = selected
? selectedValues.filter((item) => item !== String(option.value))
: [...selectedValues, String(option.value)];
emitValue(nextValues);
}}
>
{option.label}
</Button>
);
})}
</XStack>
);
}
if (type === 'file') {
return (
<XStack alignItems="center" gap="$3" flexWrap="wrap">
<Button
size="$3"
chromeless
backgroundColor="$bgPanel"
borderColor="$lineSubtle"
borderWidth={1}
disabled={disabled || staticMode}
onPress={() => handleFilePick(controlId, emitValue, { accept, readAs })}
>
{normalizedValue?.name ? 'Replace File' : 'Choose File'}
</Button>
<Text color="$textMuted">
{normalizedValue?.name || 'No file selected'}
</Text>
</XStack>
);
}
if (type === 'textarea') {
if (staticMode) {
return <StaticTextValue value={normalizedValue} fieldProps={fieldProps} multiline />;
}
return (
<TextArea
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
minHeight={rows > 0 ? Math.max(88, rows * 24) : 120}
required={required}
onChangeText={emitValue}
/>
);
}
if (staticMode) {
return wrapControlWithIcons(<StaticTextValue value={normalizedValue} fieldProps={fieldProps} />);
}
return wrapControlWithIcons(
<Input
{...decoratedInputProps}
{...fieldProps}
placeholder={placeholder}
value={String(normalizedValue)}
readOnly={readOnly}
disabled={disabled}
required={required}
onChangeText={emitValue}
/>
);
}, [
accept,
active,
children,
cols,
controlId,
decoratedInputProps,
disabled,
emitValue,
effectiveLeftAction,
effectiveLeftActionOnPress,
effectiveRightAction,
effectiveRightActionOnPress,
error,
fieldProps,
format,
locked,
normalizedValue,
normalizedLeftPlacement,
normalizedRightPlacement,
options,
orientation,
placeholder,
readAs,
readOnly,
required,
render,
rows,
autocompleteOpen,
colorPickerOpen,
dateTimePickerOpen,
showPassword,
type
]);
if (embedded) {
return (
<YStack gap="$1.5" flex={1} minWidth={0} marginTop="$2">
<YStack {...chrome} minWidth={0}>
{borderless ? (
label ? (
<Text {...getTypographyRoleProps('fieldLabel', { fontSize: '$2', color: '$textSecondary' })}>
{label}
</Text>
) : null
) : (
<EmbeddedLabel label={label} required={required} />
)}
{control}
</YStack>
{renderHelper(error, error ? '' : (hint || helperText))}
</YStack>
);
}
if (orientation === 'horizontal') {
return (
<YStack gap="$1.5" width="100%">
<XStack gap="$4" alignItems="flex-start" width="100%">
<Text {...getTypographyRoleProps('fieldLabel')} width={180} paddingTop="$2">
{label}{required ? ' *' : ''}
</Text>
<YStack flex={1} minWidth={0} gap="$1.5">
{control}
</YStack>
</XStack>
{renderHelper(error, error ? '' : (hint || helperText))}
</YStack>
);
}
return (
<YStack gap="$1.5" width="100%">
{label ? (
<Text {...getTypographyRoleProps('fieldLabel')}>
{label}{required ? ' *' : ''}
</Text>
) : null}
{control}
{renderHelper(error, error ? '' : (hint || helperText))}
</YStack>
);
}
export default FieldControl;