field control improvement
This commit is contained in:
@@ -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 (
|
||||
<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} {...context} /> : 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} {...context} /> : 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="$3"
|
||||
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="$4"
|
||||
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;
|
||||
Reference in New Issue
Block a user