Skip to content
Source
Sign inSign up

Dialog.android.tsx

288 lines10.1 KB

1import { NativeThemeHost } from "./NativeThemeHost.android";
2import {
3 AlertDialog,
4 Button,
5 OutlinedButton,
6 Icon,
7 FlowRow,
8 Column,
9 OutlinedTextField,
10 Text,
11 useNativeState,
12} from "@expo/ui/jetpack-compose";
13import { verticalScroll } from "@expo/ui/jetpack-compose/modifiers";
14​
15import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
16import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole";
17​
18import { useSyncExternalStore } from "react";
19import { themeColorWithContrast } from "../../lib/mobileTheme";
20import type { Alert, AlertButton } from "react-native";
21import type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types";
22​
23export type { ConfirmDialogRequest, TextInputDialogRequest } from "./Dialog.types";
24​
25type AlertRequest = {
26 readonly title: Parameters<typeof Alert.alert>[0];
27 readonly message: Parameters<typeof Alert.alert>[1];
28 readonly buttons: Parameters<typeof Alert.alert>[2];
29 readonly options: Parameters<typeof Alert.alert>[3];
30};
31​
32type DialogRequest =
33 | { readonly kind: "alert"; readonly request: AlertRequest }
34 | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest }
35 | { readonly kind: "text-input"; readonly request: TextInputDialogRequest };
36​
37type PresentedDialog = DialogRequest & { readonly id: number };
38​
39// App services raise dialogs before the root mounts, so the request lives in
40// the module and the Host renders whatever is current. One dialog shows at a
41// time: a new request replaces the presented one.
42let presented: PresentedDialog | null = null;
43let nextPresentationId = 0;
44const listeners = new Set<() => void>();
45​
46function setPresented(next: PresentedDialog | null) {
47 presented = next;
48 for (const listener of listeners) listener();
50​
51function submitRequest(dialog: DialogRequest) {
52 setPresented({ ...dialog, id: ++nextPresentationId });
54​
55/** Closes the dialog only if it is still the one the action came from. */
56function dismiss(id: number) {
57 if (presented?.id === id) setPresented(null);
59​
60function subscribe(listener: () => void) {
61 listeners.add(listener);
62 return () => {
63 listeners.delete(listener);
64 };
66​
67function getPresented() {
68 return presented;
70​
71export function alert(...[title, message, buttons, options]: Parameters<typeof Alert.alert>): void {
72 const actions = buttons?.length ? buttons.slice(0, 3) : [{ text: "OK" }];
73 const normalized = actions.map((action, index) =>
74 index === actions.length - 1 ? { ...action, text: action.text || "OK" } : action,
75 );
76 submitRequest({
77 kind: "alert",
78 request: { title, message, buttons: normalized, options },
79 });
81​
82export function confirm(request: ConfirmDialogRequest): void {
83 submitRequest({ kind: "confirm", request });
85​
86export function prompt(request: TextInputDialogRequest): void {
87 submitRequest({ kind: "text-input", request });
89​
90export function Host() {
91 const presented = useSyncExternalStore(subscribe, getPresented, getPresented);
92​
93 const handleCancel = () => {
94 if (!presented) return;
95 dismiss(presented.id);
96 if (presented.kind === "alert") presented.request.options?.onDismiss?.();
97 else presented.request.onCancel?.();
98 };
99​
100 const handleConfirm = (nativeInputValue?: string) => {
101 if (!presented) return;
102 dismiss(presented.id);
103 if (presented.kind === "confirm") {
104 presented.request.onConfirm();
105 } else if (presented.kind === "text-input") {
106 presented.request.onConfirm(nativeInputValue ?? presented.request.initialValue);
107 }
108 };
109​
110 if (presented?.kind === "alert") {
111 return (
112 <DialogPresentation
113 key={presented.id}
114 request={{
115 title: presented.request.title ?? "",
116 message: presented.request.message ?? undefined,
117 confirmText: "",
118 }}
119 alertActions={presented.request.buttons ?? [{ text: "OK" }]}
120 dismissable={presented.request.options?.cancelable ?? false}
121 onAlertAction={(action) => {
122 dismiss(presented.id);
123 action.onPress?.();
124 }}
125 onCancel={handleCancel}
126 onConfirm={handleConfirm}
127 />
128 );
129 }
130​
131 return presented ? (
132 <DialogPresentation
133 key={presented.id}
134 request={presented.request}
135 inputInitialValue={
136 presented.kind === "text-input" ? presented.request.initialValue : undefined
137 }
138 inputPlaceholder={presented.kind === "text-input" ? presented.request.placeholder : undefined}
139 onCancel={handleCancel}
140 onConfirm={handleConfirm}
141 />
142 ) : null;
144​
145function DialogPresentation(props: DialogPresentationProps) {
146 const { themeVariables: colors } = useAppearancePreferences();
147 const titleTypography = useScaledTextRole("title");
148 const bodyTypography = useScaledTextRole("footnote");
149 const inputTypography = useScaledTextRole("body");
150 const inputState = useNativeState(props.inputInitialValue ?? "");
151 const inputSelection = useNativeState({ start: 0, end: props.inputInitialValue?.length ?? 0 });
152 const confirm = () => {
153 const value = props.inputInitialValue === undefined ? undefined : inputState.get();
154 props.onConfirm(value);
155 };
156 const actions = props.alertActions ?? [
157 { text: props.request.cancelText ?? "Cancel", onPress: props.onCancel },
158 {
159 text: props.request.confirmText,
160 style: props.request.destructive ? ("destructive" as const) : ("default" as const),
161 onPress: confirm,
162 },
163 ];
164 const destructive = actions.some((action) => action.style === "destructive");
165 // Error text colors can be pastel in dark themes. Filled actions need a white-label contrast pair.
166 const destructiveFill = themeColorWithContrast(colors["--color-danger-foreground"], "#ffffff");
167 const renderAction = (action: AlertButton, index: number) => {
168 const isDestructive = action.style === "destructive";
169 const isPrimary = isDestructive || index === actions.length - 1;
170 const Component = isPrimary ? Button : OutlinedButton;
171 return (
172 <Component
173 key={index}
174 onClick={() => (props.alertActions ? props.onAlertAction?.(action) : action.onPress?.())}
175 colors={{
176 containerColor: isDestructive
177 ? destructiveFill
178 : isPrimary
179 ? colors["--color-prominent-action"]
180 : "#00000000",
181 contentColor: isDestructive
182 ? "#ffffff"
183 : isPrimary
184 ? colors["--color-prominent-action-foreground"]
185 : colors["--color-foreground"],
186 }}
187 >
188 <Text style={bodyTypography}>{action.text ?? ""}</Text>
189 </Component>
190 );
191 };
192 return (
193 <NativeThemeHost ignoreSafeAreaKeyboardInsets style={{ height: 0, width: 0 }}>
194 <AlertDialog
195 onDismissRequest={props.onCancel}
196 properties={{
197 dismissOnBackPress: props.dismissable ?? true,
198 dismissOnClickOutside: props.dismissable ?? true,
199 }}
200 tonalElevation={0}
201 colors={{
202 containerColor: colors["--color-card"],
203 titleContentColor: colors["--color-foreground"],
204 textContentColor: colors["--color-foreground-secondary"],
205 }}
206 >
207 <AlertDialog.Title>
208 <Column verticalArrangement={{ spacedBy: 16 }}>
209 {destructive ? (
210 <Icon
211 source={require("../../../assets/icons/delete.xml")}
212 tint={colors["--color-danger-foreground"]}
213 size={24}
214 />
215 ) : null}
216 <Text style={titleTypography}>{props.request.title}</Text>
217 </Column>
218 </AlertDialog.Title>
219 {props.inputInitialValue !== undefined ? (
220 <AlertDialog.Text>
221 <Column verticalArrangement={{ spacedBy: 16 }}>
222 {props.request.message ? (
223 <Text style={bodyTypography}>{props.request.message}</Text>
224 ) : null}
225 <OutlinedTextField
226 autoFocus
227 singleLine
228 value={inputState}
229 selection={inputSelection}
230 textStyle={inputTypography}
231 keyboardOptions={{ imeAction: "done" }}
232 keyboardActions={{
233 onDone: confirm,
234 }}
235 colors={{
236 focusedTextColor: colors["--color-foreground"],
237 unfocusedTextColor: colors["--color-foreground"],
238 focusedIndicatorColor: colors["--color-focus"],
239 unfocusedIndicatorColor: colors["--color-border"],
240 cursorColor: colors["--color-focus"],
241 }}
242 >
243 <OutlinedTextField.Label>
244 <Text style={bodyTypography}>{props.request.title}</Text>
245 </OutlinedTextField.Label>
246 {props.inputPlaceholder ? (
247 <OutlinedTextField.Placeholder>
248 <Text style={bodyTypography}>{props.inputPlaceholder}</Text>
249 </OutlinedTextField.Placeholder>
250 ) : null}
251 </OutlinedTextField>
252 </Column>
253 </AlertDialog.Text>
254 ) : props.request.message ? (
255 <AlertDialog.Text>
256 <Text modifiers={[verticalScroll()]} style={bodyTypography}>
257 {props.request.message}
258 </Text>
259 </AlertDialog.Text>
260 ) : null}
261 {actions.length > 1 ? (
262 <AlertDialog.DismissButton>
263 <FlowRow>{actions.slice(0, -1).map(renderAction)}</FlowRow>
264 </AlertDialog.DismissButton>
265 ) : null}
266 {actions.length > 0 ? (
267 <AlertDialog.ConfirmButton>
268 {renderAction(actions[actions.length - 1]!, actions.length - 1)}
269 </AlertDialog.ConfirmButton>
270 ) : null}
271 </AlertDialog>
272 </NativeThemeHost>
273 );
275​
276interface DialogPresentationProps {
277 readonly alertActions?: readonly AlertButton[];
278 readonly onAlertAction?: (action: AlertButton) => void;
279 readonly dismissable?: boolean;
280 readonly request: Pick<
281 ConfirmDialogRequest,
282 "title" | "message" | "cancelText" | "confirmText" | "destructive"
283 >;
284 readonly inputInitialValue?: string;
285 readonly inputPlaceholder?: string;
286 readonly onCancel: () => void;
287 readonly onConfirm: (inputValue?: string) => void;