1import { Box, Icon, IconButton, RNHostView } from "@expo/ui/jetpack-compose";
2import { size } from "@expo/ui/jetpack-compose/modifiers";
3import type { MenuAction } from "@react-native-menu/menu";
4import {
5 createContext,
6 use,
7 useLayoutEffect,
8 useState,
9 useSyncExternalStore,
10 type ReactNode,
11} from "react";
12import { Pressable, View } from "react-native";
13
14import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu.android";
15import { AndroidHeaderIconButton } from "./AndroidScreenHeader.android";
16import { AppText } from "./AppText";
17import { NativeAppBar } from "./NativeAppBar.android";
18import { useControlTheme } from "./useControlTheme";
19import { useToolbarLayout } from "./useToolbarLayout";
20
21const SEARCH_ICON = require("../../../assets/icons/search.xml");
22const FILTER_ICON = require("../../../assets/icons/filter.xml");
23const FILTER_FILLED_ICON = require("../../../assets/icons/filter-filled.xml");
24// Material TopAppBar: 48dp icon buttons, the last one 4dp from the end.
25const ACTION_SIZE = 48;
26const ACTION_END_INSET = 4;
27
28export interface WorkspaceAppBarProps {
29 readonly title: string;
30 readonly titleContent?: ReactNode;
31 readonly search?: {
32 readonly accessibilityLabel: string;
33 readonly active: boolean;
34 readonly field: ReactNode;
35 readonly onOpen: () => void;
36 readonly onClose: () => void;
37 };
38 /** Stays trailing while searching, so it never moves and still narrows the results. */
39 readonly filter?: {
40 readonly accessibilityLabel: string;
41 readonly customized: boolean;
42 readonly actions: MenuAction[];
43 readonly onSelect: (id: string) => void;
44 };
45}
46
47function createWorkspaceAppBarStore() {
48 let owner: object | null = null;
49 let content: WorkspaceAppBarProps | null = null;
50 const listeners = new Set<() => void>();
51 const emit = () => {
52 for (const listener of listeners) listener();
53 };
54 return {
55 subscribe: (listener: () => void) => {
56 listeners.add(listener);
57 return () => {
58 listeners.delete(listener);
59 };
60 },
61 get: () => content,
62 set: (nextOwner: object, next: WorkspaceAppBarProps) => {
63 owner = nextOwner;
64 content = next;
65 emit();
66 },
67 clear: (previousOwner: object) => {
68 if (owner !== previousOwner) return;
69 owner = null;
70 content = null;
71 emit();
72 },
73 };
74}
75
76const WorkspaceAppBarStoreContext = createContext<ReturnType<
77 typeof createWorkspaceAppBarStore
78> | null>(null);
79
80/**
81 * Hosts one app bar above a set of workspace tab panes. Hidden panes lay out at zero size, and a
82 * Compose bar inside one takes frames to measure again when it returns, so the bar lives here and
83 * the visible tab projects its contents into it.
84 */
85export function WorkspaceAppBarScope(props: {
86 readonly children: ReactNode;
87 /** Off, panes draw their own bars; the children keep their place so toggling keeps their state. */
88 readonly enabled?: boolean;
89}) {
90 const [store] = useState(createWorkspaceAppBarStore);
91 const enabled = props.enabled ?? true;
92 return (
93 <WorkspaceAppBarStoreContext value={enabled ? store : null}>
94 {enabled ? <WorkspaceAppBarHost /> : null}
95 {props.children}
96 </WorkspaceAppBarStoreContext>
97 );
98}
99
100function WorkspaceAppBarHost() {
101 const store = use(WorkspaceAppBarStoreContext)!;
102 const content = useSyncExternalStore(store.subscribe, store.get, store.get);
103 return <WorkspaceAppBarView {...(content ?? { title: "" })} />;
104}
105
106export function useHasWorkspaceAppBar() {
107 return use(WorkspaceAppBarStoreContext) !== null;
108}
109
110/** Shows a tab's header in the enclosing scope's bar, or inline outside one. */
111export function WorkspaceAppBar(props: WorkspaceAppBarProps) {
112 const store = use(WorkspaceAppBarStoreContext);
113 const [owner] = useState(() => ({}));
114 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects this tab's header into the out-of-tree workspace bar after every commit
115 useLayoutEffect(() => {
116 store?.set(owner, props);
117 });
118 // Hidden panes destroy their effects, so the tab leaving the screen releases the bar.
119 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the workspace bar when this tab hides or unmounts
120 useLayoutEffect(() => (store ? () => store.clear(owner) : undefined), [store, owner]);
121 return store ? null : <WorkspaceAppBarView {...props} />;
122}
123
124/**
125 * Every tab draws the same bar, so a switch only changes its contents. The actions are Compose nodes
126 * and the title host keeps one height: React Native content hosted in Compose lags Compose's layout
127 * by a frame whenever its size changes, which showed as the filter jumping between tabs.
128 */
129function WorkspaceAppBarView(props: WorkspaceAppBarProps) {
130 const { search, filter } = props;
131 const searching = search?.active === true;
132 const { colors } = useControlTheme();
133 const tint = colors["--color-header-foreground"];
134 const { height, paddingTop, paddingBottom } = useToolbarLayout();
135 const filterMenu = filter ? (
136 <AndroidAnchoredMenu
137 actions={filter.actions}
138 onPressAction={({ nativeEvent }) => filter.onSelect(nativeEvent.event)}
139 >
140 {(open) =>
141 searching ? (
142 <AndroidHeaderIconButton
143 accessibilityLabel={filter.accessibilityLabel}
144 icon={
145 filter.customized
146 ? "line.3.horizontal.decrease.circle.fill"
147 : "line.3.horizontal.decrease.circle"
148 }
149 onPress={open}
150 />
151 ) : (
152 // The bar draws the icon; this takes the touch so the menu can anchor to the slot.
153 <Pressable
154 accessibilityLabel={filter.accessibilityLabel}
155 accessibilityRole="button"
156 android_ripple={{ borderless: true, radius: 20 }}
157 onPress={open}
158 style={{ width: ACTION_SIZE, height: ACTION_SIZE }}
159 />
160 )
161 }
162 </AndroidAnchoredMenu>
163 ) : null;
164 return (
165 <View className="bg-header" style={{ paddingTop, paddingBottom }}>
166 <View
167 pointerEvents={searching ? "none" : "auto"}
168 accessibilityElementsHidden={searching}
169 importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
170 style={{ opacity: searching ? 0 : 1 }}
171 >
172 <NativeAppBar
173 title={
174 <RNHostView matchContents={{ vertical: true }}>
175 <View collapsable={false} className="justify-center" style={{ height: ACTION_SIZE }}>
176 {props.titleContent ?? (
177 <AppText textRole="title" numberOfLines={1} className="text-header-foreground">
178 {props.title}
179 </AppText>
180 )}
181 </View>
182 </RNHostView>
183 }
184 // Both slots always exist: Expo UI applies a child insert or removal a frame before the
185 // other children's new props, so changing the slot count drew the wrong glyph mid-switch.
186 nativeActions={
187 <>
188 <IconButton enabled={search !== undefined} onClick={search?.onOpen}>
189 <Icon
190 source={SEARCH_ICON}
191 size={24}
192 tint={search ? tint : "transparent"}
193 contentDescription={search?.accessibilityLabel}
194 />
195 </IconButton>
196 <Box contentAlignment="center" modifiers={[size(ACTION_SIZE, ACTION_SIZE)]}>
197 <Icon
198 source={filter?.customized ? FILTER_FILLED_ICON : FILTER_ICON}
199 size={24}
200 tint={filter ? tint : "transparent"}
201 />
202 </Box>
203 </>
204 }
205 />
206 {filter && !searching ? (
207 <View
208 className="absolute"
209 style={{ right: ACTION_END_INSET, top: (height - ACTION_SIZE) / 2 }}
210 >
211 {filterMenu}
212 </View>
213 ) : null}
214 </View>
215 {searching ? (
216 <View
217 className="absolute inset-0 bg-header"
218 style={{ paddingTop, paddingBottom, paddingHorizontal: ACTION_END_INSET }}
219 >
220 <View className="flex-1 flex-row items-center gap-1">
221 <AndroidHeaderIconButton
222 accessibilityLabel="Close search"
223 icon="arrow.left"
224 onPress={search.onClose}
225 />
226 {search.field}
227 {filterMenu}
228 </View>
229 </View>
230 ) : null}
231 </View>
232 );
233}