more usage of tamagui primitives

This commit is contained in:
Codex Agent
2025-12-30 16:04:30 +01:00
parent efe2f25b3e
commit d7c2f85eeb
12 changed files with 744 additions and 315 deletions

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
color: { val: '#111827' },
gray: { val: '#6b7280' },
borderColor: { val: '#e5e7eb' },
primary: { val: '#FF5A5F' },
surface: { val: '#ffffff' },
red10: { val: '#b91c1c' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('tamagui', () => ({
Input: ({ ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
TextArea: ({ ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('@tamagui/select', () => {
const SelectContext = React.createContext<{ onValueChange?: (value: string) => void } | null>(null);
const Select = ({ children, onValueChange }: { children: React.ReactNode; onValueChange?: (value: string) => void }) => (
<SelectContext.Provider value={{ onValueChange }}>
<div>{children}</div>
</SelectContext.Provider>
);
const Trigger = ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>;
const Value = ({ placeholder }: { placeholder?: React.ReactNode }) => <span>{placeholder}</span>;
const Content = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Viewport = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Group = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const Item = ({ children, value }: { children: React.ReactNode; value: string }) => {
const ctx = React.useContext(SelectContext);
return (
<button type="button" data-value={value} onClick={() => ctx?.onValueChange?.(value)}>
{children}
</button>
);
};
const ItemText = ({ children }: { children: React.ReactNode }) => <span>{children}</span>;
Select.Trigger = Trigger;
Select.Value = Value;
Select.Content = Content;
Select.Viewport = Viewport;
Select.Group = Group;
Select.Item = Item;
Select.ItemText = ItemText;
return { Select };
});
import { MobileSelect } from './FormControls';
describe('MobileSelect', () => {
it('maps options and forwards selection changes', () => {
const handleChange = vi.fn();
render(
<MobileSelect value="" onChange={handleChange}>
<option value="">None</option>
<option value="one">One</option>
</MobileSelect>,
);
const items = screen.getAllByRole('button');
fireEvent.click(items[1]);
expect(handleChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.objectContaining({ value: 'one' }),
}),
);
});
});

View File

@@ -2,6 +2,8 @@ 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';
@@ -40,41 +42,41 @@ type ControlProps = {
compact?: boolean;
};
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const [focused, setFocused] = React.useState(false);
type MobileSelectProps = React.ComponentPropsWithoutRef<'select'> & ControlProps & {
placeholder?: string;
containerStyle?: React.CSSProperties;
};
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
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}
<Input
ref={ref as React.Ref<any>}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
onChangeText={(value) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLInputElement>);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
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}`,
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
hoverStyle={{
borderColor,
}}
style={style}
/>
);
},
@@ -83,40 +85,35 @@ export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPro
export const MobileTextArea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) {
>(function MobileTextArea({ hasError = false, compact = false, style, onChange, ...props }, ref) {
const { border, surface, text, primary, danger } = useAdminTheme();
const [focused, setFocused] = React.useState(false);
const borderColor = hasError ? danger : focused ? primary : border;
const borderColor = hasError ? danger : border;
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
return (
<textarea
ref={ref}
<TextArea
ref={ref as React.Ref<any>}
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
onChangeText={(value) => {
onChange?.({ target: { value } } as React.ChangeEvent<HTMLTextAreaElement>);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
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}`,
}}
style={{
width: '100%',
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '10px 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
minHeight: compact ? 72 : 96,
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
resize: 'vertical',
...style,
hoverStyle={{
borderColor,
}}
style={{ resize: 'vertical', ...style }}
/>
);
});
@@ -125,50 +122,92 @@ export function MobileSelect({
children,
hasError = false,
compact = false,
containerStyle,
style,
...props
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
}: MobileSelectProps) {
const { border, surface, text, primary, danger, subtle } = useAdminTheme();
const [focused, setFocused] = React.useState(false);
const height = compact ? 36 : 44;
const borderColor = hasError ? danger : focused ? primary : border;
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">
<select
{...props}
onFocus={(event) => {
setFocused(true);
props.onFocus?.(event);
}}
onBlur={(event) => {
setFocused(false);
props.onBlur?.(event);
}}
style={{
width: '100%',
height,
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: '0 36px 0 12px',
fontSize: compact ? 13 : 14,
background: surface,
color: text,
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
boxShadow: focused || hasError ? `0 0 0 3px ${ringColor}` : 'none',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
...style,
<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'}
>
{children}
</select>
<XStack position="absolute" right={12} pointerEvents="none">
<ChevronDown size={16} color={subtle} />
</XStack>
<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>
);
}

View File

@@ -83,14 +83,18 @@ export function CTAButton({
}: {
label: string;
onPress: () => void;
tone?: 'primary' | 'ghost';
tone?: 'primary' | 'ghost' | 'danger';
fullWidth?: boolean;
disabled?: boolean;
loading?: boolean;
}) {
const { primary, surface, border, text } = useAdminTheme();
const { primary, surface, border, text, danger } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading;
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;
return (
<Pressable
onPress={isDisabled ? undefined : onPress}
@@ -106,11 +110,11 @@ export function CTAButton({
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={isPrimary ? primary : surface}
borderWidth={isPrimary ? 0 : 1}
borderColor={isPrimary ? 'transparent' : border}
backgroundColor={backgroundColor}
borderWidth={isPrimary || isDanger ? 0 : 1}
borderColor={borderColor}
>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : text}>
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
{label}
</Text>
</XStack>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key }),
}));
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
surface: { val: '#ffffff' },
color12: { val: '#111827' },
gray: { val: '#6b7280' },
gray12: { val: '#0f172a' },
borderColor: { val: '#e5e7eb' },
shadowColor: { val: 'rgba(0,0,0,0.1)' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...props}>
{children}
</button>
),
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.ScrollView = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = () => <div />;
return { Sheet };
});
import { MobileSheet } from './Sheet';
describe('MobileSheet', () => {
it('renders title and closes via the close action', () => {
const onClose = vi.fn();
render(
<MobileSheet open title="Test Sheet" onClose={onClose}>
<div>Body</div>
</MobileSheet>,
);
expect(screen.getByText('Test Sheet')).toBeInTheDocument();
fireEvent.click(screen.getByText('Close'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Sheet } from '@tamagui/sheet';
import { useTranslation } from 'react-i18next';
import { useAdminTheme } from '../theme';
@@ -17,42 +18,64 @@ type SheetProps = {
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
const { t } = useTranslation('mobile');
const { surface, textStrong, muted, overlay, shadow } = useAdminTheme();
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
if (!open) return null;
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: `${overlay}66` }}>
<YStack
<Sheet
modal
open={open}
onOpenChange={(next) => {
if (!next) {
onClose();
}
}}
snapPoints={[82]}
snapPointsMode="percent"
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
>
<Sheet.Overlay backgroundColor={`${overlay}66`} />
<Sheet.Frame
width="100%"
maxWidth={520}
alignSelf="center"
borderTopLeftRadius={24}
borderTopRightRadius={24}
backgroundColor={surface}
padding="$4"
paddingBottom="$7"
space="$3"
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={18}
shadowOffset={{ width: 0, height: -8 }}
maxHeight="82vh"
overflow="auto"
// keep sheet above bottom nav / safe area
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
style={{ marginBottom: bottomOffset }}
>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{title}
</Text>
<Pressable onPress={onClose}>
<Text fontSize="$md" color={muted}>
{t('actions.close', 'Close')}
</Text>
</Pressable>
</XStack>
{children}
{footer ? footer : null}
</YStack>
</div>
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
<Sheet.ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 6 }}
>
<YStack space="$3">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color={textStrong}>
{title}
</Text>
<Pressable onPress={onClose}>
<Text fontSize="$md" color={muted}>
{t('actions.close', 'Close')}
</Text>
</Pressable>
</XStack>
{children}
{footer ? footer : null}
</YStack>
</Sheet.ScrollView>
</Sheet.Frame>
</Sheet>
);
}

View File

@@ -23,6 +23,15 @@ vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock('@tamagui/sheet', () => {
const Sheet = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Frame = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Overlay = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
Sheet.ScrollView = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
Sheet.Handle = () => <div />;
return { Sheet };
});
import { LegalConsentSheet } from '../LegalConsentSheet';
describe('LegalConsentSheet', () => {