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;
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Input, Text, XStack, YStack } from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
const ChevronLeft = getIcon('chevron-left');
|
||||||
|
const ChevronRight = getIcon('chevron-right');
|
||||||
|
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTH_LABELS = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
function clampByte(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentToHex(value) {
|
||||||
|
return clampByte(value).toString(16).padStart(2, '0').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex({ r, g, b }) {
|
||||||
|
return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
const short = /^#([0-9a-fA-F]{3})$/;
|
||||||
|
const full = /^#([0-9a-fA-F]{6})$/;
|
||||||
|
|
||||||
|
if (short.test(raw)) {
|
||||||
|
const [, group] = raw.match(short);
|
||||||
|
return {
|
||||||
|
r: parseInt(group[0] + group[0], 16),
|
||||||
|
g: parseInt(group[1] + group[1], 16),
|
||||||
|
b: parseInt(group[2] + group[2], 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (full.test(raw)) {
|
||||||
|
const [, group] = raw.match(full);
|
||||||
|
return {
|
||||||
|
r: parseInt(group.slice(0, 2), 16),
|
||||||
|
g: parseInt(group.slice(2, 4), 16),
|
||||||
|
b: parseInt(group.slice(4, 6), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { r: 0, g: 0, b: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h, s, l) {
|
||||||
|
const hue = ((h % 360) + 360) % 360;
|
||||||
|
const saturation = Math.max(0, Math.min(1, s));
|
||||||
|
const lightness = Math.max(0, Math.min(1, l));
|
||||||
|
|
||||||
|
if (saturation === 0) {
|
||||||
|
const gray = Math.round(lightness * 255);
|
||||||
|
return { r: gray, g: gray, b: gray };
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = lightness < 0.5
|
||||||
|
? lightness * (1 + saturation)
|
||||||
|
: lightness + saturation - (lightness * saturation);
|
||||||
|
const p = 2 * lightness - q;
|
||||||
|
|
||||||
|
function hueToChannel(t) {
|
||||||
|
let channel = t;
|
||||||
|
if (channel < 0) channel += 1;
|
||||||
|
if (channel > 1) channel -= 1;
|
||||||
|
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||||
|
if (channel < 1 / 2) return q;
|
||||||
|
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hk = hue / 360;
|
||||||
|
return {
|
||||||
|
r: Math.round(hueToChannel(hk + 1 / 3) * 255),
|
||||||
|
g: Math.round(hueToChannel(hk) * 255),
|
||||||
|
b: Math.round(hueToChannel(hk - 1 / 3) * 255),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(Math.max(0, Math.min(99, Number(value) || 0))).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTimeHour(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(23, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampTimeMinute(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(59, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateTimeParts(value, type) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (type === 'time') {
|
||||||
|
const match = raw.match(/^(\d{1,2}):(\d{1,2})/);
|
||||||
|
return {
|
||||||
|
datePart: '',
|
||||||
|
hour: match ? clampTimeHour(match[1]) : 0,
|
||||||
|
minute: match ? clampTimeMinute(match[2]) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'date') {
|
||||||
|
return { datePart: raw || '', hour: 0, minute: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = raw.match(/^(.+?)(?:[T\s](\d{1,2}):(\d{1,2}))?$/);
|
||||||
|
return {
|
||||||
|
datePart: match?.[1] || '',
|
||||||
|
hour: match?.[2] ? clampTimeHour(match[2]) : 0,
|
||||||
|
minute: match?.[3] ? clampTimeMinute(match[3]) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIsoDate(value) {
|
||||||
|
const match = String(value || '').trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, yearText, monthText, dayText] = match;
|
||||||
|
const year = Number(yearText);
|
||||||
|
const month = Number(monthText) - 1;
|
||||||
|
const day = Number(dayText);
|
||||||
|
const date = new Date(year, month, day, 12, 0, 0, 0);
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIsoDate(date) {
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
String(date.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(date.getDate()).padStart(2, '0'),
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a, b) {
|
||||||
|
return a && b
|
||||||
|
&& a.getFullYear() === b.getFullYear()
|
||||||
|
&& a.getMonth() === b.getMonth()
|
||||||
|
&& a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthAnchorFromDate(date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1, 12, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(anchor, delta) {
|
||||||
|
return new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1, 12, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCalendarCells(anchor) {
|
||||||
|
const monthStart = monthAnchorFromDate(anchor);
|
||||||
|
const gridStart = new Date(monthStart);
|
||||||
|
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
for (let index = 0; index < 42; index += 1) {
|
||||||
|
const date = new Date(gridStart);
|
||||||
|
date.setDate(gridStart.getDate() + index);
|
||||||
|
cells.push({
|
||||||
|
key: formatIsoDate(date),
|
||||||
|
date,
|
||||||
|
inMonth: date.getMonth() === anchor.getMonth(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeDateTimeValue(type, parts) {
|
||||||
|
const hour = pad2(parts.hour);
|
||||||
|
const minute = pad2(parts.minute);
|
||||||
|
|
||||||
|
if (type === 'time') {
|
||||||
|
return `${hour}:${minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'date') {
|
||||||
|
return parts.datePart || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parts.datePart) {
|
||||||
|
return `${hour}:${minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parts.datePart}T${hour}:${minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorField({ color, onPick }) {
|
||||||
|
const columns = 32;
|
||||||
|
const rows = 24;
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row += 1) {
|
||||||
|
const y = row / Math.max(1, rows - 1);
|
||||||
|
const lightness = 0.78 - (y * 0.56);
|
||||||
|
const rowCells = [];
|
||||||
|
for (let col = 0; col < columns; col += 1) {
|
||||||
|
const x = col / Math.max(1, columns - 1);
|
||||||
|
const hue = x * 360;
|
||||||
|
const saturation = 0.92 - (y * 0.18);
|
||||||
|
const rgb = hslToRgb(hue, saturation, lightness);
|
||||||
|
const hex = rgbToHex(rgb);
|
||||||
|
rowCells.push(
|
||||||
|
<YStack
|
||||||
|
key={`${row}-${col}`}
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
height={8}
|
||||||
|
backgroundColor={hex}
|
||||||
|
onMouseEnter={() => onPick(hex)}
|
||||||
|
onPress={() => onPick(hex)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cells.push(
|
||||||
|
<XStack key={`row-${row}`} gap={0} flex={1}>
|
||||||
|
{rowCells}
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
minHeight={192}
|
||||||
|
overflow="hidden"
|
||||||
|
borderRadius="$3"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
backgroundColor={color}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PickerFrame({ width = 320, children }) {
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
position="absolute"
|
||||||
|
top="100%"
|
||||||
|
right={0}
|
||||||
|
zIndex={40}
|
||||||
|
marginTop="$1.5"
|
||||||
|
width={width}
|
||||||
|
padding="$3"
|
||||||
|
gap="$3"
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
borderRadius="$4"
|
||||||
|
shadowColor="$shadowColor"
|
||||||
|
elevation="$3"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorPickerPopup({ value, onChange, onClose }) {
|
||||||
|
const rgb = parseHexColor(value);
|
||||||
|
const setChannel = (channel, nextValue) => {
|
||||||
|
const next = {
|
||||||
|
...rgb,
|
||||||
|
[channel]: clampByte(nextValue),
|
||||||
|
};
|
||||||
|
onChange(rgbToHex(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerFrame width={320}>
|
||||||
|
<XStack gap="$3" alignItems="stretch">
|
||||||
|
<ColorField color={value} onPick={onChange} />
|
||||||
|
<YStack width={92} gap="$2">
|
||||||
|
<XStack gap="$2" alignItems="center">
|
||||||
|
<Text width={12} fontSize="$2" color="$textSecondary">R:</Text>
|
||||||
|
<Input flex={1} size="$2" keyboardType="number-pad" value={String(rgb.r)} onChangeText={(nextValue) => setChannel('r', nextValue)} />
|
||||||
|
</XStack>
|
||||||
|
<XStack gap="$2" alignItems="center">
|
||||||
|
<Text width={12} fontSize="$2" color="$textSecondary">G:</Text>
|
||||||
|
<Input flex={1} size="$2" keyboardType="number-pad" value={String(rgb.g)} onChangeText={(nextValue) => setChannel('g', nextValue)} />
|
||||||
|
</XStack>
|
||||||
|
<XStack gap="$2" alignItems="center">
|
||||||
|
<Text width={12} fontSize="$2" color="$textSecondary">B:</Text>
|
||||||
|
<Input flex={1} size="$2" keyboardType="number-pad" value={String(rgb.b)} onChangeText={(nextValue) => setChannel('b', nextValue)} />
|
||||||
|
</XStack>
|
||||||
|
<YStack
|
||||||
|
marginTop="$1"
|
||||||
|
height={44}
|
||||||
|
borderRadius="$3"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
backgroundColor={value || '#000000'}
|
||||||
|
/>
|
||||||
|
</YStack>
|
||||||
|
</XStack>
|
||||||
|
<XStack justifyContent="space-between" alignItems="center">
|
||||||
|
<Text fontSize="$2" color="$textSecondary">{String(value || '#000000').toUpperCase()}</Text>
|
||||||
|
<Button size="$2" chromeless onPress={onClose}>Close</Button>
|
||||||
|
</XStack>
|
||||||
|
</PickerFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateSection({ value, onChange }) {
|
||||||
|
const selectedDate = parseIsoDate(value);
|
||||||
|
const today = React.useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0);
|
||||||
|
}, []);
|
||||||
|
const [viewMonth, setViewMonth] = React.useState(() => monthAnchorFromDate(selectedDate || today));
|
||||||
|
const selectedDateKey = selectedDate ? formatIsoDate(selectedDate) : '';
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setViewMonth(monthAnchorFromDate(selectedDate || today));
|
||||||
|
}, [selectedDateKey, today]);
|
||||||
|
|
||||||
|
const cells = React.useMemo(() => buildCalendarCells(viewMonth), [viewMonth]);
|
||||||
|
const monthLabel = `${MONTH_LABELS[viewMonth.getMonth()]} ${viewMonth.getFullYear()}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<YStack
|
||||||
|
gap="$2"
|
||||||
|
minHeight={184}
|
||||||
|
borderRadius="$3"
|
||||||
|
borderWidth={1}
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
padding="$4"
|
||||||
|
>
|
||||||
|
<XStack alignItems="center" justifyContent="space-between">
|
||||||
|
<Button
|
||||||
|
chromeless
|
||||||
|
size="$2"
|
||||||
|
paddingHorizontal="$1"
|
||||||
|
onPress={() => setViewMonth((current) => shiftMonth(current, -1))}
|
||||||
|
>
|
||||||
|
{ChevronLeft ? <ChevronLeft size="sm" color="$textSecondary" /> : '<'}
|
||||||
|
</Button>
|
||||||
|
<Text color="$textPrimary" fontWeight="600">{monthLabel}</Text>
|
||||||
|
<Button
|
||||||
|
chromeless
|
||||||
|
size="$2"
|
||||||
|
paddingHorizontal="$1"
|
||||||
|
onPress={() => setViewMonth((current) => shiftMonth(current, 1))}
|
||||||
|
>
|
||||||
|
{ChevronRight ? <ChevronRight size="sm" color="$textSecondary" /> : '>'}
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<XStack gap="$1.5">
|
||||||
|
{WEEKDAY_LABELS.map((label) => (
|
||||||
|
<YStack key={label} flex={1} alignItems="center">
|
||||||
|
<Text fontSize="$2" color="$textSecondary">{label}</Text>
|
||||||
|
</YStack>
|
||||||
|
))}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
|
<YStack gap="$1.5">
|
||||||
|
{Array.from({ length: 6 }, (_, rowIndex) => (
|
||||||
|
<XStack key={`week-${rowIndex}`} gap="$1.5">
|
||||||
|
{cells.slice(rowIndex * 7, (rowIndex + 1) * 7).map((cell) => {
|
||||||
|
const isSelected = sameDay(cell.date, selectedDate);
|
||||||
|
const isToday = sameDay(cell.date, today);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cell.key}
|
||||||
|
size="$2"
|
||||||
|
flex={1}
|
||||||
|
minWidth={0}
|
||||||
|
height={34}
|
||||||
|
paddingHorizontal="$0"
|
||||||
|
borderRadius="$3"
|
||||||
|
borderWidth={isSelected ? 1 : (isToday ? 1 : 0)}
|
||||||
|
borderColor={isSelected ? '$accent' : (isToday ? '$lineStrong' : 'transparent')}
|
||||||
|
backgroundColor={isSelected ? '$accentBg' : 'transparent'}
|
||||||
|
color={cell.inMonth ? (isSelected ? '$accent' : '$textPrimary') : '$textMuted'}
|
||||||
|
hoverStyle={{
|
||||||
|
backgroundColor: isSelected ? '$accentBgHover' : '$bgPage',
|
||||||
|
}}
|
||||||
|
pressStyle={{
|
||||||
|
backgroundColor: isSelected ? '$accentBgHover' : '$bgPage',
|
||||||
|
}}
|
||||||
|
onPress={() => onChange(formatIsoDate(cell.date))}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</XStack>
|
||||||
|
))}
|
||||||
|
</YStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeSection({ hour, minute, onHourChange, onMinuteChange }) {
|
||||||
|
return (
|
||||||
|
<YStack gap="$2">
|
||||||
|
<Text color="$textPrimary" fontWeight="600">Time</Text>
|
||||||
|
<XStack gap="$2" alignItems="center">
|
||||||
|
<XStack gap="$2" alignItems="center" flex={1}>
|
||||||
|
<Text width={18} fontSize="$2" color="$textSecondary">H:</Text>
|
||||||
|
<Input
|
||||||
|
flex={1}
|
||||||
|
size="$2"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={String(hour)}
|
||||||
|
onChangeText={onHourChange}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
<Text color="$textSecondary">:</Text>
|
||||||
|
<XStack gap="$2" alignItems="center" flex={1}>
|
||||||
|
<Text width={18} fontSize="$2" color="$textSecondary">M:</Text>
|
||||||
|
<Input
|
||||||
|
flex={1}
|
||||||
|
size="$2"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
value={String(minute)}
|
||||||
|
onChangeText={onMinuteChange}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
|
</XStack>
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateTimePickerPopup({ type = 'date-time', value, onChange, onClose }) {
|
||||||
|
const parts = parseDateTimeParts(value, type);
|
||||||
|
const showDate = type === 'date' || type === 'date-time';
|
||||||
|
const showTime = type === 'time' || type === 'date-time';
|
||||||
|
|
||||||
|
const updateDate = (nextDatePart) => {
|
||||||
|
const next = {
|
||||||
|
...parts,
|
||||||
|
datePart: nextDatePart,
|
||||||
|
};
|
||||||
|
onChange(composeDateTimeValue(type, next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTime = (channel, nextValue) => {
|
||||||
|
const next = {
|
||||||
|
...parts,
|
||||||
|
[channel]: channel === 'hour' ? clampTimeHour(nextValue) : clampTimeMinute(nextValue),
|
||||||
|
};
|
||||||
|
onChange(composeDateTimeValue(type, next));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerFrame width={320}>
|
||||||
|
{showDate ? <DateSection value={parts.datePart} onChange={updateDate} /> : null}
|
||||||
|
{showTime ? (
|
||||||
|
<TimeSection
|
||||||
|
hour={parts.hour}
|
||||||
|
minute={parts.minute}
|
||||||
|
onHourChange={(nextValue) => updateTime('hour', nextValue)}
|
||||||
|
onMinuteChange={(nextValue) => updateTime('minute', nextValue)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<XStack justifyContent="space-between" alignItems="center">
|
||||||
|
<Text fontSize="$2" color="$textSecondary">
|
||||||
|
{String(composeDateTimeValue(type, parts) || value || '').toUpperCase() || '—'}
|
||||||
|
</Text>
|
||||||
|
<Button size="$2" chromeless onPress={onClose}>Close</Button>
|
||||||
|
</XStack>
|
||||||
|
</PickerFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
ColorPickerPopup,
|
||||||
|
DateTimePickerPopup,
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
|||||||
import { Button, XStack, YStack } from 'tamagui';
|
import { Button, XStack, YStack } from 'tamagui';
|
||||||
import { SidePanelShell } from './SidePanelShell.jsx';
|
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||||
import { FormField } from './FormField.jsx';
|
import { FormField } from './FormField.jsx';
|
||||||
|
import { FieldControl } from './FieldControl.jsx';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
function defaultExpressionEvaluator(template, form) {
|
function defaultExpressionEvaluator(template, form) {
|
||||||
@@ -87,7 +88,7 @@ export function FormView({
|
|||||||
}, [buttons, hideButtons, onReset, onSubmit]);
|
}, [buttons, hideButtons, onReset, onSubmit]);
|
||||||
|
|
||||||
const renderField = (field, index) => (
|
const renderField = (field, index) => (
|
||||||
<FormField
|
<FieldControl
|
||||||
key={field.id || `${field.type || 'field'}-${index}`}
|
key={field.id || `${field.type || 'field'}-${index}`}
|
||||||
{...field}
|
{...field}
|
||||||
value={mergedValues[field.id]}
|
value={mergedValues[field.id]}
|
||||||
@@ -103,7 +104,7 @@ export function FormView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return React.Children.map(children, (child) => {
|
return React.Children.map(children, (child) => {
|
||||||
if (React.isValidElement(child) && child.type === FormField) {
|
if (React.isValidElement(child) && (child.type === FieldControl || child.type === FormField)) {
|
||||||
const fieldId = child.props.id;
|
const fieldId = child.props.id;
|
||||||
return React.cloneElement(child, {
|
return React.cloneElement(child, {
|
||||||
value: mergedValues[fieldId],
|
value: mergedValues[fieldId],
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ export function ToastViewport() {
|
|||||||
bottom="$4"
|
bottom="$4"
|
||||||
right="$4"
|
right="$4"
|
||||||
gap="$2"
|
gap="$2"
|
||||||
zIndex={10000}
|
zIndex={22000}
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
maxWidth="calc(100vw - 32px)"
|
maxWidth="calc(100vw - 32px)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export { DirView, default as DirViewDefault } from './DirView.jsx';
|
|||||||
export { DetView, default as DetViewDefault } from './DetView.jsx';
|
export { DetView, default as DetViewDefault } from './DetView.jsx';
|
||||||
export { FormView, default as FormViewDefault } from './FormView.jsx';
|
export { FormView, default as FormViewDefault } from './FormView.jsx';
|
||||||
export { FormField, default as FormFieldDefault } from './FormField.jsx';
|
export { FormField, default as FormFieldDefault } from './FormField.jsx';
|
||||||
|
export { FieldControl, default as FieldControlDefault } from './FieldControl.jsx';
|
||||||
|
export { ColorPickerPopup, DateTimePickerPopup } from './FieldControlPickers.jsx';
|
||||||
export { Router, useRouter, useRoute } from './Router.jsx';
|
export { Router, useRouter, useRoute } from './Router.jsx';
|
||||||
export { Page, default as PageDefault } from './Page.jsx';
|
export { Page, default as PageDefault } from './Page.jsx';
|
||||||
export { ProgressBar, default as ProgressBarDefault } from './ProgressBar.jsx';
|
export { ProgressBar, default as ProgressBarDefault } from './ProgressBar.jsx';
|
||||||
|
|||||||
Reference in New Issue
Block a user