Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions apps/mobile/src/components/ScreenHeader.android.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { BackHandler, Keyboard, Pressable, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { NativeStackScreenOptions } from "../native/StackHeader";
import { AndroidWorkspaceSidebarButton } from "../features/layout/workspace-sidebar-toolbar";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AndroidScreenHeader } from "./AndroidScreenHeader";
import { ScreenHeaderButton } from "./ScreenHeaderButton.android";
import { SymbolView } from "./AppSymbol";
import { ControlPillMenu } from "./ControlPill";
import { MaterialSearchField } from "./MaterialSearchField";
import { androidHeaderMenuActions, findHeaderMenuAction } from "./headerMenu.android";
import type { ScreenHeaderProps } from "./ScreenHeader.types";

export function ScreenHeader(props: ScreenHeaderProps) {
const { search } = props;
const insets = useSafeAreaInsets();
const { themeVariables } = useAppearancePreferences();
const inputRef = useRef<TextInput>(null);
const [searchOpen, setSearchOpen] = useState(false);
const searching = search !== undefined && (searchOpen || search.value.length > 0);
const onSearchChange = search?.onChangeText;
const closeSearch = useCallback(() => {
onSearchChange?.("");
setSearchOpen(false);
Keyboard.dismiss();
}, [onSearchChange]);
useEffect(() => {
if (!searching || search?.mode === "inline") return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
closeSearch();
return true;
});
return () => subscription.remove();
}, [closeSearch, searching, search?.mode]);
const menuView = props.menus?.map((menu) => (
<ControlPillMenu
key={menu.title}
actions={androidHeaderMenuActions(menu.items)}
isAnchoredToRight
title={menu.status ?? menu.title}
onPressAction={({ nativeEvent }) => {
const action = findHeaderMenuAction(menu.items, nativeEvent.event);
if (action && !action.disabled) action.onPress();
}}
>
{search?.mode === "inline" ? (
<Pressable
accessibilityLabel={menu.title}
accessibilityRole="button"
className="size-11 items-center justify-center rounded-full bg-subtle"
>
<SymbolView
name={menu.icon}
size={16}
tintColorClassName="accent-icon"
type="monochrome"
/>
</Pressable>
) : (
<ScreenHeaderButton accessibilityLabel={menu.title} icon={menu.icon} />
)}
</ControlPillMenu>
));
const options = (
<NativeStackScreenOptions
options={{
...props.options,
...(props.matchSearchSurface
? { contentStyle: { backgroundColor: themeVariables["--color-header"] } }
: undefined),
headerShown: false,
title: props.title,
}}
optionsVersion={props.optionsVersion}
/>
);
if (search?.mode === "inline") {
return (
<>
{options}
<View
className="border-b border-header-border bg-header px-3 pb-2.5"
style={{ paddingTop: Math.max(insets.top, 12), borderBottomWidth: 0 }}
>
<View className="min-h-12 flex-row items-center gap-2">
{props.onBack ? (
<Pressable
accessibilityLabel="Navigate up"
accessibilityRole="button"
hitSlop={8}
onPress={props.onBack}
className="size-11 items-center justify-center"
>
<SymbolView
name="chevron.left"
size={24}
tintColorClassName="accent-foreground"
type="monochrome"
/>
</Pressable>
) : null}
<View className="min-h-11 flex-1 flex-row items-center gap-2.5 rounded-2xl bg-input px-3.5">
<SymbolView
name="magnifyingglass"
size={17}
tintColorClassName="accent-icon"
type="monochrome"
/>
<TextInput
accessibilityLabel={search.placeholder}
autoCapitalize="none"
onChangeText={search.onChangeText}
value={search.value}
placeholder={search.placeholder}
placeholderTextColorClassName="accent-placeholder"
className="flex-1 py-2 text-base font-sans text-foreground"
/>
</View>
{menuView}
</View>
</View>
</>
);
}
const header = (
<AndroidScreenHeader
title={props.title}
subtitle={props.subtitle}
onBack={props.onBack}
leading={props.sidebar !== false ? <AndroidWorkspaceSidebarButton /> : undefined}
hideBottomBorder={props.hideBottomBorder}
actions={
search
? [
{
accessibilityLabel: search.placeholder,
icon: "magnifyingglass",
onPress: () => setSearchOpen(true),
},
...(search.onRefresh
? [
{
accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh",
icon: "arrow.clockwise" as const,
onPress: search.onRefresh,
},
]
: []),
...(props.actions ?? []),
]
: props.actions
}
trailing={
<>
{menuView}
{props.trailing}
</>
}
/>
);
return (
<>
{options}
{search ? (
<View>
<View
pointerEvents={searching ? "none" : "auto"}
accessibilityElementsHidden={searching}
importantForAccessibility={searching ? "no-hide-descendants" : "auto"}
style={{ opacity: searching ? 0 : 1 }}
>
{header}
</View>
{searching ? (
<View
className="absolute inset-0 bg-header px-2 pb-2"
style={{ paddingTop: Math.max(insets.top, 12) }}
>
<View className="min-h-14 flex-1 flex-row items-center gap-1">
<ScreenHeaderButton
accessibilityLabel={search.closeAccessibilityLabel ?? "Close search"}
icon="arrow.left"
onPress={closeSearch}
/>
<MaterialSearchField
inputRef={inputRef}
accessibilityLabel={search.placeholder}
clearAccessibilityLabel={search.clearAccessibilityLabel ?? "Clear search"}
placeholder={search.placeholder}
value={search.value}
onChangeText={search.onChangeText}
/>
</View>
</View>
) : null}
</View>
) : (
header
)}
</>
);
}
164 changes: 164 additions & 0 deletions apps/mobile/src/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens";
import { useId } from "react";
import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios";
import { ScreenHeaderButton } from "./ScreenHeaderButton";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../native/StackHeader";
import { useAdaptiveWorkspaceLayout } from "../features/layout/AdaptiveWorkspaceLayout";
import {
createNativeMailSearchToolbarItem,
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../features/layout/native-mail-search-toolbar";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import type { ScreenHeaderMenuItem, ScreenHeaderProps } from "./ScreenHeader.types";
import type { AppSymbolName } from "./AppSymbol";

function iosIcon(icon: AppSymbolName) {
return typeof icon === "string" ? icon : icon.ios;
}

type MailMenu = NonNullable<HeaderBarButtonMailSearchToolbarItem["filterMenu"]>;
function mailMenuItems(items: ReadonlyArray<ScreenHeaderMenuItem>): MailMenu["items"] {
return items.map((item) =>
"items" in item
? { type: "submenu", title: item.title ?? "", items: mailMenuItems(item.items) }
: {
type: "action",
title: item.title,
state: item.selected ? "on" : "off",
disabled: item.disabled,
onPress: item.onPress,
},
);
}

export function ScreenHeader(props: ScreenHeaderProps) {
const headerId = useId();
const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();
const { themeVariables } = useAppearancePreferences();
const { search, menus } = props;
const menu = menus?.[0];
const compactSearch =
search !== undefined &&
(search.compactToolbar ?? !layout.usesSplitView) &&
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const visibleMenus = compactSearch ? (menus?.slice(1) ?? []) : (menus ?? []);
const refresh = search?.refreshInToolbar ? search.onRefresh : undefined;
return (
<>
<NativeStackScreenOptions
optionsVersion={[
props.optionsVersion,
compactSearch ? menu : undefined,
compactSearch ? refresh : undefined,
]}
options={{
headerShown: true,
title: props.title,
unstable_headerSubtitle: props.subtitle || undefined,
...(props.matchSearchSurface
? { contentStyle: { backgroundColor: themeVariables["--color-sheet-solid"] } }
: undefined),
...(search
? {
unstable_headerToolbarItems: compactSearch
? () => [
createNativeMailSearchToolbarItem({
showsSearchDismissButton: true,
placeholder: search.compactPlaceholder ?? search.placeholder,
onSearchTextChange: search.onChangeText,
searchTextChangeId: `${headerId}-search-text`,
...(refresh
? {
composeButtonId: `${headerId}-refresh`,
composeSystemImageName: "arrow.clockwise",
onComposePress: refresh,
}
: undefined),
...(menu
? {
filterButtonId: `${headerId}-filter`,
filterSystemImageName: iosIcon(menu.icon),
filterMenu: { title: menu.title, items: mailMenuItems(menu.items) },
}
: undefined),
}),
]
: undefined,
headerSearchBarOptions: compactSearch
? undefined
: {
allowToolbarIntegration: true,
...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED && search.mode === "inline"
? { placement: "integratedButton" as const }
: undefined),
autoCapitalize: "none",
hideNavigationBar: false,
...(search.mode === "inline" ? { obscureBackground: false } : undefined),
placeholder: search.placeholder,
onChangeText: (event) => search.onChangeText(event.nativeEvent.text),
onCancelButtonPress: () => search.onChangeText(""),
},
}
: undefined),
...props.options,
}}
/>
{props.sidebar !== false && layout.usesSplitView ? (
<NativeHeaderToolbar placement="left">
{props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? (
<ScreenHeaderButton
{...props.backInSplitView}
onPress={props.backInSplitView.onPress ?? props.onBack}
/>
) : null}
<ScreenHeaderButton
accessibilityLabel={
panes.primarySidebarVisible ? `Maximize ${props.title.toLowerCase()}` : "Show threads"
}
icon={
panes.primarySidebarVisible ? "arrow.up.left.and.arrow.down.right" : "sidebar.left"
}
onPress={togglePrimarySidebar}
separateBackground={
props.backInSplitView ? props.backInSplitView.separateBackground : true
}
/>
</NativeHeaderToolbar>
) : null}
{(props.actions?.length ||
(!compactSearch && refresh) ||
visibleMenus.length ||
props.trailing) &&
props.options?.unstable_headerRightItems === undefined ? (
<NativeHeaderToolbar placement="right">
{props.actions?.map((action) => (
<ScreenHeaderButton
key={action.accessibilityLabel}
{...action}
icon={iosIcon(action.icon)}
separateBackground
/>
))}
{refresh && !compactSearch ? (
<ScreenHeaderButton
accessibilityLabel={search?.refreshAccessibilityLabel}
icon="arrow.clockwise"
onPress={refresh}
separateBackground
/>
) : null}
{visibleMenus.map(createNativeHeaderMenu)}
{props.trailing}
</NativeHeaderToolbar>
) : null}
</>
);
}

export type {
ScreenHeaderAction,
ScreenHeaderMenu,
ScreenHeaderMenuItem,
ScreenHeaderProps,
ScreenHeaderSearch,
} from "./ScreenHeader.types";
Loading
Loading