Files
fotospiel-app/resources/js/admin/mobile/components/FormControls.tsx
Codex Agent 918bff08aa
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix auth translations and admin PWA UI
2026-01-16 12:14:53 +01:00

297 lines
8.9 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 MobileColorInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & { size?: number }
>(function MobileColorInput({ size = 52, style, ...props }, ref) {
const { border, surface } = useAdminTheme();
return (
<input
ref={ref}
type="color"
{...props}
style={{
width: size,
height: size,
borderRadius: 12,
border: `1px solid ${border}`,
background: surface,
padding: 0,
...style,
}}
/>
);
});
export const MobileFileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'>>(
function MobileFileInput({ style, ...props }, ref) {
return (
<input
ref={ref}
type="file"
{...props}
style={{
display: 'none',
...style,
}}
/>
);
},
);
export const MobileDateTimeInput = React.forwardRef<
HTMLInputElement,
React.ComponentPropsWithoutRef<'input'> & ControlProps
>(function MobileDateTimeInput({ hasError = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
const borderColor = hasError ? danger : border;
return (
<input
ref={ref}
type="datetime-local"
{...props}
style={{
width: '100%',
height: 44,
padding: '0 12px',
borderRadius: 12,
borderWidth: 1,
borderStyle: 'solid',
borderColor,
backgroundColor: surface,
color: text,
fontSize: 14,
outline: 'none',
boxShadow: `0 0 0 0 ${ringColor}`,
...style,
}}
onFocus={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 3px ${ringColor}`;
props.onFocus?.(event);
}}
onBlur={(event) => {
event.currentTarget.style.boxShadow = `0 0 0 0 ${ringColor}`;
props.onBlur?.(event);
}}
/>
);
});
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, onChange, type, ...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);
const isPassword = type === 'password';
return (
<Input
ref={ref as React.Ref<any>}
{...props}
{...({ type } as any)}
secureTextEntry={isPassword}
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}`,
} as any}
hoverStyle={{
borderColor,
} as any}
style={style as any}
/>
);
},
);
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}
{...({ minHeight: compact ? 72 : 96 } as any)}
onChangeText={(value) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
}}
size={compact ? '$3' : '$4'}
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}`,
} as any}
hoverStyle={{
borderColor,
} as any}
style={{ resize: 'vertical', ...style } as any}
/>
);
});
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 as any}
backgroundColor={surface as any}
paddingVertical={compact ? 6 : 10}
paddingHorizontal="$3"
disabled={props.disabled}
onFocus={props.onFocus as any}
onBlur={props.onBlur as any}
iconAfter={<ChevronDown size={16} color={subtle} />}
focusStyle={{
borderColor: (hasError ? danger : primary) as any,
boxShadow: `0 0 0 3px ${ringColor}`,
}}
hoverStyle={{
borderColor: borderColor as any,
}}
style={style as any}
>
<Select.Value placeholder={props.placeholder ?? (emptyOption?.label as any) ?? ''} {...({ color: text } as any)} />
</Select.Trigger>
<Select.Content
zIndex={200000}
{...({ borderRadius: 14 } as any)}
borderWidth={1}
borderColor={border}
backgroundColor={surface as any}
>
<Select.Viewport {...({ padding: "$2" } as any)}>
<Select.Group>
{options.map((option, index) => (
<Select.Item index={index} 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>
);
}