| author | |
| committer | |
| log | a39fd7be954379dfedab0f1d1e2fd8c02d748e9b |
| tree | 65cf2782350d836cf42e83cbb7b336a9375a948e |
| parent | 679b03e1c680d983cf89f5162c545342d6036ad3 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 174 insertions(+), 167 deletions(-)
apps/mobile/src/components/ListRow.tsx created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { Platform } from "react-native"; | ||
| 2 | |||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | import { SymbolView } from "./AppSymbol"; | ||
| 5 | import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout"; | ||
| 6 | import { RowPressable } from "./RowPressable"; | ||
| 7 | |||
| 8 | /** A navigation row owns its look; callers supply content and destination behavior. */ | ||
| 9 | export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) { | ||
| 10 | const rippleStyle = useResolveClassNames("bg-subtle-strong"); | ||
| 11 | return ( | ||
| 12 | <RowPressable | ||
| 13 | accessibilityRole="button" | ||
| 14 | accessibilityLabel={[props.label, props.description, props.detail].filter(Boolean).join(", ")} | ||
| 15 | accessibilityState={{ disabled: Boolean(props.disabled) }} | ||
| 16 | disabled={props.disabled} | ||
| 17 | onPress={props.onPress} | ||
| 18 | android_ripple={{ color: rippleStyle.backgroundColor }} | ||
| 19 | unstable_pressDelay={Platform.OS === "android" ? 50 : undefined} | ||
| 20 | className="bg-grouped-card" | ||
| 21 | > | ||
| 22 | <ListRowLayout | ||
| 23 | {...props} | ||
| 24 | accessory={ | ||
| 25 | <SymbolView | ||
| 26 | name="chevron.right" | ||
| 27 | size={16} | ||
| 28 | tintColorClassName="accent-chevron" | ||
| 29 | type="monochrome" | ||
| 30 | weight="semibold" | ||
| 31 | /> | ||
| 32 | } | ||
| 33 | /> | ||
| 34 | </RowPressable> | ||
| 35 | ); | ||
| 36 | } | ||
apps/mobile/src/components/ListRowLayout.tsx created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { Platform, useWindowDimensions, View } from "react-native"; | ||
| 3 | |||
| 4 | import { cn } from "../lib/cn"; | ||
| 5 | import { AppText } from "./AppText"; | ||
| 6 | import { SymbolView, type AppSymbolName } from "./AppSymbol"; | ||
| 7 | |||
| 8 | export interface ListRowContentProps { | ||
| 9 | readonly icon?: AppSymbolName; | ||
| 10 | readonly label: string; | ||
| 11 | readonly description?: string; | ||
| 12 | readonly detail?: string; | ||
| 13 | readonly disabled?: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | /** Shared row geometry; controls own their behavior and accessibility. */ | ||
| 17 | export function ListRowLayout({ | ||
| 18 | icon, | ||
| 19 | label, | ||
| 20 | description, | ||
| 21 | detail, | ||
| 22 | disabled, | ||
| 23 | accessory, | ||
| 24 | }: ListRowContentProps & { readonly accessory?: ReactNode }) { | ||
| 25 | const { fontScale } = useWindowDimensions(); | ||
| 26 | // Leave the label room to wrap at accessibility text sizes. | ||
| 27 | const stackDetail = fontScale >= 1.5; | ||
| 28 | return ( | ||
| 29 | <View | ||
| 30 | className={cn( | ||
| 31 | "min-h-14 flex-row items-center gap-3 px-4 py-3", | ||
| 32 | description && "min-h-18", | ||
| 33 | disabled && "opacity-45", | ||
| 34 | )} | ||
| 35 | > | ||
| 36 | {icon ? ( | ||
| 37 | <View className="size-6 items-center justify-center"> | ||
| 38 | <SymbolView | ||
| 39 | name={icon} | ||
| 40 | size={Platform.OS === "android" ? 24 : 22} | ||
| 41 | tintColorClassName="accent-icon" | ||
| 42 | type="monochrome" | ||
| 43 | weight="regular" | ||
| 44 | /> | ||
| 45 | </View> | ||
| 46 | ) : null} | ||
| 47 | <View className="min-w-0 flex-1 gap-1"> | ||
| 48 | <AppText className="text-base text-foreground">{label}</AppText> | ||
| 49 | {description ? ( | ||
| 50 | <AppText className="text-sm text-foreground-muted">{description}</AppText> | ||
| 51 | ) : null} | ||
| 52 | {detail && stackDetail ? ( | ||
| 53 | <AppText className="text-sm text-foreground-muted">{detail}</AppText> | ||
| 54 | ) : null} | ||
| 55 | </View> | ||
| 56 | {detail && !stackDetail ? ( | ||
| 57 | <AppText | ||
| 58 | className="max-w-[40%] shrink text-right text-sm text-foreground-muted" | ||
| 59 | numberOfLines={1} | ||
| 60 | ellipsizeMode="middle" | ||
| 61 | > | ||
| 62 | {detail} | ||
| 63 | </AppText> | ||
| 64 | ) : null} | ||
| 65 | {accessory} | ||
| 66 | </View> | ||
| 67 | ); | ||
| 68 | } | ||
apps/mobile/src/components/ListSection.tsx created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { Platform, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "./AppText"; | ||
| 5 | |||
| 6 | export function ListSection(props: { | ||
| 7 | readonly title?: string; | ||
| 8 | readonly titleIcon?: ReactNode; | ||
| 9 | readonly trailing?: ReactNode; | ||
| 10 | readonly children: ReactNode; | ||
| 11 | readonly footer?: string; | ||
| 12 | }) { | ||
| 13 | return ( | ||
| 14 | <View className="gap-2"> | ||
| 15 | {props.title ? ( | ||
| 16 | <View className="flex-row items-center justify-between gap-3"> | ||
| 17 | <View | ||
| 18 | className={ | ||
| 19 | Platform.OS === "android" | ||
| 20 | ? "min-w-0 flex-1 flex-row items-center gap-2 px-4" | ||
| 21 | : "min-w-0 flex-1 flex-row items-center gap-2 px-2" | ||
| 22 | } | ||
| 23 | > | ||
| 24 | {props.titleIcon} | ||
| 25 | <Text className="shrink text-sm font-t3-medium text-foreground-muted"> | ||
| 26 | {props.title} | ||
| 27 | </Text> | ||
| 28 | </View> | ||
| 29 | {props.trailing} | ||
| 30 | </View> | ||
| 31 | ) : null} | ||
| 32 | <View | ||
| 33 | className={ | ||
| 34 | Platform.OS === "android" | ||
| 35 | ? "overflow-hidden rounded-[28px] bg-grouped-card" | ||
| 36 | : "overflow-hidden rounded-[24px] border-continuous bg-grouped-card" | ||
| 37 | } | ||
| 38 | > | ||
| 39 | {props.children} | ||
| 40 | </View> | ||
| 41 | {props.footer ? ( | ||
| 42 | <Text className="px-4 text-sm text-foreground-muted">{props.footer}</Text> | ||
| 43 | ) : null} | ||
| 44 | </View> | ||
| 45 | ); | ||
| 46 | } | ||
apps/mobile/src/features/settings/components/SettingsRow.tsx+23-119| ... | @@ -1,18 +1,13 @@ | ... | @@ -1,18 +1,13 @@ |
| 1 | import { MaterialListRow } from "../../../components/MaterialListRow"; | ||
| 2 | import { useNavigation } from "@react-navigation/native"; | 1 | import { useNavigation } from "@react-navigation/native"; |
| 3 | import type { ComponentProps } from "react"; | 2 | import type { ComponentProps } from "react"; |
| 4 | import { Platform, Pressable, View } from "react-native"; | 3 | import { Platform } from "react-native"; |
| 5 | 4 | ||
| 6 | import { SymbolView } from "../../../components/AppSymbol"; | 5 | import { ListRow } from "../../../components/ListRow"; |
| 7 | import { AppText as Text } from "../../../components/AppText"; | ||
| 8 | import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; | 6 | import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets"; |
| 9 | import { cn } from "../../../lib/cn"; | ||
| 10 | |||
| 11 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 12 | 7 | ||
| 13 | export function SettingsRow(props: { | 8 | export function SettingsRow(props: { |
| 14 | readonly disabled?: boolean; | 9 | readonly disabled?: boolean; |
| 15 | readonly icon: SymbolName; | 10 | readonly icon: ComponentProps<typeof ListRow>["icon"]; |
| 16 | readonly label: string; | 11 | readonly label: string; |
| 17 | readonly value?: string; | 12 | readonly value?: string; |
| 18 | readonly valuePosition?: "below" | "trailing"; | 13 | readonly valuePosition?: "below" | "trailing"; |
| ... | @@ -21,119 +16,28 @@ export function SettingsRow(props: { | ... | @@ -21,119 +16,28 @@ export function SettingsRow(props: { |
| 21 | readonly onPress?: () => void; | 16 | readonly onPress?: () => void; |
| 22 | }) { | 17 | }) { |
| 23 | const navigation = useNavigation(); | 18 | const navigation = useNavigation(); |
| 24 | if (Platform.OS === "android") { | 19 | const valueBelow = |
| 25 | return ( | 20 | props.valuePosition === "below" || |
| 26 | <MaterialListRow | 21 | (props.valuePosition === undefined && Platform.OS === "android"); |
| 27 | className="bg-grouped-card" | 22 | return ( |
| 28 | title={props.label} | 23 | <ListRow |
| 29 | subtitle={props.valuePosition === "trailing" ? undefined : props.value} | 24 | icon={props.icon} |
| 30 | accessibilityLabel={[props.label, props.value].filter(Boolean).join(", ")} | 25 | label={props.label} |
| 31 | trailing={ | 26 | description={valueBelow ? props.value : undefined} |
| 32 | props.valuePosition === "trailing" && props.value ? ( | 27 | detail={valueBelow ? undefined : props.value} |
| 33 | <View className="flex-row items-center gap-3"> | 28 | disabled={props.disabled} |
| 34 | <Text className="text-sm text-foreground-muted">{props.value}</Text> | 29 | onPress={() => { |
| 35 | <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" /> | 30 | if (props.target) { |
| 36 | </View> | ||
| 37 | ) : undefined | ||
| 38 | } | ||
| 39 | disabled={props.disabled} | ||
| 40 | leading={ | ||
| 41 | <SymbolView | ||
| 42 | name={props.icon} | ||
| 43 | size={24} | ||
| 44 | tintColorClassName="accent-icon" | ||
| 45 | type="monochrome" | ||
| 46 | weight="regular" | ||
| 47 | /> | ||
| 48 | } | ||
| 49 | onPress={() => { | ||
| 50 | if (props.target) | ||
| 51 | navigation.navigate("SettingsSheet", { | ||
| 52 | screen: "SettingsContent", | ||
| 53 | params: { screen: props.target }, | ||
| 54 | }); | ||
| 55 | else if (props.fullScreenTarget) navigation.navigate(props.fullScreenTarget); | ||
| 56 | else props.onPress?.(); | ||
| 57 | }} | ||
| 58 | /> | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | const content = ( | ||
| 62 | <View className={cn("flex-row items-center gap-4 p-4", props.disabled && "opacity-[0.45]")}> | ||
| 63 | <SymbolView | ||
| 64 | name={props.icon} | ||
| 65 | size={22} | ||
| 66 | tintColorClassName="accent-icon" | ||
| 67 | type="monochrome" | ||
| 68 | weight="regular" | ||
| 69 | /> | ||
| 70 | <> | ||
| 71 | <Text className="shrink-0 text-lg text-foreground" numberOfLines={1}> | ||
| 72 | {props.label} | ||
| 73 | </Text> | ||
| 74 | <View className="min-w-0 flex-1 items-end"> | ||
| 75 | {props.value ? ( | ||
| 76 | <Text | ||
| 77 | className="max-w-[180px] text-right text-base text-foreground-muted" | ||
| 78 | ellipsizeMode="middle" | ||
| 79 | numberOfLines={1} | ||
| 80 | > | ||
| 81 | {props.value} | ||
| 82 | </Text> | ||
| 83 | ) : null} | ||
| 84 | </View> | ||
| 85 | </> | ||
| 86 | <SymbolView | ||
| 87 | name="chevron.right" | ||
| 88 | size={16} | ||
| 89 | tintColorClassName="accent-chevron" | ||
| 90 | type="monochrome" | ||
| 91 | weight="semibold" | ||
| 92 | /> | ||
| 93 | </View> | ||
| 94 | ); | ||
| 95 | |||
| 96 | const target = props.target; | ||
| 97 | if (target) { | ||
| 98 | return ( | ||
| 99 | <Pressable | ||
| 100 | accessibilityLabel={props.label} | ||
| 101 | accessibilityRole="button" | ||
| 102 | disabled={props.disabled} | ||
| 103 | onPress={() => | ||
| 104 | navigation.navigate("SettingsSheet", { | 31 | navigation.navigate("SettingsSheet", { |
| 105 | screen: "SettingsContent", | 32 | screen: "SettingsContent", |
| 106 | params: { screen: target }, | 33 | params: { screen: props.target }, |
| 107 | }) | 34 | }); |
| 35 | } else if (props.fullScreenTarget) { | ||
| 36 | navigation.navigate(props.fullScreenTarget); | ||
| 37 | } else { | ||
| 38 | props.onPress?.(); | ||
| 108 | } | 39 | } |
| 109 | > | 40 | }} |
| 110 | {content} | 41 | /> |
| 111 | </Pressable> | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 115 | const fullScreenTarget = props.fullScreenTarget; | ||
| 116 | if (fullScreenTarget) { | ||
| 117 | return ( | ||
| 118 | <Pressable | ||
| 119 | accessibilityLabel={props.label} | ||
| 120 | accessibilityRole="button" | ||
| 121 | disabled={props.disabled} | ||
| 122 | onPress={() => navigation.navigate(fullScreenTarget)} | ||
| 123 | > | ||
| 124 | {content} | ||
| 125 | </Pressable> | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | |||
| 129 | return ( | ||
| 130 | <Pressable | ||
| 131 | accessibilityLabel={props.label} | ||
| 132 | accessibilityRole="button" | ||
| 133 | disabled={props.disabled} | ||
| 134 | onPress={props.onPress} | ||
| 135 | > | ||
| 136 | {content} | ||
| 137 | </Pressable> | ||
| 138 | ); | 42 | ); |
| 139 | } | 43 | } |
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-48| ... | @@ -1,48 +1 @@ | ... | @@ -1,48 +1 @@ |
| 1 | import type { ReactNode } from "react"; | 1 | export { ListSection as SettingsSection } from "../../../components/ListSection"; |
| 2 | import { Platform, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../../components/AppText"; | ||
| 5 | |||
| 6 | export function SettingsSection(props: { | ||
| 7 | readonly title?: string; | ||
| 8 | readonly titleIcon?: ReactNode; | ||
| 9 | readonly trailing?: ReactNode; | ||
| 10 | readonly children: ReactNode; | ||
| 11 | }) { | ||
| 12 | return ( | ||
| 13 | <View className="gap-2"> | ||
| 14 | {props.title ? ( | ||
| 15 | <View className="flex-row items-center justify-between gap-3"> | ||
| 16 | <View | ||
| 17 | className={ | ||
| 18 | Platform.OS === "android" | ||
| 19 | ? "min-w-0 flex-1 flex-row items-center gap-2 px-4" | ||
| 20 | : "min-w-0 flex-1 flex-row items-center gap-2 px-2" | ||
| 21 | } | ||
| 22 | > | ||
| 23 | {props.titleIcon} | ||
| 24 | <Text | ||
| 25 | className={ | ||
| 26 | Platform.OS === "android" | ||
| 27 | ? "shrink text-sm font-t3-medium text-primary-text" | ||
| 28 | : "shrink text-sm font-t3-medium text-foreground-muted" | ||
| 29 | } | ||
| 30 | > | ||
| 31 | {props.title} | ||
| 32 | </Text> | ||
| 33 | </View> | ||
| 34 | {props.trailing} | ||
| 35 | </View> | ||
| 36 | ) : null} | ||
| 37 | <View | ||
| 38 | className={ | ||
| 39 | Platform.OS === "android" | ||
| 40 | ? "overflow-hidden rounded-[28px] bg-grouped-card" | ||
| 41 | : "overflow-hidden rounded-[24px] border-continuous bg-grouped-card" | ||
| 42 | } | ||
| 43 | > | ||
| 44 | {props.children} | ||
| 45 | </View> | ||
| 46 | </View> | ||
| 47 | ); | ||
| 48 | } |