| 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 | import { MaterialListRow } from "../../../components/MaterialListRow"; | |
| 2 | 1 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 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"; | |
| 7 | import { AppText as Text } from "../../../components/AppText"; | |
| 5 | import { ListRow } from "../../../components/ListRow"; | |
| 8 | 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 | 8 | export function SettingsRow(props: { |
| 14 | 9 | readonly disabled?: boolean; |
| 15 | readonly icon: SymbolName; | |
| 10 | readonly icon: ComponentProps<typeof ListRow>["icon"]; | |
| 16 | 11 | readonly label: string; |
| 17 | 12 | readonly value?: string; |
| 18 | 13 | readonly valuePosition?: "below" | "trailing"; |
| ... | ... | @@ -21,119 +16,28 @@ export function SettingsRow(props: { |
| 21 | 16 | readonly onPress?: () => void; |
| 22 | 17 | }) { |
| 23 | 18 | const navigation = useNavigation(); |
| 24 | if (Platform.OS === "android") { | |
| 25 | return ( | |
| 26 | <MaterialListRow | |
| 27 | className="bg-grouped-card" | |
| 28 | title={props.label} | |
| 29 | subtitle={props.valuePosition === "trailing" ? undefined : props.value} | |
| 30 | accessibilityLabel={[props.label, props.value].filter(Boolean).join(", ")} | |
| 31 | trailing={ | |
| 32 | props.valuePosition === "trailing" && props.value ? ( | |
| 33 | <View className="flex-row items-center gap-3"> | |
| 34 | <Text className="text-sm text-foreground-muted">{props.value}</Text> | |
| 35 | <SymbolView name="chevron.right" size={16} tintColorClassName="accent-chevron" /> | |
| 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={() => | |
| 19 | const valueBelow = | |
| 20 | props.valuePosition === "below" || | |
| 21 | (props.valuePosition === undefined && Platform.OS === "android"); | |
| 22 | return ( | |
| 23 | <ListRow | |
| 24 | icon={props.icon} | |
| 25 | label={props.label} | |
| 26 | description={valueBelow ? props.value : undefined} | |
| 27 | detail={valueBelow ? undefined : props.value} | |
| 28 | disabled={props.disabled} | |
| 29 | onPress={() => { | |
| 30 | if (props.target) { | |
| 104 | 31 | navigation.navigate("SettingsSheet", { |
| 105 | 32 | screen: "SettingsContent", |
| 106 | params: { screen: target }, | |
| 107 | }) | |
| 33 | params: { screen: props.target }, | |
| 34 | }); | |
| 35 | } else if (props.fullScreenTarget) { | |
| 36 | navigation.navigate(props.fullScreenTarget); | |
| 37 | } else { | |
| 38 | props.onPress?.(); | |
| 108 | 39 | } |
| 109 | > | |
| 110 | {content} | |
| 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> | |
| 40 | }} | |
| 41 | /> | |
| 138 | 42 | ); |
| 139 | 43 | } |
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-48| ... | ... | @@ -1,48 +1 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 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 | } | |
| 1 | export { ListSection as SettingsSection } from "../../../components/ListSection"; |