214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
import React from 'react';
|
|
import { ChevronDown } from 'lucide-react';
|
|
import { YStack, XStack } from '@tamagui/stacks';
|
|
import { SizableText as Text } from '@tamagui/text';
|
|
import { Input, TextArea } from 'tamagui';
|
|
import { Select } from '@tamagui/select';
|
|
import { withAlpha } from './colors';
|
|
import { useAdminTheme } from '../theme';
|
|
|
|
type FieldProps = {
|
|
label: string;
|
|
hint?: string;
|
|
error?: string | null;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
export function MobileField({ label, hint, error, children }: FieldProps) {
|
|
const { text, muted, danger } = useAdminTheme();
|
|
|
|
return (
|
|
<YStack space="$1.5">
|
|
<Text fontSize="$sm" fontWeight="800" color={text}>
|
|
{label}
|
|
</Text>
|
|
{children}
|
|
{hint ? (
|
|
<Text fontSize="$xs" color={muted}>
|
|
{hint}
|
|
</Text>
|
|
) : null}
|
|
{error ? (
|
|
<Text fontSize="$xs" color={danger}>
|
|
{error}
|
|
</Text>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
type ControlProps = {
|
|
hasError?: boolean;
|
|
compact?: boolean;
|
|
};
|
|
|
|
type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps & {
|
|
placeholder?: string;
|
|
containerStyle?: React.CSSProperties;
|
|
};
|
|
|
|
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
|
function MobileInput({ hasError = false, compact = false, style, onChange, ...props }, ref) {
|
|
const { border, surface, text, primary, danger } = useAdminTheme();
|
|
const borderColor = hasError ? danger : border;
|
|
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
|
|
|
return (
|
|
<Input
|
|
ref={ref as React.Ref<any>}
|
|
{...props}
|
|
onChangeText={(value) => {
|
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
|
|
}}
|
|
size={compact ? '$3' : '$4'}
|
|
height={compact ? 36 : 44}
|
|
paddingHorizontal="$3"
|
|
borderRadius={12}
|
|
width="100%"
|
|
fontSize={compact ? 13 : 14}
|
|
backgroundColor={surface}
|
|
color={text}
|
|
borderColor={borderColor}
|
|
focusStyle={{
|
|
borderColor: hasError ? danger : primary,
|
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
|
}}
|
|
hoverStyle={{
|
|
borderColor,
|
|
}}
|
|
style={style}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const MobileTextArea = React.forwardRef<
|
|
HTMLTextAreaElement,
|
|
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
|
|
>(function MobileTextArea({ hasError = false, compact = false, style, onChange, ...props }, ref) {
|
|
const { border, surface, text, primary, danger } = useAdminTheme();
|
|
const borderColor = hasError ? danger : border;
|
|
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
|
|
|
return (
|
|
<TextArea
|
|
ref={ref as React.Ref<any>}
|
|
{...props}
|
|
onChangeText={(value) => {
|
|
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
|
|
}}
|
|
size={compact ? '$3' : '$4'}
|
|
minHeight={compact ? 72 : 96}
|
|
borderRadius={12}
|
|
padding="$3"
|
|
width="100%"
|
|
fontSize={compact ? 13 : 14}
|
|
backgroundColor={surface}
|
|
color={text}
|
|
borderColor={borderColor}
|
|
focusStyle={{
|
|
borderColor: hasError ? danger : primary,
|
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
|
}}
|
|
hoverStyle={{
|
|
borderColor,
|
|
}}
|
|
style={{ resize: 'vertical', ...style }}
|
|
/>
|
|
);
|
|
});
|
|
|
|
export function MobileSelect({
|
|
children,
|
|
hasError = false,
|
|
compact = false,
|
|
containerStyle,
|
|
style,
|
|
...props
|
|
}: MobileSelectProps) {
|
|
const { border, surface, text, primary, danger, subtle } = useAdminTheme();
|
|
const borderColor = hasError ? danger : border;
|
|
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
|
const hasSizing =
|
|
typeof containerStyle === 'object' &&
|
|
containerStyle !== null &&
|
|
('width' in containerStyle ||
|
|
'maxWidth' in containerStyle ||
|
|
'minWidth' in containerStyle ||
|
|
'flex' in containerStyle ||
|
|
'flexGrow' in containerStyle);
|
|
const options = React.Children.toArray(children).flatMap((child) => {
|
|
if (!React.isValidElement(child)) return [];
|
|
const { value, children: label, disabled } = child.props as {
|
|
value?: string | number;
|
|
children?: React.ReactNode;
|
|
disabled?: boolean;
|
|
};
|
|
return [
|
|
{
|
|
value: value === undefined ? '' : String(value),
|
|
label: label ?? '',
|
|
disabled: Boolean(disabled),
|
|
},
|
|
];
|
|
});
|
|
const emptyOption = options.find((option) => option.value === '');
|
|
const selectValue = props.value === undefined ? undefined : String(props.value ?? '');
|
|
const selectDefault = props.defaultValue === undefined ? undefined : String(props.defaultValue ?? '');
|
|
|
|
return (
|
|
<XStack position="relative" alignItems="center" width={hasSizing ? undefined : '100%'} style={containerStyle}>
|
|
<Select
|
|
value={selectValue}
|
|
defaultValue={selectValue === undefined ? selectDefault : undefined}
|
|
onValueChange={(next) => {
|
|
props.onChange?.({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
|
|
}}
|
|
size={compact ? '$3' : '$4'}
|
|
>
|
|
<Select.Trigger
|
|
width="100%"
|
|
borderRadius={12}
|
|
borderWidth={1}
|
|
borderColor={borderColor}
|
|
backgroundColor={surface}
|
|
paddingVertical={compact ? 6 : 10}
|
|
paddingHorizontal="$3"
|
|
disabled={props.disabled}
|
|
onFocus={props.onFocus}
|
|
onBlur={props.onBlur}
|
|
iconAfter={<ChevronDown size={16} color={subtle} />}
|
|
focusStyle={{
|
|
borderColor: hasError ? danger : primary,
|
|
boxShadow: `0 0 0 3px ${ringColor}`,
|
|
}}
|
|
hoverStyle={{
|
|
borderColor,
|
|
}}
|
|
style={style}
|
|
>
|
|
<Select.Value placeholder={props.placeholder ?? emptyOption?.label ?? ''} color={text} />
|
|
</Select.Trigger>
|
|
<Select.Content
|
|
zIndex={200000}
|
|
borderRadius={14}
|
|
borderWidth={1}
|
|
borderColor={border}
|
|
backgroundColor={surface}
|
|
>
|
|
<Select.Viewport padding="$2">
|
|
<Select.Group>
|
|
{options.map((option, index) => (
|
|
<Select.Item key={`${option.value}-${index}`} value={option.value} disabled={option.disabled}>
|
|
<Select.ItemText>{option.label}</Select.ItemText>
|
|
</Select.Item>
|
|
))}
|
|
</Select.Group>
|
|
</Select.Viewport>
|
|
</Select.Content>
|
|
</Select>
|
|
{props.name ? <input type="hidden" name={props.name} value={selectValue ?? selectDefault ?? ''} /> : null}
|
|
</XStack>
|
|
);
|
|
}
|