mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
The status region beside the iPhone Duo's top band is the window's top-right obstruction, the same one Windows and Linux window controls publish. Every row in the band (workspace header in both widths, left sidebar's first row, explorer tab rail) starts at the band's top, sits in the top bar's height, and the row owning the top-right corner pads past the status region. Nothing starts below it. The docked explorer takes the corner from the center column. Removes the per-row clearance (useTopBandRow) and its start-below fallback. Co-authored-by: Elsa Granger <zeyugao@outlook.com>
194 lines
5.7 KiB
TypeScript
194 lines
5.7 KiB
TypeScript
import { useCallback, useMemo, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Text, View } from "react-native";
|
|
import { router } from "expo-router";
|
|
import { ArrowLeft } from "lucide-react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
|
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
|
import {
|
|
IconButtonChromeProvider,
|
|
iconButtonChromeGlyphSize,
|
|
mutedIconColorMapping,
|
|
} from "@/components/ui/icon-button-chrome";
|
|
import {
|
|
HEADER_INNER_HEIGHT,
|
|
HEADER_INNER_HEIGHT_MOBILE,
|
|
HEADER_TOP_PADDING_MOBILE,
|
|
} from "@/constants/layout";
|
|
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import { useTopBand } from "@/window-layout";
|
|
|
|
const ThemedArrowLeft = withUnistyles(ArrowLeft);
|
|
const HeaderSafeArea = withUnistyles(WindowChromeSafeArea, (theme) => ({
|
|
horizontalPadding: theme.spacing[3],
|
|
}));
|
|
|
|
const NO_SHORTCUT: ShortcutKey[] = [];
|
|
|
|
interface CompactHeaderProps {
|
|
/** The leading island: the sidebar menu, or a back button. */
|
|
navigation: "menu" | "back";
|
|
/** Back button action. Defaults to navigating back. */
|
|
onBack?: () => void;
|
|
title?: string;
|
|
titleTestID?: string;
|
|
/** A line under the title. */
|
|
subtitle?: ReactNode;
|
|
/** Show a placeholder instead of the title while it loads. */
|
|
loading?: boolean;
|
|
/** Icon actions inherit the island chrome through the shared button primitives. */
|
|
actions?: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* The phone header: the navigation button and the screen's actions sit in islands, with the title
|
|
* left-aligned between them. Neither the header nor its islands draw a background, so it reads the
|
|
* same in flow and floating over content (see `FloatingHeaderLayout`, whose fade gives the icons
|
|
* contrast). Its height matches the old flat header, so anything
|
|
* offset by the header height still lines up. In a window's top band it takes the top bar's
|
|
* place, like the full header, and keeps clear of the status region as it does of window controls.
|
|
*/
|
|
export function CompactHeader({
|
|
navigation,
|
|
onBack,
|
|
title,
|
|
titleTestID,
|
|
subtitle,
|
|
loading = false,
|
|
actions,
|
|
}: CompactHeaderProps) {
|
|
const insets = useSafeAreaInsets();
|
|
const inTopBand = useTopBand() !== null;
|
|
const rootStyle = useMemo(
|
|
() => ({
|
|
paddingTop: insets.top + (inTopBand ? 0 : HEADER_TOP_PADDING_MOBILE),
|
|
marginLeft: insets.left,
|
|
marginRight: insets.right,
|
|
}),
|
|
[inTopBand, insets.top, insets.left, insets.right],
|
|
);
|
|
|
|
return (
|
|
<IconButtonChromeProvider value="header-island">
|
|
<HeaderSafeArea placement="inline" style={rootStyle}>
|
|
<View style={inTopBand ? styles.topBandRow : styles.row}>
|
|
<View style={styles.island}>
|
|
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
|
|
</View>
|
|
<View style={styles.titleGroup}>
|
|
<CompactHeaderTitle
|
|
loading={loading}
|
|
title={title}
|
|
titleTestID={titleTestID}
|
|
subtitle={subtitle}
|
|
/>
|
|
</View>
|
|
{actions ? <View style={styles.island}>{actions}</View> : null}
|
|
</View>
|
|
</HeaderSafeArea>
|
|
</IconButtonChromeProvider>
|
|
);
|
|
}
|
|
|
|
function CompactHeaderTitle({
|
|
loading,
|
|
title,
|
|
titleTestID,
|
|
subtitle,
|
|
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
|
|
if (loading) {
|
|
return <View style={styles.titleSkeleton} />;
|
|
}
|
|
return (
|
|
<>
|
|
{title ? (
|
|
<Text
|
|
style={subtitle ? styles.title : styles.singleLineTitle}
|
|
numberOfLines={1}
|
|
testID={titleTestID}
|
|
>
|
|
{title}
|
|
</Text>
|
|
) : null}
|
|
{subtitle}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function BackButton({ onBack }: { onBack?: () => void }) {
|
|
const { t } = useTranslation();
|
|
const handlePress = useCallback(() => {
|
|
if (onBack) {
|
|
onBack();
|
|
return;
|
|
}
|
|
router.back();
|
|
}, [onBack]);
|
|
return (
|
|
<HeaderToggleButton
|
|
onPress={handlePress}
|
|
tooltipLabel={t("common.actions.back")}
|
|
tooltipKeys={NO_SHORTCUT}
|
|
tooltipSide="right"
|
|
testID="header-back-button"
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("common.actions.back")}
|
|
>
|
|
<ThemedArrowLeft
|
|
size={iconButtonChromeGlyphSize("large")}
|
|
strokeWidth={1.5}
|
|
uniProps={mutedIconColorMapping}
|
|
/>
|
|
</HeaderToggleButton>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
row: {
|
|
height: HEADER_INNER_HEIGHT_MOBILE,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
topBandRow: {
|
|
height: HEADER_INNER_HEIGHT,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
titleGroup: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
// Both title lines receive the remaining width; text alignment does not size the slot.
|
|
alignItems: "stretch",
|
|
},
|
|
island: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
title: {
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.medium,
|
|
color: theme.colors.foreground,
|
|
textAlign: "left",
|
|
},
|
|
// A title without a subtitle has the header to itself, so it reads one step larger.
|
|
singleLineTitle: {
|
|
fontSize: theme.fontSize.lg,
|
|
fontWeight: theme.fontWeight.medium,
|
|
color: theme.colors.foreground,
|
|
textAlign: "left",
|
|
},
|
|
titleSkeleton: {
|
|
width: 140,
|
|
maxWidth: "100%",
|
|
height: 22,
|
|
borderRadius: theme.borderRadius.full,
|
|
backgroundColor: theme.colors.surface3,
|
|
opacity: 0.25,
|
|
},
|
|
}));
|