| author | |
| committer | |
| log | 4fb9dfc8b8600d830b36da1c15c5f0cd21627e77 |
| tree | 4833b52d13464699314b4e3e271cf9e571876d32 |
| parent | 7230f6b1be4d1ad839b9a6ab45d0d70b4abc9a54 |
| signature | Signed by PGP key B5690EEEBB952194 |
16 files changed, 1042 insertions(+), 382 deletions(-)
apps/mobile/src/components/ScreenHeader.android.tsx created+203| ... | ... | @@ -0,0 +1,203 @@ |
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native"; | |
| 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 4 | import { NativeStackScreenOptions } from "../native/StackHeader"; | |
| 5 | import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar"; | |
| 6 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 7 | import { AndroidScreenHeader } from "./AndroidScreenHeader"; | |
| 8 | import { ScreenHeaderButton } from "./ScreenHeaderButton.android"; | |
| 9 | import { SymbolView } from "./AppSymbol"; | |
| 10 | import { ControlPillMenu } from "./ControlPill"; | |
| 11 | import { MaterialSearchField } from "./MaterialSearchField"; | |
| 12 | import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; | |
| 13 | import type { ScreenHeaderProps } from "./ScreenHeader.types"; | |
| 14 | ||
| 15 | export function ScreenHeader(props: ScreenHeaderProps) { | |
| 16 | const { search } = props; | |
| 17 | const insets = useSafeAreaInsets(); | |
| 18 | const { themeVariables } = useAppearancePreferences(); | |
| 19 | const inputRef = useRef<TextInput>(null); | |
| 20 | const [searchOpen, setSearchOpen] = useState(false); | |
| 21 | const searching = search !== undefined && (searchOpen || search.value.length > 0); | |
| 22 | const onSearchChange = search?.onChangeText; | |
| 23 | const closeSearch = useCallback(() => { | |
| 24 | onSearchChange?.(""); | |
| 25 | setSearchOpen(false); | |
| 26 | Keyboard.dismiss(); | |
| 27 | }, [onSearchChange]); | |
| 28 | useEffect(() => { | |
| 29 | if (!searching || search?.mode === "inline") return; | |
| 30 | const subscription = BackHandler.addEventListener("hardwareBackPress", () => { | |
| 31 | closeSearch(); | |
| 32 | return true; | |
| 33 | }); | |
| 34 | return () => subscription.remove(); | |
| 35 | }, [closeSearch, searching, search?.mode]); | |
| 36 | const menuView = props.menus?.map((menu) => ( | |
| 37 | <ControlPillMenu | |
| 38 | key={menu.title} | |
| 39 | actions={androidHeaderMenuActions(menu.items)} | |
| 40 | isAnchoredToRight | |
| 41 | title={menu.status ?? menu.title} | |
| 42 | onPressAction={({ nativeEvent }) => { | |
| 43 | const action = findHeaderMenuAction(menu.items, nativeEvent.event); | |
| 44 | if (action && !action.disabled) action.onPress(); | |
| 45 | }} | |
| 46 | > | |
| 47 | {search?.mode === "inline" ? ( | |
| 48 | <Pressable | |
| 49 | accessibilityLabel={menu.title} | |
| 50 | accessibilityRole="button" | |
| 51 | className="size-11 items-center justify-center rounded-full bg-subtle" | |
| 52 | > | |
| 53 | <SymbolView | |
| 54 | name={menu.icon} | |
| 55 | size={16} | |
| 56 | tintColorClassName="accent-icon" | |
| 57 | type="monochrome" | |
| 58 | /> | |
| 59 | </Pressable> | |
| 60 | ) : ( | |
| 61 | <ScreenHeaderButton accessibilityLabel={menu.title} icon={menu.icon} /> | |
| 62 | )} | |
| 63 | </ControlPillMenu> | |
| 64 | )); | |
| 65 | const options = ( | |
| 66 | <NativeStackScreenOptions | |
| 67 | options={{ | |
| 68 | ...props.options, | |
| 69 | ...(props.matchSearchSurface | |
| 70 | ? { contentStyle: { backgroundColor: themeVariables["--color-header"] } } | |
| 71 | : undefined), | |
| 72 | headerShown: false, | |
| 73 | title: props.title, | |
| 74 | }} | |
| 75 | optionsVersion={props.optionsVersion} | |
| 76 | /> | |
| 77 | ); | |
| 78 | if (search?.mode === "inline") { | |
| 79 | return ( | |
| 80 | <> | |
| 81 | {options} | |
| 82 | <View | |
| 83 | className="border-b border-header-border bg-header px-3 pb-2.5" | |
| 84 | style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }} | |
| 85 | > | |
| 86 | <View className="min-h-12 flex-row items-center gap-2"> | |
| 87 | {props.onBack ? ( | |
| 88 | <Pressable | |
| 89 | accessibilityLabel="Navigate up" | |
| 90 | accessibilityRole="button" | |
| 91 | hitSlop={8} | |
| 92 | onPress={props.onBack} | |
| 93 | className="size-11 items-center justify-center" | |
| 94 | > | |
| 95 | <SymbolView | |
| 96 | name="chevron.left" | |
| 97 | size={24} | |
| 98 | tintColorClassName="accent-foreground" | |
| 99 | type="monochrome" | |
| 100 | /> | |
| 101 | </Pressable> | |
| 102 | ) : null} | |
| 103 | <View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5"> | |
| 104 | <SymbolView | |
| 105 | name="magnifyingglass" | |
| 106 | size={17} | |
| 107 | tintColorClassName="accent-icon" | |
| 108 | type="monochrome" | |
| 109 | /> | |
| 110 | <TextInput | |
| 111 | accessibilityLabel={search.placeholder} | |
| 112 | autoCapitalize="none" | |
| 113 | onChangeText={search.onChangeText} | |
| 114 | value={search.value} | |
| 115 | placeholder={search.placeholder} | |
| 116 | placeholderTextColorClassName="accent-placeholder" | |
| 117 | className="flex-1 py-2 text-base font-sans text-foreground" | |
| 118 | /> | |
| 119 | </View> | |
| 120 | {menuView} | |
| 121 | </View> | |
| 122 | </View> | |
| 123 | </> | |
| 124 | ); | |
| 125 | } | |
| 126 | const header = ( | |
| 127 | <AndroidScreenHeader | |
| 128 | title={props.title} | |
| 129 | subtitle={props.subtitle} | |
| 130 | onBack={props.onBack} | |
| 131 | leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined} | |
| 132 | hideBottomBorder={props.hideBottomBorder} | |
| 133 | actions={ | |
| 134 | search | |
| 135 | ? [ | |
| 136 | { | |
| 137 | accessibilityLabel: search.placeholder, | |
| 138 | icon: "magnifyingglass", | |
| 139 | onPress: () => setSearchOpen(true), | |
| 140 | }, | |
| 141 | ...(search.onRefresh | |
| 142 | ? [ | |
| 143 | { | |
| 144 | accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh", | |
| 145 | icon: "arrow.clockwise" as const, | |
| 146 | onPress: search.onRefresh, | |
| 147 | }, | |
| 148 | ] | |
| 149 | : []), | |
| 150 | ...(props.actions ?? []), | |
| 151 | ] | |
| 152 | : props.actions | |
| 153 | } | |
| 154 | trailing={ | |
| 155 | <> | |
| 156 | {menuView} | |
| 157 | {props.trailing} | |
| 158 | </> | |
| 159 | } | |
| 160 | /> | |
| 161 | ); | |
| 162 | return ( | |
| 163 | <> | |
| 164 | {options} | |
| 165 | {search ? ( | |
| 166 | <View> | |
| 167 | <View | |
| 168 | pointerEvents={searching ? "none" : "auto"} | |
| 169 | accessibilityElementsHidden={searching} | |
| 170 | importantForAccessibility={searching ? "no-hide-descendants" : "auto"} | |
| 171 | style={{ opacity: searching ? 0 : 1 }} | |
| 172 | > | |
| 173 | {header} | |
| 174 | </View> | |
| 175 | {searching ? ( | |
| 176 | <View | |
| 177 | className="absolute inset-0 bg-header px-2 pb-2" | |
| 178 | style={{ paddingTop: Math.max(insets.top, 12) }} | |
| 179 | > | |
| 180 | <View className="min-h-14 flex-1 flex-row items-center gap-1"> | |
| 181 | <ScreenHeaderButton | |
| 182 | accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"} | |
| 183 | icon="arrow.left" | |
| 184 | onPress={closeSearch} | |
| 185 | /> | |
| 186 | <MaterialSearchField | |
| 187 | inputRef={inputRef} | |
| 188 | accessibilityLabel={search.placeholder} | |
| 189 | clearAccessibilityLabel={search.clearAccessibilityLabel ?? "Clear search"} | |
| 190 | placeholder={search.placeholder} | |
| 191 | value={search.value} | |
| 192 | onChangeText={search.onChangeText} | |
| 193 | /> | |
| 194 | </View> | |
| 195 | </View> | |
| 196 | ) : null} | |
| 197 | </View> | |
| 198 | ) : ( | |
| 199 | header | |
| 200 | )} | |
| 201 | </> | |
| 202 | ); | |
| 203 | } |
apps/mobile/src/components/ScreenHeader.tsx created+164| ... | ... | @@ -0,0 +1,164 @@ |
| 1 | import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; | |
| 2 | import { useId } from "react"; | |
| 3 | import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; | |
| 4 | import { ScreenHeaderButton } from "./ScreenHeaderButton"; | |
| 5 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader"; | |
| 6 | import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout"; | |
| 7 | import { | |
| 8 | createNativeMailSearchToolbarItem, | |
| 9 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, | |
| 10 | } from "../features/layout/native-mail-search-toolbar"; | |
| 11 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 12 | import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types"; | |
| 13 | import type { AppSymbolName } from "./AppSymbol"; | |
| 14 | ||
| 15 | function iosIcon(icon: AppSymbolName) { | |
| 16 | return typeof icon === "string" ? icon : icon.ios; | |
| 17 | } | |
| 18 | ||
| 19 | type MailMenu = NonNullable<HeaderBarButtonMailSearchToolbarItem["filterMenu"]>; | |
| 20 | function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["items"] { | |
| 21 | return items.map((item) => | |
| 22 | "items" in item | |
| 23 | ? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) } | |
| 24 | : { | |
| 25 | type: "action", | |
| 26 | title: item.title, | |
| 27 | state: item.selected ? "on" : "off", | |
| 28 | disabled: item.disabled, | |
| 29 | onPress: item.onPress, | |
| 30 | }, | |
| 31 | ); | |
| 32 | } | |
| 33 | ||
| 34 | export function ScreenHeader(props: ScreenHeaderProps) { | |
| 35 | const headerId = useId(); | |
| 36 | const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); | |
| 37 | const { themeVariables } = useAppearancePreferences(); | |
| 38 | const { search, menus } = props; | |
| 39 | const menu = menus?.[0]; | |
| 40 | const compactSearch = | |
| 41 | search !== undefined && | |
| 42 | (search.compactToolbar ?? !layout.usesSplitView) && | |
| 43 | NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; | |
| 44 | const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []); | |
| 45 | const refresh = search?.refreshInToolbar ? search.onRefresh : undefined; | |
| 46 | return ( | |
| 47 | <> | |
| 48 | <NativeStackScreenOptions | |
| 49 | optionsVersion={[ | |
| 50 | props.optionsVersion, | |
| 51 | compactSearch ? menu : undefined, | |
| 52 | compactSearch ? refresh : undefined, | |
| 53 | ]} | |
| 54 | options={{ | |
| 55 | headerShown: true, | |
| 56 | title: props.title, | |
| 57 | unstable_headerSubtitle: props.subtitle || undefined, | |
| 58 | ...(props.matchSearchSurface | |
| 59 | ? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } } | |
| 60 | : undefined), | |
| 61 | ...(search | |
| 62 | ? { | |
| 63 | unstable_headerToolbarItems: compactSearch | |
| 64 | ? () => [ | |
| 65 | createNativeMailSearchToolbarItem({ | |
| 66 | showsSearchDismissButton: true, | |
| 67 | placeholder: search.compactPlaceholder ?? search.placeholder, | |
| 68 | onSearchTextChange: search.onChangeText, | |
| 69 | searchTextChangeId: `${headerId}-search-text`, | |
| 70 | ...(refresh | |
| 71 | ? { | |
| 72 | composeButtonId: `${headerId}-refresh`, | |
| 73 | composeSystemImageName: "arrow.clockwise", | |
| 74 | onComposePress: refresh, | |
| 75 | } | |
| 76 | : undefined), | |
| 77 | ...(menu | |
| 78 | ? { | |
| 79 | filterButtonId: `${headerId}-filter`, | |
| 80 | filterSystemImageName: iosIcon(menu.icon), | |
| 81 | filterMenu: { title: menu.title, items: mailMenuItems(menu.items) }, | |
| 82 | } | |
| 83 | : undefined), | |
| 84 | }), | |
| 85 | ] | |
| 86 | : undefined, | |
| 87 | headerSearchBarOptions: compactSearch | |
| 88 | ? undefined | |
| 89 | : { | |
| 90 | allowToolbarIntegration: true, | |
| 91 | ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline" | |
| 92 | ? { placement: "integratedButton" as const } | |
| 93 | : undefined), | |
| 94 | autoCapitalize: "none", | |
| 95 | hideNavigationBar: false, | |
| 96 | ...(search.mode === "inline" ? { obscureBackground: false } : undefined), | |
| 97 | placeholder: search.placeholder, | |
| 98 | onChangeText: (event) => search.onChangeText(event.nativeEvent.text), | |
| 99 | onCancelButtonPress: () => search.onChangeText(""), | |
| 100 | }, | |
| 101 | } | |
| 102 | : undefined), | |
| 103 | ...props.options, | |
| 104 | }} | |
| 105 | /> | |
| 106 | {props.sidebar !== false && layout.usesSplitView ? ( | |
| 107 | <NativeHeaderToolbar placement="left"> | |
| 108 | {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? ( | |
| 109 | <ScreenHeaderButton | |
| 110 | {...props.backInSplitView} | |
| 111 | onPress={props.backInSplitView.onPress ?? props.onBack} | |
| 112 | /> | |
| 113 | ) : null} | |
| 114 | <ScreenHeaderButton | |
| 115 | accessibilityLabel={ | |
| 116 | panes.primarySidebarVisible ? `Maximize ${props.title.toLowerCase()}` : "Show threads" | |
| 117 | } | |
| 118 | icon={ | |
| 119 | panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left" | |
| 120 | } | |
| 121 | onPress={togglePrimarySidebar} | |
| 122 | separateBackground={ | |
| 123 | props.backInSplitView ? props.backInSplitView.separateBackground : true | |
| 124 | } | |
| 125 | /> | |
| 126 | </NativeHeaderToolbar> | |
| 127 | ) : null} | |
| 128 | {(props.actions?.length || | |
| 129 | (!compactSearch && refresh) || | |
| 130 | visibleMenus.length || | |
| 131 | props.trailing) && | |
| 132 | props.options?.unstable_headerRightItems === undefined ? ( | |
| 133 | <NativeHeaderToolbar placement="right"> | |
| 134 | {props.actions?.map((action) => ( | |
| 135 | <ScreenHeaderButton | |
| 136 | key={action.accessibilityLabel} | |
| 137 | {...action} | |
| 138 | icon={iosIcon(action.icon)} | |
| 139 | separateBackground | |
| 140 | /> | |
| 141 | ))} | |
| 142 | {refresh && !compactSearch ? ( | |
| 143 | <ScreenHeaderButton | |
| 144 | accessibilityLabel={search?.refreshAccessibilityLabel} | |
| 145 | icon="arrow.clockwise" | |
| 146 | onPress={refresh} | |
| 147 | separateBackground | |
| 148 | /> | |
| 149 | ) : null} | |
| 150 | {visibleMenus.map(createNativeHeaderMenu)} | |
| 151 | {props.trailing} | |
| 152 | </NativeHeaderToolbar> | |
| 153 | ) : null} | |
| 154 | </> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | export type { | |
| 159 | ScreenHeaderAction, | |
| 160 | ScreenHeaderMenu, | |
| 161 | ScreenHeaderMenuItem, | |
| 162 | ScreenHeaderProps, | |
| 163 | ScreenHeaderSearch, | |
| 164 | } from "./ScreenHeader.types"; |
apps/mobile/src/components/ScreenHeader.types.ts created+76| ... | ... | @@ -0,0 +1,76 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import type { ColorValue } from "react-native"; | |
| 3 | import type { AppNativeStackNavigationOptions } from "../native/StackHeader"; | |
| 4 | import type { AppSymbolName } from "./AppSymbol"; | |
| 5 | ||
| 6 | export interface ScreenHeaderAction { | |
| 7 | readonly accessibilityLabel: string; | |
| 8 | readonly icon: AppSymbolName; | |
| 9 | readonly onPress: () => void; | |
| 10 | readonly disabled?: boolean; | |
| 11 | readonly selected?: boolean; | |
| 12 | readonly tintColor?: ColorValue; | |
| 13 | } | |
| 14 | ||
| 15 | export type ScreenHeaderMenuItem = | |
| 16 | | { | |
| 17 | readonly id: string; | |
| 18 | readonly title: string; | |
| 19 | readonly icon?: string; | |
| 20 | readonly subtitle?: string; | |
| 21 | readonly disabled?: boolean; | |
| 22 | readonly selected?: boolean; | |
| 23 | readonly onPress: () => void; | |
| 24 | } | |
| 25 | | { | |
| 26 | readonly id: string; | |
| 27 | readonly title?: string; | |
| 28 | readonly icon?: string; | |
| 29 | readonly inline?: boolean; | |
| 30 | readonly items: ReadonlyArray<ScreenHeaderMenuItem>; | |
| 31 | }; | |
| 32 | ||
| 33 | export interface ScreenHeaderMenu { | |
| 34 | readonly title: string; | |
| 35 | readonly icon: AppSymbolName; | |
| 36 | readonly status?: string; | |
| 37 | readonly separateBackground?: boolean; | |
| 38 | readonly items: ReadonlyArray<ScreenHeaderMenuItem>; | |
| 39 | } | |
| 40 | ||
| 41 | export interface ScreenHeaderSearch { | |
| 42 | readonly value: string; | |
| 43 | readonly onChangeText: (value: string) => void; | |
| 44 | readonly placeholder: string; | |
| 45 | readonly mode?: "inline" | "collapsible"; | |
| 46 | readonly compactToolbar?: boolean; | |
| 47 | readonly compactPlaceholder?: string; | |
| 48 | readonly closeAccessibilityLabel?: string; | |
| 49 | readonly clearAccessibilityLabel?: string; | |
| 50 | readonly onRefresh?: () => void; | |
| 51 | readonly refreshAccessibilityLabel?: string; | |
| 52 | readonly refreshInToolbar?: boolean; | |
| 53 | } | |
| 54 | ||
| 55 | export interface ScreenHeaderProps { | |
| 56 | readonly title: string; | |
| 57 | readonly subtitle?: string; | |
| 58 | readonly actions?: ReadonlyArray<ScreenHeaderAction>; | |
| 59 | readonly menus?: ReadonlyArray<ScreenHeaderMenu>; | |
| 60 | readonly search?: ScreenHeaderSearch; | |
| 61 | readonly onBack?: () => void; | |
| 62 | readonly sidebar?: boolean; | |
| 63 | readonly backInSplitView?: { | |
| 64 | readonly accessibilityLabel: string; | |
| 65 | readonly icon: string; | |
| 66 | readonly separateBackground?: boolean; | |
| 67 | readonly onPress?: () => void; | |
| 68 | }; | |
| 69 | readonly hideBottomBorder?: boolean; | |
| 70 | readonly trailing?: ReactNode; | |
| 71 | /** Search scenes use sheet colors on iOS and header colors on Android. */ | |
| 72 | readonly matchSearchSurface?: boolean; | |
| 73 | /** Custom native layouts can override options without duplicating the header renderer. */ | |
| 74 | readonly options?: AppNativeStackNavigationOptions; | |
| 75 | readonly optionsVersion?: unknown; | |
| 76 | } |
apps/mobile/src/components/ScreenHeaderButton.android.tsx created+1| ... | ... | @@ -0,0 +1 @@ |
| 1 | export { AndroidHeaderIconButton as ScreenHeaderButton } from "./AndroidScreenHeader"; |
apps/mobile/src/components/ScreenHeaderButton.tsx created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | import { NativeHeaderToolbar } from "../native/StackHeader"; | |
| 2 | export const ScreenHeaderButton: ( | |
| 3 | props: Parameters<typeof NativeHeaderToolbar.Button>[0] & { readonly selected?: boolean }, | |
| 4 | ) => ReturnType<typeof NativeHeaderToolbar.Button> = NativeHeaderToolbar.Button; |
apps/mobile/src/components/headerMenu.android.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import type { MenuAction } from "@react-native-menu/menu"; | |
| 2 | import type { ScreenHeaderMenuItem } from "./ScreenHeader.types"; | |
| 3 | ||
| 4 | export function androidHeaderMenuActions(items: ReadonlyArray<ScreenHeaderMenuItem>): MenuAction[] { | |
| 5 | return items.flatMap((item): MenuAction[] => | |
| 6 | "items" in item | |
| 7 | ? item.inline && !item.title | |
| 8 | ? androidHeaderMenuActions(item.items) | |
| 9 | : [ | |
| 10 | { | |
| 11 | id: item.id, | |
| 12 | title: item.title ?? "", | |
| 13 | image: item.icon, | |
| 14 | subactions: androidHeaderMenuActions(item.items), | |
| 15 | }, | |
| 16 | ] | |
| 17 | : [ | |
| 18 | { | |
| 19 | id: item.id, | |
| 20 | title: item.title, | |
| 21 | subtitle: item.subtitle, | |
| 22 | image: item.icon, | |
| 23 | state: item.selected ? "on" : undefined, | |
| 24 | attributes: item.disabled ? { disabled: true } : undefined, | |
| 25 | }, | |
| 26 | ], | |
| 27 | ); | |
| 28 | } | |
| 29 | ||
| 30 | export function findHeaderMenuAction( | |
| 31 | items: ReadonlyArray<ScreenHeaderMenuItem>, | |
| 32 | id: string, | |
| 33 | ): Extract<ScreenHeaderMenuItem, { readonly onPress: () => void }> | undefined { | |
| 34 | for (const item of items) { | |
| 35 | if ("items" in item) { | |
| 36 | const action = findHeaderMenuAction(item.items, id); | |
| 37 | if (action) return action; | |
| 38 | } else if (item.id === id) { | |
| 39 | return item; | |
| 40 | } | |
| 41 | } | |
| 42 | return undefined; | |
| 43 | } |
apps/mobile/src/components/headerMenu.test.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android"; | |
| 3 | import type { ScreenHeaderMenuItem } from "./ScreenHeader.types"; | |
| 4 | ||
| 5 | const items: ReadonlyArray<ScreenHeaderMenuItem> = [ | |
| 6 | { | |
| 7 | id: "sessions", | |
| 8 | title: "Sessions", | |
| 9 | items: [ | |
| 10 | { | |
| 11 | id: "terminal-session:main", | |
| 12 | title: "Main shell", | |
| 13 | subtitle: "Running · workspace", | |
| 14 | selected: true, | |
| 15 | onPress: () => {}, | |
| 16 | }, | |
| 17 | { id: "closed", title: "Closed shell", disabled: true, onPress: () => {} }, | |
| 18 | ], | |
| 19 | }, | |
| 20 | ]; | |
| 21 | ||
| 22 | describe("header menus", () => { | |
| 23 | it("keeps named sections as Android submenus", () => { | |
| 24 | const actions = androidHeaderMenuActions([ | |
| 25 | { | |
| 26 | id: "text-size", | |
| 27 | title: "Text size", | |
| 28 | inline: true, | |
| 29 | items: [{ id: "font-increase", title: "A+", onPress: () => {} }], | |
| 30 | }, | |
| 31 | ]); | |
| 32 | expect(actions[0]?.title).toBe("Text size"); | |
| 33 | expect(actions[0]?.subactions?.[0]?.id).toBe("font-increase"); | |
| 34 | }); | |
| 35 | ||
| 36 | it("flattens inline groups while keeping their action IDs", () => { | |
| 37 | const actions = androidHeaderMenuActions([ | |
| 38 | { id: "modes", inline: true, items: [{ id: "code", title: "Code", onPress: () => {} }] }, | |
| 39 | ]); | |
| 40 | expect(actions.map(({ id }) => id)).toEqual(["code"]); | |
| 41 | }); | |
| 42 | ||
| 43 | it("keeps stable action IDs, selection, and disabled state inside submenus", () => { | |
| 44 | const menu = androidHeaderMenuActions(items); | |
| 45 | expect(menu[0]?.id).toBe("sessions"); | |
| 46 | expect(menu[0]?.subactions?.[0]).toMatchObject({ | |
| 47 | id: "terminal-session:main", | |
| 48 | title: "Main shell", | |
| 49 | subtitle: "Running · workspace", | |
| 50 | state: "on", | |
| 51 | }); | |
| 52 | expect(menu[0]?.subactions?.[1]).toMatchObject({ | |
| 53 | id: "closed", | |
| 54 | attributes: { disabled: true }, | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | it("resolves nested action IDs without dispatching group or missing IDs", () => { | |
| 59 | expect(findHeaderMenuAction(items, "terminal-session:main")).toMatchObject({ | |
| 60 | title: "Main shell", | |
| 61 | selected: true, | |
| 62 | }); | |
| 63 | expect(findHeaderMenuAction(items, "sessions")).toBeUndefined(); | |
| 64 | expect(findHeaderMenuAction(items, "removed-session")).toBeUndefined(); | |
| 65 | }); | |
| 66 | }); |
apps/mobile/src/components/nativeHeaderMenu.ios.tsx created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { NativeHeaderToolbar } from "../native/StackHeader"; | |
| 2 | import type { ScreenHeaderMenu, ScreenHeaderMenuItem } from "./ScreenHeader.types"; | |
| 3 | ||
| 4 | function renderMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>) { | |
| 5 | return items.map((item) => | |
| 6 | "items" in item ? ( | |
| 7 | <NativeHeaderToolbar.Menu | |
| 8 | key={item.id} | |
| 9 | title={item.title} | |
| 10 | icon={item.icon} | |
| 11 | inline={item.inline} | |
| 12 | > | |
| 13 | {renderMenuItems(item.items)} | |
| 14 | </NativeHeaderToolbar.Menu> | |
| 15 | ) : ( | |
| 16 | <NativeHeaderToolbar.MenuAction | |
| 17 | key={item.id} | |
| 18 | icon={item.icon} | |
| 19 | subtitle={item.subtitle} | |
| 20 | disabled={item.disabled} | |
| 21 | isOn={item.selected} | |
| 22 | onPress={item.onPress} | |
| 23 | > | |
| 24 | {item.title} | |
| 25 | </NativeHeaderToolbar.MenuAction> | |
| 26 | ), | |
| 27 | ); | |
| 28 | } | |
| 29 | ||
| 30 | /** Returns direct native items for toolbar serialization, including nested menu data. */ | |
| 31 | export function createNativeHeaderMenu(menu: ScreenHeaderMenu) { | |
| 32 | return ( | |
| 33 | <NativeHeaderToolbar.Menu | |
| 34 | key={menu.title} | |
| 35 | title={menu.title} | |
| 36 | accessibilityLabel={menu.title} | |
| 37 | icon={typeof menu.icon === "string" ? menu.icon : menu.icon.ios} | |
| 38 | separateBackground={menu.separateBackground ?? true} | |
| 39 | > | |
| 40 | {menu.status ? <NativeHeaderToolbar.Label>{menu.status}</NativeHeaderToolbar.Label> : null} | |
| 41 | {renderMenuItems(menu.items)} | |
| 42 | </NativeHeaderToolbar.Menu> | |
| 43 | ); | |
| 44 | } |
apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx+2-22| ... | ... | @@ -1,6 +1,5 @@ |
| 1 | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | 2 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 3 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 4 | 3 | import { |
| 5 | 4 | StackActions, |
| 6 | 5 | useNavigation, |
| ... | ... | @@ -194,7 +193,8 @@ export function ConnectionsNewRouteScreen({ |
| 194 | 193 | actions={[ |
| 195 | 194 | { |
| 196 | 195 | accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", |
| 197 | icon: showScanner ? "xmark" : "camera", | |
| 196 | icon: showScanner ? "xmark" : Platform.OS === "ios" ? "qrcode.viewfinder" : "camera", | |
| 197 | tintColor: headerIconColor, | |
| 198 | 198 | onPress: () => { |
| 199 | 199 | if (showScanner) { |
| 200 | 200 | closeScanner(); |
| ... | ... | @@ -205,26 +205,6 @@ export function ConnectionsNewRouteScreen({ |
| 205 | 205 | }, |
| 206 | 206 | ]} |
| 207 | 207 | > |
| 208 | <NativeStackScreenOptions | |
| 209 | options={{ title: showScanner ? "Scan QR Code" : "Add Environment" }} | |
| 210 | /> | |
| 211 | {Platform.OS !== "android" ? ( | |
| 212 | <NativeHeaderToolbar placement="right"> | |
| 213 | <NativeHeaderToolbar.Button | |
| 214 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} | |
| 215 | onPress={() => { | |
| 216 | if (showScanner) { | |
| 217 | closeScanner(); | |
| 218 | } else { | |
| 219 | void openScanner(); | |
| 220 | } | |
| 221 | }} | |
| 222 | separateBackground | |
| 223 | tintColor={headerIconColor} | |
| 224 | /> | |
| 225 | </NativeHeaderToolbar> | |
| 226 | ) : null} | |
| 227 | ||
| 228 | 208 | <ScrollView |
| 229 | 209 | contentInsetAdjustmentBehavior="automatic" |
| 230 | 210 | showsVerticalScrollIndicator={false} |
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+1-17| ... | ... | @@ -1,9 +1,7 @@ |
| 1 | 1 | import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; |
| 2 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | |
| 3 | 2 | import { useNavigation } from "@react-navigation/native"; |
| 4 | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | 4 | import { useCallback, useState } from "react"; |
| 6 | import { Platform } from "react-native"; | |
| 7 | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 6 | |
| 9 | 7 | import { SettingsScreen } from "./components/SettingsScreen"; |
| ... | ... | @@ -82,6 +80,7 @@ export function SettingsEnvironmentsRouteScreen() { |
| 82 | 80 | { |
| 83 | 81 | accessibilityLabel: "Add environment", |
| 84 | 82 | icon: "plus", |
| 83 | tintColor: headerIconColor, | |
| 85 | 84 | onPress: () => |
| 86 | 85 | navigation.navigate("SettingsSheet", { |
| 87 | 86 | screen: "SettingsContent", |
| ... | ... | @@ -90,21 +89,6 @@ export function SettingsEnvironmentsRouteScreen() { |
| 90 | 89 | }, |
| 91 | 90 | ]} |
| 92 | 91 | > |
| 93 | {Platform.OS !== "android" ? ( | |
| 94 | <NativeHeaderToolbar placement="right"> | |
| 95 | <NativeHeaderToolbar.Button | |
| 96 | icon="plus" | |
| 97 | onPress={() => | |
| 98 | navigation.navigate("SettingsSheet", { | |
| 99 | screen: "SettingsContent", | |
| 100 | params: { screen: "SettingsEnvironmentNew" }, | |
| 101 | }) | |
| 102 | } | |
| 103 | separateBackground | |
| 104 | tintColor={headerIconColor} | |
| 105 | /> | |
| 106 | </NativeHeaderToolbar> | |
| 107 | ) : null} | |
| 108 | 92 | <ScrollView |
| 109 | 93 | contentInsetAdjustmentBehavior="automatic" |
| 110 | 94 | showsVerticalScrollIndicator={false} |
apps/mobile/src/features/settings/components/SettingsScreen.tsx+12-18| ... | ... | @@ -1,16 +1,15 @@ |
| 1 | 1 | import { useNavigation } from "@react-navigation/native"; |
| 2 | import type { ComponentProps, ReactNode } from "react"; | |
| 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Platform, View } from "react-native"; |
| 4 | 4 | |
| 5 | import { AndroidScreenHeader } from "../../../components/AndroidScreenHeader"; | |
| 5 | import type { ScreenHeaderProps } from "../../../components/ScreenHeader.types"; | |
| 6 | import { ScreenHeader } from "../../../components/ScreenHeader"; | |
| 6 | 7 | import { MaterialScreenContent as SettingsScreenContent } from "../../../components/MaterialScreenContent"; |
| 7 | import { NativeStackScreenOptions } from "../../../native/StackHeader"; | |
| 8 | import { AndroidWorkspaceSidebarButton } from "../../layout/workspace-sidebar-toolbar"; | |
| 9 | 8 | |
| 10 | 9 | export { SettingsScreenContent }; |
| 11 | 10 | |
| 12 | 11 | export function SettingsScreen( |
| 13 | props: Pick<ComponentProps<typeof AndroidScreenHeader>, "title" | "actions" | "trailing"> & { | |
| 12 | props: Pick<ScreenHeaderProps, "title" | "actions" | "trailing"> & { | |
| 14 | 13 | readonly children: ReactNode; |
| 15 | 14 | /** A native form sheet already owns its rounded outer frame. */ |
| 16 | 15 | readonly formSheet?: boolean; |
| ... | ... | @@ -20,19 +19,14 @@ export function SettingsScreen( |
| 20 | 19 | |
| 21 | 20 | return ( |
| 22 | 21 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 23 | {Platform.OS === "android" ? ( | |
| 24 | <> | |
| 25 | <NativeStackScreenOptions options={{ headerShown: false }} /> | |
| 26 | <AndroidScreenHeader | |
| 27 | title={props.title} | |
| 28 | leading={props.formSheet ? undefined : <AndroidWorkspaceSidebarButton />} | |
| 29 | actions={props.actions} | |
| 30 | trailing={props.trailing} | |
| 31 | onBack={() => navigation.goBack()} | |
| 32 | hideBottomBorder={!props.formSheet} | |
| 33 | /> | |
| 34 | </> | |
| 35 | ) : null} | |
| 22 | <ScreenHeader | |
| 23 | title={props.title} | |
| 24 | actions={props.actions} | |
| 25 | trailing={Platform.OS === "android" ? props.trailing : undefined} | |
| 26 | sidebar={Platform.OS === "android" && !props.formSheet} | |
| 27 | onBack={() => navigation.goBack()} | |
| 28 | hideBottomBorder={!props.formSheet} | |
| 29 | /> | |
| 36 | 30 | {props.formSheet ? ( |
| 37 | 31 | props.children |
| 38 | 32 | ) : ( |
apps/mobile/src/features/threads/ThreadGitControls.android.tsx created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 1 | import type { | |
| 2 | ThreadGitControls as IosThreadGitControls, | |
| 3 | ThreadGitMenu as IosThreadGitMenu, | |
| 4 | useThreadGitRightHeaderItems as useIosThreadGitRightHeaderItems, | |
| 5 | } from "./ThreadGitControls"; | |
| 6 | ||
| 7 | export type { ThreadGitMenuProps } from "./ThreadGitControls"; | |
| 8 | ||
| 9 | type ThreadGitControlsProps = Parameters<typeof IosThreadGitControls>[0]; | |
| 10 | const EMPTY_HEADER_ITEMS: ReturnType<typeof useIosThreadGitRightHeaderItems> = []; | |
| 11 | ||
| 12 | export function useThreadGitRightHeaderItems(_props: ThreadGitControlsProps) { | |
| 13 | return EMPTY_HEADER_ITEMS; | |
| 14 | } | |
| 15 | ||
| 16 | export function useThreadGitCenterHeaderItems(_props: ThreadGitControlsProps) { | |
| 17 | return EMPTY_HEADER_ITEMS; | |
| 18 | } | |
| 19 | ||
| 20 | export function ThreadGitControls(_props: ThreadGitControlsProps) { | |
| 21 | return null; | |
| 22 | } | |
| 23 | ||
| 24 | export function ThreadGitMenu(_props: Parameters<typeof IosThreadGitMenu>[0]) { | |
| 25 | return null; | |
| 26 | } | |
| 27 | ||
| 28 | export function useThreadGitMenuDefinition(_props: Parameters<typeof IosThreadGitMenu>[0]) { | |
| 29 | return null; | |
| 30 | } |
apps/mobile/src/features/threads/ThreadGitControls.tsx+56-39| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { createNativeHeaderMenu } from "../../components/nativeHeaderMenu.ios"; | |
| 2 | import type { ScreenHeaderMenu } from "../../components/ScreenHeader.types"; | |
| 1 | 3 | import { |
| 2 | 4 | EnvironmentId, |
| 3 | 5 | type GitRunStackedActionResult, |
| ... | ... | @@ -426,6 +428,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { |
| 426 | 428 | ) : null} |
| 427 | 429 | {showActionControls ? ( |
| 428 | 430 | <NativeHeaderToolbar.Menu |
| 431 | accessibilityLabel="Open terminal" | |
| 429 | 432 | icon="terminal" |
| 430 | 433 | disabled={!props.canOpenTerminal} |
| 431 | 434 | separateBackground |
| ... | ... | @@ -489,7 +492,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { |
| 489 | 492 | separateBackground |
| 490 | 493 | /> |
| 491 | 494 | ) : null} |
| 492 | {showActionControls ? <ThreadGitMenu {...props} /> : null} | |
| 495 | {showActionControls ? createNativeHeaderMenu(threadGitMenuDefinition(props, model)) : null} | |
| 493 | 496 | </NativeHeaderToolbar> |
| 494 | 497 | ); |
| 495 | 498 | } |
| ... | ... | @@ -500,43 +503,57 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { |
| 500 | 503 | * chat header and the review screen's toolbar. |
| 501 | 504 | */ |
| 502 | 505 | export function ThreadGitMenu(props: ThreadGitMenuProps) { |
| 503 | const model = useThreadGitControlModel(props); | |
| 506 | const menu = useThreadGitMenuDefinition(props); | |
| 507 | return menu ? createNativeHeaderMenu(menu) : null; | |
| 508 | } | |
| 504 | 509 | |
| 505 | return ( | |
| 506 | <NativeHeaderToolbar.Menu icon="point.topleft.down.curvedto.point.bottomright.up"> | |
| 507 | <NativeHeaderToolbar.MenuAction | |
| 508 | icon="point.topleft.down.curvedto.point.bottomright.up" | |
| 509 | disabled | |
| 510 | onPress={() => {}} | |
| 511 | subtitle={compactMenuStatus(props.gitStatus)} | |
| 512 | > | |
| 513 | <NativeHeaderToolbar.Label> | |
| 514 | {compactMenuBranchLabel(model.currentBranchLabel)} | |
| 515 | </NativeHeaderToolbar.Label> | |
| 516 | </NativeHeaderToolbar.MenuAction> | |
| 517 | <NativeHeaderToolbar.MenuAction | |
| 518 | icon={model.quickActionIcon} | |
| 519 | disabled={model.quickAction.disabled} | |
| 520 | onPress={() => void model.runQuickAction()} | |
| 521 | subtitle={model.quickActionHint ?? undefined} | |
| 522 | > | |
| 523 | <NativeHeaderToolbar.Label>{model.quickAction.label}</NativeHeaderToolbar.Label> | |
| 524 | </NativeHeaderToolbar.MenuAction> | |
| 525 | <NativeHeaderToolbar.MenuAction | |
| 526 | icon="text.bubble" | |
| 527 | disabled={!model.isRepo} | |
| 528 | onPress={model.openReview} | |
| 529 | subtitle="Turn diffs and worktree changes" | |
| 530 | > | |
| 531 | <NativeHeaderToolbar.Label>Review changes</NativeHeaderToolbar.Label> | |
| 532 | </NativeHeaderToolbar.MenuAction> | |
| 533 | <NativeHeaderToolbar.MenuAction | |
| 534 | icon="ellipsis" | |
| 535 | onPress={model.openGitInspector} | |
| 536 | subtitle="Commit, files, branches" | |
| 537 | > | |
| 538 | <NativeHeaderToolbar.Label>More</NativeHeaderToolbar.Label> | |
| 539 | </NativeHeaderToolbar.MenuAction> | |
| 540 | </NativeHeaderToolbar.Menu> | |
| 541 | ); | |
| 510 | /** Returns menu data because native toolbars serialize direct items rather than rendering component children. */ | |
| 511 | export function useThreadGitMenuDefinition(props: ThreadGitMenuProps): ScreenHeaderMenu | null { | |
| 512 | return threadGitMenuDefinition(props, useThreadGitControlModel(props)); | |
| 513 | } | |
| 514 | ||
| 515 | function threadGitMenuDefinition( | |
| 516 | props: ThreadGitMenuProps, | |
| 517 | model: ReturnType<typeof useThreadGitControlModel>, | |
| 518 | ): ScreenHeaderMenu { | |
| 519 | return { | |
| 520 | title: "Git controls", | |
| 521 | icon: "point.topleft.down.curvedto.point.bottomright.up", | |
| 522 | separateBackground: false, | |
| 523 | items: [ | |
| 524 | { | |
| 525 | id: "git-status", | |
| 526 | title: compactMenuBranchLabel(model.currentBranchLabel), | |
| 527 | icon: "point.topleft.down.curvedto.point.bottomright.up", | |
| 528 | disabled: true, | |
| 529 | subtitle: compactMenuStatus(props.gitStatus), | |
| 530 | onPress: () => {}, | |
| 531 | }, | |
| 532 | { | |
| 533 | id: "git-quick-action", | |
| 534 | title: model.quickAction.label, | |
| 535 | icon: model.quickActionIcon, | |
| 536 | disabled: model.quickAction.disabled, | |
| 537 | subtitle: model.quickActionHint ?? undefined, | |
| 538 | onPress: () => { | |
| 539 | void model.runQuickAction(); | |
| 540 | }, | |
| 541 | }, | |
| 542 | { | |
| 543 | id: "git-review", | |
| 544 | title: "Review changes", | |
| 545 | icon: "text.bubble", | |
| 546 | disabled: !model.isRepo, | |
| 547 | subtitle: "Turn diffs and worktree changes", | |
| 548 | onPress: model.openReview, | |
| 549 | }, | |
| 550 | { | |
| 551 | id: "git-more", | |
| 552 | title: "More", | |
| 553 | icon: "ellipsis", | |
| 554 | subtitle: "Commit, files, branches", | |
| 555 | onPress: model.openGitInspector, | |
| 556 | }, | |
| 557 | ], | |
| 558 | }; | |
| 542 | 559 | } |
apps/mobile/src/native/NativeHeaderToolbar.android.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import type { NativeHeaderToolbar as IosNativeHeaderToolbar } from "./NativeHeaderToolbar"; | |
| 2 | ||
| 3 | function NativeHeaderToolbarRoot(_props: Parameters<typeof IosNativeHeaderToolbar>[0]) { | |
| 4 | return null; | |
| 5 | } | |
| 6 | ||
| 7 | function NativeHeaderToolbarButton(_props: Parameters<typeof IosNativeHeaderToolbar.Button>[0]) { | |
| 8 | return null; | |
| 9 | } | |
| 10 | ||
| 11 | function NativeHeaderToolbarMenu(_props: Parameters<typeof IosNativeHeaderToolbar.Menu>[0]) { | |
| 12 | return null; | |
| 13 | } | |
| 14 | ||
| 15 | function NativeHeaderToolbarMenuAction( | |
| 16 | _props: Parameters<typeof IosNativeHeaderToolbar.MenuAction>[0], | |
| 17 | ) { | |
| 18 | return null; | |
| 19 | } | |
| 20 | ||
| 21 | function NativeHeaderToolbarLabel(_props: Parameters<typeof IosNativeHeaderToolbar.Label>[0]) { | |
| 22 | return null; | |
| 23 | } | |
| 24 | ||
| 25 | function NativeHeaderToolbarSpacer(_props: Parameters<typeof IosNativeHeaderToolbar.Spacer>[0]) { | |
| 26 | return null; | |
| 27 | } | |
| 28 | ||
| 29 | function NativeHeaderToolbarSearchBarSlot() { | |
| 30 | return null; | |
| 31 | } | |
| 32 | ||
| 33 | // Native header item factories are iOS-only; Android owns its in-flow header. | |
| 34 | export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { | |
| 35 | Button: NativeHeaderToolbarButton, | |
| 36 | Label: NativeHeaderToolbarLabel, | |
| 37 | Menu: Object.assign(NativeHeaderToolbarMenu, { | |
| 38 | Action: NativeHeaderToolbarMenuAction, | |
| 39 | }), | |
| 40 | MenuAction: NativeHeaderToolbarMenuAction, | |
| 41 | SearchBarSlot: NativeHeaderToolbarSearchBarSlot, | |
| 42 | Spacer: NativeHeaderToolbarSpacer, | |
| 43 | }); |
apps/mobile/src/native/NativeHeaderToolbar.tsx created+294| ... | ... | @@ -0,0 +1,294 @@ |
| 1 | import { useNavigation, type ParamListBase } from "@react-navigation/native"; | |
| 2 | import type { | |
| 3 | NativeStackHeaderItem, | |
| 4 | NativeStackHeaderItemMenu, | |
| 5 | NativeStackNavigationOptions, | |
| 6 | NativeStackNavigationProp, | |
| 7 | } from "@react-navigation/native-stack"; | |
| 8 | import { | |
| 9 | Children, | |
| 10 | isValidElement, | |
| 11 | useLayoutEffect, | |
| 12 | useMemo, | |
| 13 | type ReactElement, | |
| 14 | type ReactNode, | |
| 15 | } from "react"; | |
| 16 | import type { ColorValue } from "react-native"; | |
| 17 | ||
| 18 | function useNativeStackNavigation(): NativeStackNavigationProp<ParamListBase> | null { | |
| 19 | return useNavigation<NativeStackNavigationProp<ParamListBase>>(); | |
| 20 | } | |
| 21 | ||
| 22 | function labelFromChildren(children: ReactNode): string { | |
| 23 | const parts: string[] = []; | |
| 24 | Children.forEach(children, (child) => { | |
| 25 | if (typeof child === "string" || typeof child === "number") { | |
| 26 | parts.push(String(child)); | |
| 27 | } else if (isValidElement<{ children?: ReactNode }>(child)) { | |
| 28 | parts.push(labelFromChildren(child.props.children)); | |
| 29 | } | |
| 30 | }); | |
| 31 | return parts.join(""); | |
| 32 | } | |
| 33 | ||
| 34 | type NativeStackHeaderIcon = NonNullable< | |
| 35 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] | |
| 36 | >; | |
| 37 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { | |
| 38 | unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; | |
| 39 | }; | |
| 40 | ||
| 41 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { | |
| 42 | if (typeof icon !== "string") { | |
| 43 | return undefined; | |
| 44 | } | |
| 45 | return { type: "sfSymbol", name: icon as never }; | |
| 46 | } | |
| 47 | ||
| 48 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; | |
| 49 | ||
| 50 | function elementTypeName(element: ReactElement): string | undefined { | |
| 51 | const type = element.type; | |
| 52 | if (typeof type === "function") { | |
| 53 | return (type as { displayName?: string; name?: string }).displayName ?? type.name; | |
| 54 | } | |
| 55 | return undefined; | |
| 56 | } | |
| 57 | ||
| 58 | function convertMenuAction( | |
| 59 | element: ReactElement<ToolbarElementProps>, | |
| 60 | ): NativeStackHeaderItemMenu["menu"]["items"][number] | null { | |
| 61 | const typeName = elementTypeName(element); | |
| 62 | if (typeName === "NativeHeaderToolbarMenuAction") { | |
| 63 | const label = labelFromChildren(element.props.children); | |
| 64 | return { | |
| 65 | type: "action", | |
| 66 | label, | |
| 67 | description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, | |
| 68 | disabled: Boolean(element.props.disabled), | |
| 69 | icon: iconFromProp(element.props.icon), | |
| 70 | onPress: | |
| 71 | typeof element.props.onPress === "function" | |
| 72 | ? (element.props.onPress as () => void) | |
| 73 | : () => undefined, | |
| 74 | state: element.props.isOn === true ? "on" : undefined, | |
| 75 | destructive: Boolean(element.props.destructive), | |
| 76 | discoverabilityLabel: | |
| 77 | typeof element.props.discoverabilityLabel === "string" | |
| 78 | ? element.props.discoverabilityLabel | |
| 79 | : undefined, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | if (typeName === "NativeHeaderToolbarMenu") { | |
| 84 | return { | |
| 85 | type: "submenu", | |
| 86 | label: | |
| 87 | typeof element.props.title === "string" | |
| 88 | ? element.props.title | |
| 89 | : labelFromChildren(element.props.children), | |
| 90 | icon: iconFromProp(element.props.icon), | |
| 91 | inline: Boolean(element.props.inline), | |
| 92 | items: collectMenuItems(element.props.children), | |
| 93 | }; | |
| 94 | } | |
| 95 | ||
| 96 | return null; | |
| 97 | } | |
| 98 | ||
| 99 | function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { | |
| 100 | const items: NativeStackHeaderItemMenu["menu"]["items"] = []; | |
| 101 | Children.forEach(children, (child) => { | |
| 102 | if (!isValidElement<ToolbarElementProps>(child)) { | |
| 103 | return; | |
| 104 | } | |
| 105 | const item = convertMenuAction(child); | |
| 106 | if (item) { | |
| 107 | items.push(item); | |
| 108 | return; | |
| 109 | } | |
| 110 | items.push(...collectMenuItems(child.props.children)); | |
| 111 | }); | |
| 112 | return items; | |
| 113 | } | |
| 114 | ||
| 115 | function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | |
| 116 | if (!isValidElement<ToolbarElementProps>(child)) { | |
| 117 | return null; | |
| 118 | } | |
| 119 | ||
| 120 | const typeName = elementTypeName(child); | |
| 121 | if (typeName === "NativeHeaderToolbarButton") { | |
| 122 | return { | |
| 123 | type: "button", | |
| 124 | label: typeof child.props.label === "string" ? child.props.label : "", | |
| 125 | accessibilityLabel: | |
| 126 | typeof child.props.accessibilityLabel === "string" | |
| 127 | ? child.props.accessibilityLabel | |
| 128 | : undefined, | |
| 129 | disabled: Boolean(child.props.disabled), | |
| 130 | icon: iconFromProp(child.props.icon), | |
| 131 | onPress: | |
| 132 | typeof child.props.onPress === "function" | |
| 133 | ? (child.props.onPress as () => void) | |
| 134 | : () => undefined, | |
| 135 | sharesBackground: !child.props.separateBackground, | |
| 136 | tintColor: child.props.tintColor as ColorValue | undefined, | |
| 137 | variant: "plain", | |
| 138 | }; | |
| 139 | } | |
| 140 | ||
| 141 | if (typeName === "NativeHeaderToolbarMenu") { | |
| 142 | return { | |
| 143 | type: "menu", | |
| 144 | label: typeof child.props.title === "string" ? child.props.title : "", | |
| 145 | accessibilityLabel: | |
| 146 | typeof child.props.accessibilityLabel === "string" | |
| 147 | ? child.props.accessibilityLabel | |
| 148 | : undefined, | |
| 149 | disabled: Boolean(child.props.disabled), | |
| 150 | icon: iconFromProp(child.props.icon), | |
| 151 | menu: { | |
| 152 | title: typeof child.props.title === "string" ? child.props.title : undefined, | |
| 153 | items: collectMenuItems(child.props.children), | |
| 154 | }, | |
| 155 | sharesBackground: !child.props.separateBackground, | |
| 156 | tintColor: child.props.tintColor as ColorValue | undefined, | |
| 157 | variant: "plain", | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | if (typeName === "NativeHeaderToolbarSpacer") { | |
| 162 | return { | |
| 163 | type: "spacing", | |
| 164 | spacing: typeof child.props.width === "number" ? child.props.width : 8, | |
| 165 | flexible: Boolean(child.props.flexible), | |
| 166 | } as NativeStackHeaderItem; | |
| 167 | } | |
| 168 | ||
| 169 | return null; | |
| 170 | } | |
| 171 | ||
| 172 | function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { | |
| 173 | const items: NativeStackHeaderItem[] = []; | |
| 174 | Children.forEach(children, (child) => { | |
| 175 | const item = convertToolbarChild(child); | |
| 176 | if (item) { | |
| 177 | if (item.type === "spacing") { | |
| 178 | // Native inserts spacing items at `index`, treating a missing index | |
| 179 | // as 0 — which would move the spacer in front of earlier siblings. | |
| 180 | (item as { index?: number }).index = items.length; | |
| 181 | } | |
| 182 | items.push(item); | |
| 183 | } | |
| 184 | }); | |
| 185 | return items; | |
| 186 | } | |
| 187 | ||
| 188 | function NativeHeaderToolbarRoot(props: { | |
| 189 | readonly placement?: "left" | "right" | "bottom"; | |
| 190 | readonly children?: ReactNode; | |
| 191 | }) { | |
| 192 | const navigation = useNativeStackNavigation(); | |
| 193 | const items = useMemo(() => collectToolbarItems(props.children), [props.children]); | |
| 194 | ||
| 195 | // Swap toolbar owners before paint so split and compact headers cannot clear each other. | |
| 196 | useLayoutEffect(() => { | |
| 197 | if (!navigation) { | |
| 198 | return; | |
| 199 | } | |
| 200 | if (props.placement === "bottom") { | |
| 201 | navigation.setOptions({ | |
| 202 | unstable_headerToolbarItems: () => items, | |
| 203 | } as NativeStackOptionsWithToolbar); | |
| 204 | return () => { | |
| 205 | navigation.setOptions({ | |
| 206 | unstable_headerToolbarItems: () => [], | |
| 207 | } as NativeStackOptionsWithToolbar); | |
| 208 | }; | |
| 209 | } | |
| 210 | if (props.placement === "left") { | |
| 211 | navigation.setOptions({ unstable_headerLeftItems: () => items }); | |
| 212 | return () => { | |
| 213 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); | |
| 214 | }; | |
| 215 | } | |
| 216 | navigation.setOptions({ unstable_headerRightItems: () => items }); | |
| 217 | return () => { | |
| 218 | navigation.setOptions({ unstable_headerRightItems: () => [] }); | |
| 219 | }; | |
| 220 | }, [items, navigation, props.placement]); | |
| 221 | ||
| 222 | return null; | |
| 223 | } | |
| 224 | ||
| 225 | function NativeHeaderToolbarButton(_props: { | |
| 226 | readonly accessibilityLabel?: string; | |
| 227 | readonly disabled?: boolean; | |
| 228 | readonly icon?: string; | |
| 229 | readonly label?: string; | |
| 230 | readonly onPress?: () => void; | |
| 231 | readonly separateBackground?: boolean; | |
| 232 | readonly tintColor?: ColorValue; | |
| 233 | }) { | |
| 234 | return null; | |
| 235 | } | |
| 236 | NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; | |
| 237 | ||
| 238 | function NativeHeaderToolbarMenu(_props: { | |
| 239 | readonly accessibilityLabel?: string; | |
| 240 | readonly children?: ReactNode; | |
| 241 | readonly disabled?: boolean; | |
| 242 | readonly icon?: string; | |
| 243 | readonly inline?: boolean; | |
| 244 | readonly separateBackground?: boolean; | |
| 245 | readonly tintColor?: ColorValue; | |
| 246 | readonly title?: string; | |
| 247 | }) { | |
| 248 | return null; | |
| 249 | } | |
| 250 | NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; | |
| 251 | ||
| 252 | function NativeHeaderToolbarMenuAction(_props: { | |
| 253 | readonly children?: ReactNode; | |
| 254 | readonly destructive?: boolean; | |
| 255 | readonly disabled?: boolean; | |
| 256 | readonly discoverabilityLabel?: string; | |
| 257 | readonly icon?: string; | |
| 258 | readonly isOn?: boolean; | |
| 259 | readonly onPress?: () => void; | |
| 260 | readonly subtitle?: string; | |
| 261 | }) { | |
| 262 | return null; | |
| 263 | } | |
| 264 | NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; | |
| 265 | ||
| 266 | function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { | |
| 267 | return null; | |
| 268 | } | |
| 269 | NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; | |
| 270 | ||
| 271 | function NativeHeaderToolbarSpacer(_props: { | |
| 272 | readonly flexible?: boolean; | |
| 273 | readonly sharesBackground?: boolean; | |
| 274 | readonly width?: number; | |
| 275 | }) { | |
| 276 | return null; | |
| 277 | } | |
| 278 | NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; | |
| 279 | ||
| 280 | function NativeHeaderToolbarSearchBarSlot() { | |
| 281 | return null; | |
| 282 | } | |
| 283 | NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; | |
| 284 | ||
| 285 | export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { | |
| 286 | Button: NativeHeaderToolbarButton, | |
| 287 | Label: NativeHeaderToolbarLabel, | |
| 288 | Menu: Object.assign(NativeHeaderToolbarMenu, { | |
| 289 | Action: NativeHeaderToolbarMenuAction, | |
| 290 | }), | |
| 291 | MenuAction: NativeHeaderToolbarMenuAction, | |
| 292 | SearchBarSlot: NativeHeaderToolbarSearchBarSlot, | |
| 293 | Spacer: NativeHeaderToolbarSpacer, | |
| 294 | }); |
apps/mobile/src/native/StackHeader.tsx+3-286| ... | ... | @@ -1,22 +1,13 @@ |
| 1 | 1 | import { useNavigation, type ParamListBase } from "@react-navigation/native"; |
| 2 | 2 | import type { |
| 3 | NativeStackHeaderItem, | |
| 4 | NativeStackHeaderItemMenu, | |
| 5 | 3 | NativeStackNavigationOptions, |
| 6 | 4 | NativeStackNavigationProp, |
| 7 | 5 | } from "@react-navigation/native-stack"; |
| 8 | import { | |
| 9 | Children, | |
| 10 | isValidElement, | |
| 11 | useEffect, | |
| 12 | useLayoutEffect, | |
| 13 | useMemo, | |
| 14 | useRef, | |
| 15 | type ReactElement, | |
| 16 | type ReactNode, | |
| 17 | } from "react"; | |
| 6 | import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; | |
| 18 | 7 | import type { ColorValue } from "react-native"; |
| 19 | 8 | |
| 9 | export { NativeHeaderToolbar } from "./NativeHeaderToolbar"; | |
| 10 | ||
| 20 | 11 | export { |
| 21 | 12 | nativeHeaderScrollEdgeEffects, |
| 22 | 13 | nativeTopScrollEdgeEffect, |
| ... | ... | @@ -196,277 +187,3 @@ export function NativeStackScreenOptions(props: { |
| 196 | 187 | |
| 197 | 188 | return null; |
| 198 | 189 | } |
| 199 | ||
| 200 | function labelFromChildren(children: ReactNode): string { | |
| 201 | const parts: string[] = []; | |
| 202 | Children.forEach(children, (child) => { | |
| 203 | if (typeof child === "string" || typeof child === "number") { | |
| 204 | parts.push(String(child)); | |
| 205 | } else if (isValidElement<{ children?: ReactNode }>(child)) { | |
| 206 | parts.push(labelFromChildren(child.props.children)); | |
| 207 | } | |
| 208 | }); | |
| 209 | return parts.join(""); | |
| 210 | } | |
| 211 | ||
| 212 | type NativeStackHeaderIcon = NonNullable< | |
| 213 | Extract<NativeStackHeaderItem, { type: "button" }>["icon"] | |
| 214 | >; | |
| 215 | type NativeStackOptionsWithToolbar = NativeStackNavigationOptions & { | |
| 216 | unstable_headerToolbarItems?: () => NativeStackHeaderItem[]; | |
| 217 | }; | |
| 218 | ||
| 219 | function iconFromProp(icon: unknown): NativeStackHeaderIcon | undefined { | |
| 220 | if (typeof icon !== "string") { | |
| 221 | return undefined; | |
| 222 | } | |
| 223 | return { type: "sfSymbol", name: icon as never }; | |
| 224 | } | |
| 225 | ||
| 226 | type ToolbarElementProps = Record<string, unknown> & { readonly children?: ReactNode }; | |
| 227 | ||
| 228 | function elementTypeName(element: ReactElement): string | undefined { | |
| 229 | const type = element.type; | |
| 230 | if (typeof type === "function") { | |
| 231 | return (type as { displayName?: string; name?: string }).displayName ?? type.name; | |
| 232 | } | |
| 233 | return undefined; | |
| 234 | } | |
| 235 | ||
| 236 | function convertMenuAction( | |
| 237 | element: ReactElement<ToolbarElementProps>, | |
| 238 | ): NativeStackHeaderItemMenu["menu"]["items"][number] | null { | |
| 239 | const typeName = elementTypeName(element); | |
| 240 | if (typeName === "NativeHeaderToolbarMenuAction") { | |
| 241 | const label = labelFromChildren(element.props.children); | |
| 242 | return { | |
| 243 | type: "action", | |
| 244 | label, | |
| 245 | description: typeof element.props.subtitle === "string" ? element.props.subtitle : undefined, | |
| 246 | disabled: Boolean(element.props.disabled), | |
| 247 | icon: iconFromProp(element.props.icon), | |
| 248 | onPress: | |
| 249 | typeof element.props.onPress === "function" | |
| 250 | ? (element.props.onPress as () => void) | |
| 251 | : () => undefined, | |
| 252 | state: element.props.isOn === true ? "on" : undefined, | |
| 253 | destructive: Boolean(element.props.destructive), | |
| 254 | discoverabilityLabel: | |
| 255 | typeof element.props.discoverabilityLabel === "string" | |
| 256 | ? element.props.discoverabilityLabel | |
| 257 | : undefined, | |
| 258 | }; | |
| 259 | } | |
| 260 | ||
| 261 | if (typeName === "NativeHeaderToolbarMenu") { | |
| 262 | return { | |
| 263 | type: "submenu", | |
| 264 | label: | |
| 265 | typeof element.props.title === "string" | |
| 266 | ? element.props.title | |
| 267 | : labelFromChildren(element.props.children), | |
| 268 | icon: iconFromProp(element.props.icon), | |
| 269 | inline: Boolean(element.props.inline), | |
| 270 | items: collectMenuItems(element.props.children), | |
| 271 | }; | |
| 272 | } | |
| 273 | ||
| 274 | return null; | |
| 275 | } | |
| 276 | ||
| 277 | function collectMenuItems(children: ReactNode): NativeStackHeaderItemMenu["menu"]["items"] { | |
| 278 | const items: NativeStackHeaderItemMenu["menu"]["items"] = []; | |
| 279 | Children.forEach(children, (child) => { | |
| 280 | if (!isValidElement<ToolbarElementProps>(child)) { | |
| 281 | return; | |
| 282 | } | |
| 283 | const item = convertMenuAction(child); | |
| 284 | if (item) { | |
| 285 | items.push(item); | |
| 286 | return; | |
| 287 | } | |
| 288 | items.push(...collectMenuItems(child.props.children)); | |
| 289 | }); | |
| 290 | return items; | |
| 291 | } | |
| 292 | ||
| 293 | function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { | |
| 294 | if (!isValidElement<ToolbarElementProps>(child)) { | |
| 295 | return null; | |
| 296 | } | |
| 297 | ||
| 298 | const typeName = elementTypeName(child); | |
| 299 | if (typeName === "NativeHeaderToolbarButton") { | |
| 300 | return { | |
| 301 | type: "button", | |
| 302 | label: typeof child.props.label === "string" ? child.props.label : "", | |
| 303 | accessibilityLabel: | |
| 304 | typeof child.props.accessibilityLabel === "string" | |
| 305 | ? child.props.accessibilityLabel | |
| 306 | : undefined, | |
| 307 | disabled: Boolean(child.props.disabled), | |
| 308 | icon: iconFromProp(child.props.icon), | |
| 309 | onPress: | |
| 310 | typeof child.props.onPress === "function" | |
| 311 | ? (child.props.onPress as () => void) | |
| 312 | : () => undefined, | |
| 313 | sharesBackground: !child.props.separateBackground, | |
| 314 | tintColor: child.props.tintColor as ColorValue | undefined, | |
| 315 | variant: "plain", | |
| 316 | }; | |
| 317 | } | |
| 318 | ||
| 319 | if (typeName === "NativeHeaderToolbarMenu") { | |
| 320 | return { | |
| 321 | type: "menu", | |
| 322 | label: typeof child.props.title === "string" ? child.props.title : "", | |
| 323 | accessibilityLabel: | |
| 324 | typeof child.props.accessibilityLabel === "string" | |
| 325 | ? child.props.accessibilityLabel | |
| 326 | : undefined, | |
| 327 | disabled: Boolean(child.props.disabled), | |
| 328 | icon: iconFromProp(child.props.icon), | |
| 329 | menu: { | |
| 330 | title: typeof child.props.title === "string" ? child.props.title : undefined, | |
| 331 | items: collectMenuItems(child.props.children), | |
| 332 | }, | |
| 333 | sharesBackground: !child.props.separateBackground, | |
| 334 | tintColor: child.props.tintColor as ColorValue | undefined, | |
| 335 | variant: "plain", | |
| 336 | }; | |
| 337 | } | |
| 338 | ||
| 339 | if (typeName === "NativeHeaderToolbarSpacer") { | |
| 340 | return { | |
| 341 | type: "spacing", | |
| 342 | spacing: typeof child.props.width === "number" ? child.props.width : 8, | |
| 343 | flexible: Boolean(child.props.flexible), | |
| 344 | } as NativeStackHeaderItem; | |
| 345 | } | |
| 346 | ||
| 347 | return null; | |
| 348 | } | |
| 349 | ||
| 350 | function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { | |
| 351 | const items: NativeStackHeaderItem[] = []; | |
| 352 | Children.forEach(children, (child) => { | |
| 353 | const item = convertToolbarChild(child); | |
| 354 | if (item) { | |
| 355 | if (item.type === "spacing") { | |
| 356 | // Native inserts spacing items at `index`, treating a missing index | |
| 357 | // as 0 — which would move the spacer in front of earlier siblings. | |
| 358 | (item as { index?: number }).index = items.length; | |
| 359 | } | |
| 360 | items.push(item); | |
| 361 | } | |
| 362 | }); | |
| 363 | return items; | |
| 364 | } | |
| 365 | ||
| 366 | function NativeHeaderToolbarRoot(props: { | |
| 367 | readonly placement?: "left" | "right" | "bottom"; | |
| 368 | readonly children?: ReactNode; | |
| 369 | }) { | |
| 370 | const navigation = useNativeStackNavigation(); | |
| 371 | const items = useMemo(() => collectToolbarItems(props.children), [props.children]); | |
| 372 | ||
| 373 | // Swap toolbar owners before paint so split and compact headers cannot clear each other. | |
| 374 | useLayoutEffect(() => { | |
| 375 | if (!navigation) { | |
| 376 | return; | |
| 377 | } | |
| 378 | if (props.placement === "bottom") { | |
| 379 | navigation.setOptions({ | |
| 380 | unstable_headerToolbarItems: () => items, | |
| 381 | } as NativeStackOptionsWithToolbar); | |
| 382 | return () => { | |
| 383 | navigation.setOptions({ | |
| 384 | unstable_headerToolbarItems: () => [], | |
| 385 | } as NativeStackOptionsWithToolbar); | |
| 386 | }; | |
| 387 | } | |
| 388 | if (props.placement === "left") { | |
| 389 | navigation.setOptions({ unstable_headerLeftItems: () => items }); | |
| 390 | return () => { | |
| 391 | navigation.setOptions({ unstable_headerLeftItems: () => [] }); | |
| 392 | }; | |
| 393 | } | |
| 394 | navigation.setOptions({ unstable_headerRightItems: () => items }); | |
| 395 | return () => { | |
| 396 | navigation.setOptions({ unstable_headerRightItems: () => [] }); | |
| 397 | }; | |
| 398 | }, [items, navigation, props.placement]); | |
| 399 | ||
| 400 | return null; | |
| 401 | } | |
| 402 | ||
| 403 | function NativeHeaderToolbarButton(_props: { | |
| 404 | readonly accessibilityLabel?: string; | |
| 405 | readonly disabled?: boolean; | |
| 406 | readonly icon?: string; | |
| 407 | readonly label?: string; | |
| 408 | readonly onPress?: () => void; | |
| 409 | readonly separateBackground?: boolean; | |
| 410 | readonly tintColor?: ColorValue; | |
| 411 | }) { | |
| 412 | return null; | |
| 413 | } | |
| 414 | NativeHeaderToolbarButton.displayName = "NativeHeaderToolbarButton"; | |
| 415 | ||
| 416 | function NativeHeaderToolbarMenu(_props: { | |
| 417 | readonly accessibilityLabel?: string; | |
| 418 | readonly children?: ReactNode; | |
| 419 | readonly disabled?: boolean; | |
| 420 | readonly icon?: string; | |
| 421 | readonly inline?: boolean; | |
| 422 | readonly separateBackground?: boolean; | |
| 423 | readonly tintColor?: ColorValue; | |
| 424 | readonly title?: string; | |
| 425 | }) { | |
| 426 | return null; | |
| 427 | } | |
| 428 | NativeHeaderToolbarMenu.displayName = "NativeHeaderToolbarMenu"; | |
| 429 | ||
| 430 | function NativeHeaderToolbarMenuAction(_props: { | |
| 431 | readonly children?: ReactNode; | |
| 432 | readonly destructive?: boolean; | |
| 433 | readonly disabled?: boolean; | |
| 434 | readonly discoverabilityLabel?: string; | |
| 435 | readonly icon?: string; | |
| 436 | readonly isOn?: boolean; | |
| 437 | readonly onPress?: () => void; | |
| 438 | readonly subtitle?: string; | |
| 439 | }) { | |
| 440 | return null; | |
| 441 | } | |
| 442 | NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction"; | |
| 443 | ||
| 444 | function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { | |
| 445 | return null; | |
| 446 | } | |
| 447 | NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; | |
| 448 | ||
| 449 | function NativeHeaderToolbarSpacer(_props: { | |
| 450 | readonly flexible?: boolean; | |
| 451 | readonly sharesBackground?: boolean; | |
| 452 | readonly width?: number; | |
| 453 | }) { | |
| 454 | return null; | |
| 455 | } | |
| 456 | NativeHeaderToolbarSpacer.displayName = "NativeHeaderToolbarSpacer"; | |
| 457 | ||
| 458 | function NativeHeaderToolbarSearchBarSlot() { | |
| 459 | return null; | |
| 460 | } | |
| 461 | NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot"; | |
| 462 | ||
| 463 | export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, { | |
| 464 | Button: NativeHeaderToolbarButton, | |
| 465 | Label: NativeHeaderToolbarLabel, | |
| 466 | Menu: Object.assign(NativeHeaderToolbarMenu, { | |
| 467 | Action: NativeHeaderToolbarMenuAction, | |
| 468 | }), | |
| 469 | MenuAction: NativeHeaderToolbarMenuAction, | |
| 470 | SearchBarSlot: NativeHeaderToolbarSearchBarSlot, | |
| 471 | Spacer: NativeHeaderToolbarSpacer, | |
| 472 | }); |