authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 22:31:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 22:31:14-07:00
loga39fd7be954379dfedab0f1d1e2fd8c02d748e9b
tree65cf2782350d836cf42e83cbb7b336a9375a948e
parent679b03e1c680d983cf89f5162c545342d6036ad3
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): share themed list rows and sections (#18510)


5 files changed, 174 insertions(+), 167 deletions(-)

apps/mobile/src/components/ListRow.tsx created+36
......@@ -0,0 +1,36 @@
1import { Platform } from "react-native";
2
3import { useResolveClassNames } from "uniwind";
4import { SymbolView } from "./AppSymbol";
5import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout";
6import { RowPressable } from "./RowPressable";
7
8/** A navigation row owns its look; callers supply content and destination behavior. */
9export 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 @@
1import type { ReactNode } from "react";
2import { Platform, useWindowDimensions, View } from "react-native";
3
4import { cn } from "../lib/cn";
5import { AppText } from "./AppText";
6import { SymbolView, type AppSymbolName } from "./AppSymbol";
7
8export 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. */
17export 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 @@
1import type { ReactNode } from "react";
2import { Platform, View } from "react-native";
3
4import { AppText as Text } from "./AppText";
5
6export 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 @@
1import { MaterialListRow } from "../../../components/MaterialListRow";
21import { useNavigation } from "@react-navigation/native";
32import type { ComponentProps } from "react";
4import { Platform, Pressable, View } from "react-native";
3import { Platform } from "react-native";
54
6import { SymbolView } from "../../../components/AppSymbol";
7import { AppText as Text } from "../../../components/AppText";
5import { ListRow } from "../../../components/ListRow";
86import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets";
9import { cn } from "../../../lib/cn";
10
11type SymbolName = ComponentProps<typeof SymbolView>["name"];
127
138export function SettingsRow(props: {
149 readonly disabled?: boolean;
15 readonly icon: SymbolName;
10 readonly icon: ComponentProps<typeof ListRow>["icon"];
1611 readonly label: string;
1712 readonly value?: string;
1813 readonly valuePosition?: "below" | "trailing";
......@@ -21,119 +16,28 @@ export function SettingsRow(props: {
2116 readonly onPress?: () => void;
2217}) {
2318 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) {
10431 navigation.navigate("SettingsSheet", {
10532 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?.();
10839 }
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 />
13842 );
13943}
apps/mobile/src/features/settings/components/SettingsSection.tsx+1-48
......@@ -1,48 +1 @@
1import type { ReactNode } from "react";
2import { Platform, View } from "react-native";
3
4import { AppText as Text } from "../../../components/AppText";
5
6export 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}
1export { ListSection as SettingsSection } from "../../../components/ListSection";