From 0a176ab51c8f2b5e32a10f02c65a2caed1b267de Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:49:37 +0200 Subject: [PATCH] feat: draw buttons, switches, selects, inputs and badges like the webmail --- src/components/Badge.tsx | 28 +-- src/components/Button.tsx | 70 +++++--- src/components/Input.tsx | 25 ++- src/components/ToggleSwitch.tsx | 18 +- src/components/settings/DownloadsSettings.tsx | 2 +- src/components/settings/KeywordSettings.tsx | 1 + src/components/settings/UpdatesSettings.tsx | 2 +- src/components/settings/settings-section.tsx | 163 +++++++++++++++--- src/theme/tokens.ts | 2 +- 9 files changed, 222 insertions(+), 89 deletions(-) diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index c0432812..9083a1ae 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -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 ( - + {children} @@ -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 }, }); } - diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 89037e7b..78820fee 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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 = { sm: 6, md: 4, lg: 2, icon: 4 }; +// Extra touch area so every size reaches a 48 dp target. +const HIT_SLOP: Record = { 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 ( {loading ? ( - + ) : ( <> {icon} {typeof children === 'string' ? ( - + {children} ) : ( @@ -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 }, }); } diff --git a/src/components/Input.tsx b/src/components/Input.tsx index ca64524e..ce3e8b93 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -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, diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx index c80a993f..2d041d8f 100644 --- a/src/components/ToggleSwitch.tsx +++ b/src/components/ToggleSwitch.tsx @@ -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 @@ -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: { diff --git a/src/components/settings/DownloadsSettings.tsx b/src/components/settings/DownloadsSettings.tsx index 604b1744..0f1688e9 100644 --- a/src/components/settings/DownloadsSettings.tsx +++ b/src/components/settings/DownloadsSettings.tsx @@ -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: { diff --git a/src/components/settings/KeywordSettings.tsx b/src/components/settings/KeywordSettings.tsx index 32bda22e..ce83d72e 100644 --- a/src/components/settings/KeywordSettings.tsx +++ b/src/components/settings/KeywordSettings.tsx @@ -317,6 +317,7 @@ function makeStyles(c: ThemePalette) { }, bottomActions: { flexDirection: 'row', + flexWrap: 'wrap', gap: spacing.sm, paddingTop: spacing.sm, }, diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx index 6a36f421..0ec63570 100644 --- a/src/components/settings/UpdatesSettings.tsx +++ b/src/components/settings/UpdatesSettings.tsx @@ -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, diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx index e208f876..39a7dab4 100644 --- a/src/components/settings/settings-section.tsx +++ b/src/components/settings/settings-section.tsx @@ -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 ( - + const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight]; + const body = ( + <> {label} @@ -106,10 +110,81 @@ export function SettingItem({ {children} )} + {onPress && (link === 'external' + ? + : )} + + ); + if (onPress) { + return ( + [...rowStyle, pressed && styles.settingItemPressed]} + > + {body} + + ); + } + return ( + + {body} ); } +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 ( + [ + styles.row, + disabled && styles.rowDisabled, + !disabled && pressed && styles.rowPressed, + selected && styles.rowSelected, + ]} + > + + + + {title} + {badge} + + {value ? {value} : null} + + + ); +} + +/** 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 {children}; +} + 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 ( <> [styles.select, pressed && styles.selectPressed, style]} onPress={() => setOpen(true)} + hitSlop={6} accessibilityRole="combobox" accessibilityLabel={current?.label ?? ''} accessibilityState={{ expanded: open }} > {current?.label ?? ''} - +