Skip to content
Source
Sign inSign up

AppBar.tsx

280 lines11.3 KB

1import { Toolbar } from "./Toolbar";
2import { AppActivityIndicator } from "./AppActivityIndicator";
3import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens";
4import { NavigationContext } from "@react-navigation/native";
5import { use, useId, useLayoutEffect, useRef } from "react";
6import { View } from "react-native";
7import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
8import { ScreenHeaderButton } from "./ScreenHeaderButton";
9import { NativeStackScreenOptions, useScreenOptionsWriter } from "../../native/StackHeader";
10import { useNativeWorkspaceColumnsSupported } from "../../native/NativeWorkspaceColumns";
11import { useDeviceType } from "../../native/native-layout-metrics";
12import { isDuoDevice } from "../../lib/device-type";
13import { useAdaptiveWorkspaceLayout } from "../../features/layout/AdaptiveWorkspaceLayout";
14import { useSidebarToggle } from "../../features/layout/useSidebarToggle";
15import { InspectorCloseContext } from "../../features/layout/inspector-bar-store";
16import {
17 createNativeMailSearchToolbarItem,
18 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
19} from "../../features/layout/native-mail-search-toolbar";
20import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
21import type { AppBarMenuItem, AppBarProps } from "./AppBar.types";
22import type { AppSymbolName } from "./AppSymbol";
23import { useNativeMailSearchToolbar } from "../../native/use-native-mail-search-toolbar";
24import { useControlTheme } from "./useControlTheme";
25​
26function iosIcon(icon: AppSymbolName) {
27 return typeof icon === "string" ? icon : icon.ios;
29​
30type MailMenu = NonNullable<HeaderBarButtonMailSearchToolbarItem["filterMenu"]>;
31function mailMenuItems(items: ReadonlyArray<AppBarMenuItem>): MailMenu["items"] {
32 return items.map((item) =>
33 "items" in item
34 ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) }
35 : {
36 type: "action",
37 title: item.title,
38 state: item.selected ? "on" : "off",
39 disabled: item.disabled,
40 onPress: item.onPress,
41 },
42 );
44​
45export function AppBar(props: AppBarProps) {
46 const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported();
47 const deviceType = useDeviceType();
48 const headerId = useId();
49 const { layout } = useAdaptiveWorkspaceLayout();
50 const sidebarToggle = useSidebarToggle();
51 const closeInspector = use(InspectorCloseContext);
52 const setOptions = useScreenOptionsWriter();
53 const ownsInspectorBar = closeInspector !== null && !props.embedded && !props.sheet;
54 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- an inspector pane that stops drawing its bar must not leave it in the out-of-tree inspector bar
55 useLayoutEffect(() => {
56 if (!ownsInspectorBar || !setOptions) return;
57 setOptions({ headerShown: true });
58 return () => setOptions({ headerShown: false });
59 }, [ownsInspectorBar, setOptions]);
60 const splitBackPress = props.backInSplitView?.onPress ?? props.onBack;
61 const splitBack =
62 layout.usesSplitView && props.backInSplitView && splitBackPress
63 ? { ...props.backInSplitView, onPress: splitBackPress }
64 : null;
65 const navigation = use(NavigationContext);
66 const rootBack =
67 !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false
68 ? props.onBackToList
69 : props.guardBack && props.onBack
70 ? props.onBack
71 : null;
72 const compose =
73 layout.usesSplitView && !(usesNativeWorkspaceColumns && isDuoDevice(deviceType))
74 ? props.onCompose
75 : undefined;
76 const { themeVariables } = useAppearancePreferences();
77 const { colors } = useControlTheme();
78 const usesNativeMailSearchToolbar = useNativeMailSearchToolbar();
79 const { search, menus } = props;
80 const menu = menus?.[0];
81 const searchBarRef = useRef<SearchBarCommands>(null);
82 const nativeSearchText = useRef(search?.value ?? "");
83 const searchValue = search?.value;
84 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the native search bar keeps its own text; a value the caller changes (a reset) has to be pushed into it
85 useLayoutEffect(() => {
86 if (searchValue === undefined || searchValue === nativeSearchText.current) return;
87 nativeSearchText.current = searchValue;
88 searchBarRef.current?.setText(searchValue);
89 }, [searchValue]);
90 const reportSearchText = (text: string) => {
91 nativeSearchText.current = text;
92 search?.onChangeText(text);
93 };
94 const compactSearch =
95 search !== undefined &&
96 (search.compactToolbar ?? !layout.usesSplitView) &&
97 usesNativeMailSearchToolbar;
98 const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
99 // Where the mail toolbar isn't used, the Duo keeps search at the bottom of the pane like its
100 // workspace lists; elsewhere inline search is a button in the bar.
101 const duoBottomSearch =
102 search !== undefined &&
103 !compactSearch &&
104 NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED &&
105 isDuoDevice(deviceType);
106 if (props.embedded || props.sheet) return null;
107 return (
108 <>
109 <NativeStackScreenOptions
110 optionsVersion={[props.optionsVersion, compactSearch ? menu : undefined]}
111 options={{
112 headerShown: true,
113 headerBackVisible:
114 props.onClose === undefined &&
115 splitBack === null &&
116 !(layout.usesSplitView && props.onBackToList) &&
117 !(props.guardBack && props.onBack),
118 ...(props.guardBack ? { gestureEnabled: false } : undefined),
119 title: props.title,
120 unstable_headerSubtitle: props.subtitle || undefined,
121 ...(props.matchSearchSurface
122 ? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } }
123 : undefined),
124 ...(search
125 ? {
126 unstable_headerToolbarItems: compactSearch
127 ? () => [
128 createNativeMailSearchToolbarItem({
129 showsSearchDismissButton: true,
130 placeholder: search.compactPlaceholder ?? search.placeholder,
131 onSearchTextChange: search.onChangeText,
132 searchTextChangeId: `${headerId}-search-text`,
133 ...(menu
134 ? {
135 filterButtonId: `${headerId}-filter`,
136 filterSystemImageName: iosIcon(menu.icon),
137 filterMenu: { title: menu.title, items: mailMenuItems(menu.items) },
138 }
139 : undefined),
140 }),
141 ]
142 : () => [],
143 headerSearchBarOptions: compactSearch
144 ? undefined
145 : {
146 allowToolbarIntegration: true,
147 ...(duoBottomSearch
148 ? { placement: "integrated" as const }
149 : NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline"
150 ? { placement: "integratedButton" as const }
151 : undefined),
152 autoCapitalize: "none",
153 hideNavigationBar: false,
154 ...(search.mode === "inline" || duoBottomSearch
155 ? { obscureBackground: false }
156 : undefined),
157 placeholder: search.placeholder,
158 ref: searchBarRef,
159 onChangeText: (event) => reportSearchText(event.nativeEvent.text),
160 onCancelButtonPress: () => reportSearchText(""),
161 },
162 }
163 : undefined),
164 }}
165 />
166 {props.onClose ? (
167 <Toolbar placement="left">
168 <Toolbar.Button
169 icon="xmark"
170 accessibilityLabel={props.closeAccessibilityLabel ?? "Close"}
171 onPress={props.onClose}
172 />
173 </Toolbar>
174 ) : rootBack || splitBack || sidebarToggle || compose ? (
175 <Toolbar placement="left">
176 {rootBack ? (
177 <ScreenHeaderButton
178 accessibilityLabel="Back"
179 icon="chevron.left"
180 onPress={rootBack}
181 separateBackground
182 />
183 ) : null}
184 {splitBack ? <ScreenHeaderButton {...splitBack} /> : null}
185 {sidebarToggle ? (
186 <ScreenHeaderButton
187 axisBehavior={usesNativeWorkspaceColumns ? "horizontalOnly" : undefined}
188 accessibilityLabel={sidebarToggle.accessibilityLabel}
189 icon={sidebarToggle.icon}
190 onPress={sidebarToggle.onPress}
191 separateBackground={splitBack ? splitBack.separateBackground : true}
192 />
193 ) : null}
194 {compose ? (
195 <ScreenHeaderButton
196 accessibilityLabel="New task"
197 label="New task"
198 icon="square.and.pencil"
199 onPress={compose}
200 separateBackground
201 />
202 ) : null}
203 </Toolbar>
204 ) : null}
205 {props.actions?.length ||
206 visibleMenus.length ||
207 props.trailing ||
208 props.confirm ||
209 closeInspector ? (
210 <Toolbar placement="right">
211 {props.actions?.map(({ loading, ...action }) =>
212 loading ? (
213 <Toolbar.Custom key={action.accessibilityLabel}>
214 <View
215 accessibilityLabel={action.accessibilityLabel}
216 className="size-9 items-center justify-center"
217 >
218 <AppActivityIndicator color={action.tintColor} />
219 </View>
220 </Toolbar.Custom>
221 ) : (
222 <ScreenHeaderButton
223 key={action.accessibilityLabel}
224 {...action}
225 icon={iosIcon(action.icon)}
226 separateBackground={action.separateBackground ?? true}
227 />
228 ),
229 )}
230 {visibleMenus.map(createNativeHeaderMenu)}
231 {props.trailing}
232 {props.confirm?.loading ? (
233 <Toolbar.Custom>
234 <View
235 className="size-11 items-center justify-center"
236 accessible
237 accessibilityLabel={props.confirm.label}
238 accessibilityState={{ busy: true }}
239 >
240 <AppActivityIndicator colorClassName="accent-prominent-action" />
241 </View>
242 </Toolbar.Custom>
243 ) : props.confirm ? (
244 <Toolbar.Button
245 icon="checkmark"
246 variant="prominent"
247 visibilityPriority="high"
248 tintColor={colors["--color-prominent-action"]}
249 accessibilityLabel={props.confirm.label}
250 disabled={props.confirm.disabled}
251 onPress={props.confirm.onPress}
252 />
253 ) : null}
254 {closeInspector ? (
255 <ScreenHeaderButton
256 accessibilityLabel="Close inspector"
257 icon="xmark"
258 onPress={closeInspector}
259 separateBackground
260 />
261 ) : null}
262 </Toolbar>
263 ) : null}
264 {duoBottomSearch ? (
265 <Toolbar placement="bottom">
266 <Toolbar.SearchBarSlot />
267 </Toolbar>
268 ) : null}
269 </>
270 );
272​
273export type {
274 AppBarAction,
275 AppBarConfirm,
276 AppBarMenu,
277 AppBarMenuItem,
278 AppBarProps,
279 AppBarSearch,
280} from "./AppBar.types";