feat: draw buttons, switches, selects, inputs and badges like the webmail

This commit is contained in:
Linus Rath
2026-10-15 14:49:37 +02:00
parent 75299244cf
commit 0a176ab51c
9 changed files with 222 additions and 89 deletions
+17 -11
View File
@@ -1,20 +1,25 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { radius, typography, type ThemePalette } from '../theme/tokens';
import { radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
interface BadgeProps {
/** Sentence case ("Not on this server"); badges are never upper case. */
children: string;
variant?: 'default' | 'primary' | 'destructive' | 'outline' | 'muted';
style?: ViewStyle;
}
/**
* A small status pill. The default is the webmail's outline badge: hairline
* border, muted 12px text. The tinted variants keep the same size.
*/
export default function Badge({ children, variant = 'default', style }: BadgeProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.base, styles[`bg_${variant}` as const], style]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
</View>
@@ -25,23 +30,24 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'transparent',
paddingHorizontal: 8,
paddingVertical: 2,
paddingVertical: 1,
alignSelf: 'flex-start',
},
text: { ...typography.caption, fontWeight: '500' },
text: { fontSize: 12, lineHeight: 16 },
bg_default: { backgroundColor: c.surface },
bg_primary: { backgroundColor: c.primaryBg },
bg_default: { borderColor: c.border },
bg_outline: { borderColor: c.border },
bg_primary: { backgroundColor: c.accent },
bg_destructive: { backgroundColor: c.errorBg },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_muted: { backgroundColor: c.muted },
text_default: { color: c.text },
text_primary: { color: c.primary },
text_default: { color: c.mutedForeground },
text_outline: { color: c.mutedForeground },
text_primary: { color: c.accentForeground },
text_destructive: { color: c.error },
text_outline: { color: c.text },
text_muted: { color: c.mutedForeground },
});
}
+43 -27
View File
@@ -1,9 +1,19 @@
import React from 'react';
import { Pressable, Text, StyleSheet, ViewStyle, ActivityIndicator } from 'react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { spacing, radius, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
type ButtonVariant = 'default' | 'ghost' | 'outline' | 'destructive';
/**
* The webmail's standard-theme buttons (components/ui/button.tsx):
* - default: bg-primary text-primary-foreground
* - secondary: bg-muted text-foreground
* - outline: border, transparent
* - ghost: no border or fill until pressed
* - destructive: red fill, white text
* - destructiveOutline: red border and text, for a reset in a settings row
* All 36 dp tall (lg 44 for a page's main action), radius 6, 14px medium.
*/
type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive' | 'destructiveOutline';
type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
@@ -19,8 +29,8 @@ interface ButtonProps {
accessibilityLabel?: string;
}
// Extra touch area so even the small sizes reach a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 4, lg: 2, icon: 4 };
// Extra touch area so every size reaches a 48 dp target.
const HIT_SLOP: Record<ButtonSize, number> = { sm: 6, md: 6, lg: 2, icon: 4 };
export default function Button({
variant = 'default',
@@ -36,6 +46,9 @@ export default function Button({
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const isDisabled = disabled || loading;
const spinnerColor = variant === 'default'
? c.primaryForeground
: variant === 'destructive' ? '#ffffff' : c.text;
return (
<Pressable
@@ -55,15 +68,12 @@ export default function Button({
accessibilityState={{ disabled: isDisabled, busy: loading }}
>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'default' ? c.primaryForeground : c.primary}
/>
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<>
{icon}
{typeof children === 'string' ? (
<Text style={[styles.text, styles[`text_${variant}` as const], isDisabled && styles.textDisabled]}>
<Text style={[styles.text, styles[`text_${variant}` as const]]} numberOfLines={1}>
{children}
</Text>
) : (
@@ -79,30 +89,36 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
base: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
borderRadius: radius.md, gap: spacing.sm,
borderRadius: radius.sm, gap: spacing.sm,
},
text: { ...typography.bodyMedium },
text: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
disabled: { opacity: 0.5 },
textDisabled: { opacity: 0.5 },
size_sm: { height: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { height: componentSizes.buttonMd, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
size_lg: { height: componentSizes.buttonLg, paddingHorizontal: spacing.xxxl },
// minHeight, so a larger system font grows the button instead of clipping.
size_sm: { minHeight: componentSizes.buttonSm, paddingHorizontal: spacing.md },
size_md: { minHeight: componentSizes.buttonSm, paddingHorizontal: 14 },
size_lg: { minHeight: componentSizes.buttonLg, paddingHorizontal: spacing.xxl },
size_icon: { height: componentSizes.buttonMd, width: componentSizes.buttonMd, paddingHorizontal: 0 },
bg_default: { backgroundColor: c.primary, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: c.background, borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 1, elevation: 1 },
bg_default: { backgroundColor: c.primary },
bg_secondary: { backgroundColor: c.muted },
bg_ghost: { backgroundColor: 'transparent' },
bg_outline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.border },
bg_destructive: { backgroundColor: c.error },
bg_destructiveOutline: { backgroundColor: 'transparent', borderWidth: 1, borderColor: c.error },
pressed_default: { backgroundColor: c.primaryDark, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
pressed_ghost: { backgroundColor: c.accent },
pressed_outline: { backgroundColor: c.accent },
pressed_destructive: { opacity: 0.9 },
pressed_default: { opacity: 0.9 },
pressed_secondary: { opacity: 0.8 },
pressed_ghost: { backgroundColor: c.muted },
pressed_outline: { backgroundColor: c.muted },
pressed_destructive: { opacity: 0.9 },
pressed_destructiveOutline: { backgroundColor: c.errorBg },
text_default: { color: c.primaryForeground },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: c.errorForeground },
text_default: { color: c.primaryForeground },
text_secondary: { color: c.text },
text_ghost: { color: c.text },
text_outline: { color: c.text },
text_destructive: { color: '#ffffff' },
text_destructiveOutline: { color: c.error },
});
}
+12 -13
View File
@@ -14,13 +14,12 @@ interface InputProps extends TextInputProps {
}
/**
* Matches webmail input.tsx:
* - h-10 w-full rounded-md border border-input bg-background
* - px-3 py-2 text-sm text-foreground
* - placeholder:text-muted-foreground
* - hover:border-muted-foreground
* - focus:ring-2 ring-ring border-ring
* - disabled:opacity-50
* Matches webmail input.tsx in the standard theme:
* - h-10 rounded-md border border-input, transparent background
* - px-3 text-sm text-foreground, placeholder in muted-foreground
* - focus: border-ring
* - disabled: opacity-50
* - label above in text-sm font-medium text-foreground
*/
export default function Input({
label,
@@ -50,7 +49,7 @@ export default function Input({
ref={ref}
{...props}
style={[styles.input, style]}
placeholderTextColor={c.textMuted}
placeholderTextColor={c.mutedForeground}
onFocus={(e) => { setFocused(true); props.onFocus?.(e); }}
onBlur={(e) => { setFocused(false); props.onBlur?.(e); }}
/>
@@ -65,8 +64,8 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({
label: {
...typography.bodyMedium,
color: c.textSecondary,
marginBottom: spacing.sm,
color: c.text,
marginBottom: 6,
},
inputWrapper: {
flexDirection: 'row',
@@ -75,12 +74,12 @@ function makeStyles(c: ThemePalette) {
borderWidth: 1,
borderColor: c.border, // border-input
borderRadius: radius.sm, // rounded-md = 6px
backgroundColor: c.background, // bg-background
backgroundColor: 'transparent',
paddingHorizontal: spacing.md, // px-3 = 12px
},
// Colour only, so focusing doesn't shift the text by a pixel.
inputFocused: {
borderColor: c.borderFocus, // border-ring (blue-500)
borderWidth: 2,
borderColor: c.borderFocus,
},
inputError: {
borderColor: c.error,
+11 -7
View File
@@ -12,11 +12,9 @@ interface ToggleSwitchProps {
}
/**
* Matches webmail ToggleSwitch:
* - h-6 w-11 rounded-full
* - checked: bg-primary, unchecked: bg-muted
* - thumb: h-4 w-4 rounded-full bg-background
* - translate-x-6 (checked) / translate-x-1 (unchecked)
* The webmail switch in the standard theme: a 44 x 24 track, bg-primary when
* on (knob in primary-foreground), bg-muted when off (knob in background).
* The knob is 20 dp with 2 dp of track around it.
*/
export default function ToggleSwitch({ value, onValueChange, disabled = false, accessibilityLabel }: ToggleSwitchProps) {
const c = useColors();
@@ -38,7 +36,8 @@ export default function ToggleSwitch({ value, onValueChange, disabled = false, a
<View
style={[
styles.thumb,
{ transform: [{ translateX: value ? 24 : 4 }] },
value ? styles.thumbOn : styles.thumbOff,
{ transform: [{ translateX: value ? 22 : 2 }] },
]}
/>
</Pressable>
@@ -60,9 +59,14 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
},
thumb: {
width: componentSizes.toggleThumb, // h-4 w-4 = 16
width: componentSizes.toggleThumb,
height: componentSizes.toggleThumb,
borderRadius: radius.full,
},
thumbOn: {
backgroundColor: c.primaryForeground,
},
thumbOff: {
backgroundColor: c.background,
},
disabled: {
@@ -179,7 +179,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: 2,
},
previewLabel: { fontSize: 11, fontWeight: '600', color: c.mutedForeground, textTransform: 'uppercase' },
previewLabel: { fontSize: 12, fontWeight: '500', color: c.mutedForeground },
previewValue: { ...typography.captionMedium, color: c.text },
tokenWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: spacing.md },
tokenPill: {
@@ -317,6 +317,7 @@ function makeStyles(c: ThemePalette) {
},
bottomActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
paddingTop: spacing.sm,
},
+1 -1
View File
@@ -267,7 +267,7 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.muted,
gap: spacing.sm,
},
notesTitle: { ...typography.caption, color: c.mutedForeground, textTransform: 'uppercase' },
notesTitle: { ...typography.captionMedium, color: c.mutedForeground },
notesBody: { ...typography.body, color: c.text, lineHeight: 20 },
progressBox: {
paddingHorizontal: spacing.md,
+135 -28
View File
@@ -8,7 +8,7 @@ import {
ScrollView,
ViewStyle,
} from 'react-native';
import { FlaskConical, Lock, ChevronDown, Check } from '../icons';
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import ToggleSwitchComponent from '../ToggleSwitch';
@@ -17,12 +17,14 @@ import { useSearchHighlight } from './search-highlight';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
/**
* Mirrors webmail settings-section.tsx:
* - SettingsSection: space-y-4, with experimental banner, h3 title + description
* - SettingItem: flex items-start justify-between py-3 border-b border-border
* - ToggleSwitch: h-6 w-11
* - Select: rounded-md bg-muted border text-sm px-3 py-1.5
* Mirrors webmail settings-section.tsx in the standard theme:
* - SettingsSection: title 18px medium, description 14px muted, then rows
* - SettingItem: label 14px medium, description 12px muted, 12 dp vertical
* padding, a 1 dp border under each row, the control on the right
* - ToggleSwitch: 44 x 24
* - Select: a 36 dp outline button with a chevron, radius 6
* - RadioGroup: flex gap-1.5, pill buttons
* Plus SettingsRow, the 56 dp row of the Settings list (icon, title, value).
*/
interface SettingsSectionProps {
@@ -76,6 +78,9 @@ interface SettingItemProps {
children?: React.ReactNode;
locked?: boolean;
noBorder?: boolean;
/** Makes the whole row a link: to a page (chevron) or out of the app. */
onPress?: () => void;
link?: 'page' | 'external';
}
export function SettingItem({
@@ -84,16 +89,15 @@ export function SettingItem({
children,
locked,
noBorder,
onPress,
link = 'page',
}: SettingItemProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const highlight = useSearchHighlight(label);
return (
<View
ref={highlight.ref}
onLayout={highlight.onLayout}
style={[styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight]}
>
const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
const body = (
<>
<View style={styles.settingContent}>
<View style={styles.settingLabelRow}>
<Text style={styles.settingLabel}>{label}</Text>
@@ -106,10 +110,81 @@ export function SettingItem({
<View style={styles.settingRight}>{children}</View>
</SettingLabelContext.Provider>
)}
{onPress && (link === 'external'
? <ExternalLink size={16} color={c.mutedForeground} />
: <ChevronRight size={16} color={c.mutedForeground} />)}
</>
);
if (onPress) {
return (
<Pressable
ref={highlight.ref}
onLayout={highlight.onLayout}
onPress={onPress}
accessibilityRole={link === 'external' ? 'link' : 'button'}
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
>
{body}
</Pressable>
);
}
return (
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
{body}
</View>
);
}
interface SettingsRowProps {
icon: AppIcon;
title: string;
/** The current value, e.g. "Dark · Standard theme". */
value?: string | null;
onPress: () => void;
disabled?: boolean;
/** The page open beside the list on a wide window. */
selected?: boolean;
/** Sits after the title, e.g. a Badge. */
badge?: React.ReactNode;
}
/** A row of the Settings list: muted icon, 15px title, 13px current value. */
export function SettingsRow({ icon: Icon, title, value, onPress, disabled, selected, badge }: SettingsRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={value ? `${title}, ${value}` : title}
accessibilityState={{ disabled, selected }}
style={({ pressed }) => [
styles.row,
disabled && styles.rowDisabled,
!disabled && pressed && styles.rowPressed,
selected && styles.rowSelected,
]}
>
<Icon size={20} color={c.mutedForeground} />
<View style={styles.rowText}>
<View style={styles.rowTitleLine}>
<Text style={styles.rowTitle} numberOfLines={1}>{title}</Text>
{badge}
</View>
{value ? <Text style={styles.rowValue} numberOfLines={1}>{value}</Text> : null}
</View>
</Pressable>
);
}
/** A group label in the Settings list: title case, 13px medium, muted. */
export function SettingsGroupLabel({ children }: { children: string }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return <Text style={styles.groupLabel} accessibilityRole="header">{children}</Text>;
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (v: boolean) => void;
@@ -151,6 +226,7 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps)
accessibilityRole="radio"
accessibilityState={{ selected, checked: selected }}
accessibilityLabel={opt.label}
hitSlop={{ top: 10, bottom: 10 }}
style={[
styles.radioOption,
selected ? styles.radioSelected : styles.radioUnselected,
@@ -185,14 +261,15 @@ export function Select({ value, onChange, options, style }: SelectProps) {
return (
<>
<Pressable
style={[styles.select, style]}
style={({ pressed }) => [styles.select, pressed && styles.selectPressed, style]}
onPress={() => setOpen(true)}
hitSlop={6}
accessibilityRole="combobox"
accessibilityLabel={current?.label ?? ''}
accessibilityState={{ expanded: open }}
>
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={c.mutedForeground} />
<ChevronDown size={14} color={c.text} />
</Pressable>
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={open}
@@ -235,19 +312,20 @@ export function Select({ value, onChange, options, style }: SelectProps) {
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
section: {
gap: spacing.lg,
},
sectionBody: {
gap: spacing.lg,
gap: spacing.xs,
},
// Rows sit flush; the border under each one separates them.
sectionBody: { marginTop: spacing.xs },
sectionTitle: {
...typography.h3,
fontSize: 18,
fontWeight: '500',
lineHeight: 26,
color: c.text,
},
sectionDescription: {
...typography.body,
color: c.mutedForeground,
marginTop: 4,
marginTop: 2,
},
experimentalBanner: {
flexDirection: 'row',
@@ -269,11 +347,15 @@ function makeStyles(c: ThemePalette) {
},
settingItem: {
flexDirection: 'row',
alignItems: 'flex-start',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 48,
paddingVertical: spacing.md,
gap: spacing.md,
},
settingItemPressed: {
backgroundColor: c.surfaceHover,
},
settingItemBorder: {
borderBottomWidth: 1,
borderBottomColor: c.border,
@@ -283,7 +365,6 @@ function makeStyles(c: ThemePalette) {
},
settingContent: {
flex: 1,
paddingRight: spacing.lg,
},
settingLabelRow: {
flexDirection: 'row',
@@ -297,7 +378,7 @@ function makeStyles(c: ThemePalette) {
settingDescription: {
...typography.caption,
color: c.mutedForeground,
marginTop: 2,
marginTop: 3,
},
settingRight: {
flexShrink: 0,
@@ -340,18 +421,44 @@ function makeStyles(c: ThemePalette) {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: 6,
minHeight: 36,
paddingHorizontal: 10,
borderRadius: radius.sm,
backgroundColor: c.muted,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: c.border,
minWidth: 120,
},
selectPressed: {
backgroundColor: c.muted,
},
selectText: {
...typography.body,
color: c.text,
flex: 1,
flexShrink: 1,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
minHeight: 56,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
},
rowPressed: { backgroundColor: c.surfaceHover },
rowSelected: { backgroundColor: c.muted },
rowDisabled: { opacity: 0.55 },
rowText: { flex: 1, gap: 2 },
rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
rowTitle: { fontSize: 15, lineHeight: 20, color: c.text, flexShrink: 1 },
rowValue: { fontSize: 13, lineHeight: 18, color: c.mutedForeground },
groupLabel: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
color: c.mutedForeground,
paddingHorizontal: spacing.lg,
paddingTop: spacing.lg,
paddingBottom: spacing.xs,
},
modalBackdrop: {
flex: 1,
+1 -1
View File
@@ -326,6 +326,6 @@ export const componentSizes = {
headerHeight: 56, inputHeight: 40,
buttonSm: 36, buttonMd: 40, buttonLg: 44,
tagDot: 6, eventDot: 6,
toggleHeight: 24, toggleWidth: 44, toggleThumb: 16,
toggleHeight: 24, toggleWidth: 44, toggleThumb: 20,
fab: 56, badgeSize: 16,
} as const;