chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,24 @@
import { useTheme } from '@react-navigation/native';
import * as React from 'react';
import {
Animated,
type StyleProp,
type ViewProps,
type ViewStyle,
} from 'react-native';
type Props = Omit<ViewProps, 'style'> & {
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
children: React.ReactNode;
};
export function Background({ style, ...rest }: Props) {
const { colors } = useTheme();
return (
<Animated.View
{...rest}
style={[{ flex: 1, backgroundColor: colors.background }, style]}
/>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useTheme } from '@react-navigation/native';
import Color from 'color';
import * as React from 'react';
import {
Animated,
Platform,
type StyleProp,
StyleSheet,
type TextProps,
type TextStyle,
} from 'react-native';
type Props = TextProps & {
/**
* Whether the badge is visible
*/
visible: boolean;
/**
* Content of the `Badge`.
*/
children?: string | number;
/**
* Size of the `Badge`.
*/
size?: number;
/**
* Style object for the tab bar container.
*/
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
};
const useNativeDriver = Platform.OS !== 'web';
export function Badge({
children,
style,
visible = true,
size = 18,
...rest
}: Props) {
const [opacity] = React.useState(() => new Animated.Value(visible ? 1 : 0));
const [rendered, setRendered] = React.useState(visible);
const { colors, fonts } = useTheme();
React.useEffect(() => {
if (!rendered) {
return;
}
Animated.timing(opacity, {
toValue: visible ? 1 : 0,
duration: 150,
useNativeDriver,
}).start(({ finished }) => {
if (finished && !visible) {
setRendered(false);
}
});
return () => opacity.stopAnimation();
}, [opacity, rendered, visible]);
if (!rendered) {
if (visible) {
setRendered(true);
} else {
return null;
}
}
// @ts-expect-error: backgroundColor definitely exists
const { backgroundColor = colors.notification, ...restStyle } =
StyleSheet.flatten(style) || {};
const textColor = Color(backgroundColor).isLight() ? 'black' : 'white';
const borderRadius = size / 2;
const fontSize = Math.floor((size * 3) / 4);
return (
<Animated.Text
numberOfLines={1}
style={[
{
transform: [
{
scale: opacity.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
}),
},
],
color: textColor,
lineHeight: size - 1,
height: size,
minWidth: size,
opacity,
backgroundColor,
fontSize,
borderRadius,
borderCurve: 'continuous',
},
fonts.regular,
styles.container,
restStyle,
]}
{...rest}
>
{children}
</Animated.Text>
);
}
const styles = StyleSheet.create({
container: {
alignSelf: 'flex-end',
textAlign: 'center',
paddingHorizontal: 4,
overflow: 'hidden',
},
});
+121
View File
@@ -0,0 +1,121 @@
import {
type LinkProps,
useLinkProps,
useTheme,
} from '@react-navigation/native';
import Color from 'color';
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
import {
PlatformPressable,
type Props as PlatformPressableProps,
} from './PlatformPressable';
import { Text } from './Text';
type ButtonBaseProps = Omit<PlatformPressableProps, 'children'> & {
variant?: 'plain' | 'tinted' | 'filled';
color?: string;
children: string | string[];
};
type ButtonLinkProps<ParamList extends ReactNavigation.RootParamList> =
LinkProps<ParamList> & Omit<ButtonBaseProps, 'onPress'>;
const BUTTON_RADIUS = 40;
export function Button<ParamList extends ReactNavigation.RootParamList>(
props: ButtonLinkProps<ParamList>
): React.JSX.Element;
export function Button(props: ButtonBaseProps): React.JSX.Element;
export function Button<ParamList extends ReactNavigation.RootParamList>(
props: ButtonBaseProps | ButtonLinkProps<ParamList>
) {
if ('screen' in props || 'action' in props) {
// @ts-expect-error: This is already type-checked by the prop types
return <ButtonLink {...props} />;
} else {
return <ButtonBase {...props} />;
}
}
function ButtonLink<ParamList extends ReactNavigation.RootParamList>({
screen,
params,
action,
href,
...rest
}: ButtonLinkProps<ParamList>) {
// @ts-expect-error: This is already type-checked by the prop types
const props = useLinkProps({ screen, params, action, href });
return <ButtonBase {...rest} {...props} />;
}
function ButtonBase({
variant = 'tinted',
color: customColor,
android_ripple,
style,
children,
...rest
}: ButtonBaseProps) {
const { colors, fonts } = useTheme();
const color = customColor ?? colors.primary;
let backgroundColor;
let textColor;
switch (variant) {
case 'plain':
backgroundColor = 'transparent';
textColor = color;
break;
case 'tinted':
backgroundColor = Color(color).fade(0.85).string();
textColor = color;
break;
case 'filled':
backgroundColor = color;
textColor = Color(color).isDark()
? 'white'
: Color(color).darken(0.71).string();
break;
}
return (
<PlatformPressable
{...rest}
android_ripple={{
radius: BUTTON_RADIUS,
color: Color(textColor).fade(0.85).string(),
...android_ripple,
}}
pressOpacity={Platform.OS === 'ios' ? undefined : 1}
hoverEffect={{ color: textColor }}
style={[{ backgroundColor }, styles.button, style]}
>
<Text style={[{ color: textColor }, fonts.regular, styles.text]}>
{children}
</Text>
</PlatformPressable>
);
}
const styles = StyleSheet.create({
button: {
paddingHorizontal: 24,
paddingVertical: 10,
borderRadius: BUTTON_RADIUS,
borderCurve: 'continuous',
},
text: {
fontSize: 14,
lineHeight: 20,
letterSpacing: 0.1,
textAlign: 'center',
},
});
@@ -0,0 +1,472 @@
import { useNavigation, useTheme } from '@react-navigation/native';
import Color from 'color';
import * as React from 'react';
import {
Animated,
type LayoutChangeEvent,
Platform,
StyleSheet,
View,
type ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import searchIcon from '../assets/search-icon.png';
import type { HeaderOptions, Layout } from '../types';
import { useFrameSize } from '../useFrameSize';
import { getDefaultHeaderHeight } from './getDefaultHeaderHeight';
import { HeaderBackButton } from './HeaderBackButton';
import { HeaderBackground } from './HeaderBackground';
import { HeaderButton } from './HeaderButton';
import { HeaderIcon } from './HeaderIcon';
import { HeaderSearchBar } from './HeaderSearchBar';
import { HeaderShownContext } from './HeaderShownContext';
import { HeaderTitle } from './HeaderTitle';
// Width of the screen in split layout on portrait mode on iPad Mini
const IPAD_MINI_MEDIUM_WIDTH = 414;
type Props = HeaderOptions & {
/**
* Options for the back button.
*/
back?: {
/**
* Title of the previous screen.
*/
title: string | undefined;
/**
* The `href` to use for the anchor tag on web
*/
href: string | undefined;
};
/**
* Whether the header is in a modal
*/
modal?: boolean;
/**
* Layout of the screen.
*/
layout?: Layout;
/**
* Title text for the header.
*/
title: string;
};
const warnIfHeaderStylesDefined = (styles: Record<string, any>) => {
Object.keys(styles).forEach((styleProp) => {
const value = styles[styleProp];
if (styleProp === 'position' && value === 'absolute') {
console.warn(
"position: 'absolute' is not supported on headerStyle. If you would like to render content under the header, use the 'headerTransparent' option."
);
} else if (value !== undefined) {
console.warn(
`${styleProp} was given a value of ${value}, this has no effect on headerStyle.`
);
}
});
};
export function Header(props: Props) {
const insets = useSafeAreaInsets();
const frame = useFrameSize((size) => size, true);
const { colors } = useTheme();
const navigation = useNavigation();
const isParentHeaderShown = React.useContext(HeaderShownContext);
const [searchBarVisible, setSearchBarVisible] = React.useState(false);
const [titleLayout, setTitleLayout] = React.useState<Layout | undefined>(
undefined
);
const onTitleLayout = (e: LayoutChangeEvent) => {
const { height, width } = e.nativeEvent.layout;
setTitleLayout((titleLayout) => {
if (
titleLayout &&
height === titleLayout.height &&
width === titleLayout.width
) {
return titleLayout;
}
return { height, width };
});
};
const {
layout = frame,
modal = false,
back,
title,
headerTitle: customTitle,
headerTitleAlign = Platform.OS === 'ios' ? 'center' : 'left',
headerLeft = back ? (props) => <HeaderBackButton {...props} /> : undefined,
headerSearchBarOptions,
headerTransparent,
headerTintColor,
headerBackground,
headerRight,
headerTitleAllowFontScaling: titleAllowFontScaling,
headerTitleStyle: titleStyle,
headerLeftContainerStyle: leftContainerStyle,
headerRightContainerStyle: rightContainerStyle,
headerTitleContainerStyle: titleContainerStyle,
headerBackButtonDisplayMode = Platform.OS === 'ios' ? 'default' : 'minimal',
headerBackTitleStyle,
headerBackgroundContainerStyle: backgroundContainerStyle,
headerStyle: customHeaderStyle,
headerShadowVisible,
headerPressColor,
headerPressOpacity,
headerStatusBarHeight = isParentHeaderShown ? 0 : insets.top,
} = props;
const defaultHeight = getDefaultHeaderHeight(
layout,
modal,
headerStatusBarHeight
);
const {
height = defaultHeight,
maxHeight,
minHeight,
backfaceVisibility,
backgroundColor,
borderBlockColor,
borderBlockEndColor,
borderBlockStartColor,
borderBottomColor,
borderBottomEndRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
borderBottomStartRadius,
borderBottomWidth,
borderColor,
borderCurve,
borderEndColor,
borderEndEndRadius,
borderEndStartRadius,
borderEndWidth,
borderLeftColor,
borderLeftWidth,
borderRadius,
borderRightColor,
borderRightWidth,
borderStartColor,
borderStartEndRadius,
borderStartStartRadius,
borderStartWidth,
borderStyle,
borderTopColor,
borderTopEndRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderTopStartRadius,
borderTopWidth,
borderWidth,
boxShadow,
elevation,
filter,
mixBlendMode,
opacity,
shadowColor,
shadowOffset,
shadowOpacity,
shadowRadius,
transform,
transformOrigin,
...unsafeStyles
} = StyleSheet.flatten(customHeaderStyle || {}) as ViewStyle;
if (process.env.NODE_ENV !== 'production') {
warnIfHeaderStylesDefined(unsafeStyles);
}
const safeStyles: ViewStyle = {
backfaceVisibility,
backgroundColor,
borderBlockColor,
borderBlockEndColor,
borderBlockStartColor,
borderBottomColor,
borderBottomEndRadius,
borderBottomLeftRadius,
borderBottomRightRadius,
borderBottomStartRadius,
borderBottomWidth,
borderColor,
borderCurve,
borderEndColor,
borderEndEndRadius,
borderEndStartRadius,
borderEndWidth,
borderLeftColor,
borderLeftWidth,
borderRadius,
borderRightColor,
borderRightWidth,
borderStartColor,
borderStartEndRadius,
borderStartStartRadius,
borderStartWidth,
borderStyle,
borderTopColor,
borderTopEndRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderTopStartRadius,
borderTopWidth,
borderWidth,
boxShadow,
elevation,
filter,
mixBlendMode,
opacity,
shadowColor,
shadowOffset,
shadowOpacity,
shadowRadius,
transform,
transformOrigin,
};
// Setting a property to undefined triggers default style
// So we need to filter them out
// Users can use `null` instead
for (const styleProp in safeStyles) {
// @ts-expect-error: typescript wrongly complains that styleProp cannot be used to index safeStyles
if (safeStyles[styleProp] === undefined) {
// @ts-expect-error don't need to care about index signature for deletion
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete safeStyles[styleProp];
}
}
const backgroundStyle = {
...(headerTransparent && { backgroundColor: 'transparent' }),
...((headerTransparent || headerShadowVisible === false) && {
borderBottomWidth: 0,
...Platform.select({
android: {
elevation: 0,
},
web: {
boxShadow: 'none',
},
default: {
shadowOpacity: 0,
},
}),
}),
...safeStyles,
};
const iconTintColor =
headerTintColor ??
Platform.select({
ios: colors.primary,
default: colors.text,
});
const leftButton = headerLeft
? headerLeft({
tintColor: iconTintColor,
pressColor: headerPressColor,
pressOpacity: headerPressOpacity,
displayMode: headerBackButtonDisplayMode,
titleLayout,
screenLayout: layout,
canGoBack: Boolean(back),
onPress: back ? navigation.goBack : undefined,
label: back?.title,
labelStyle: headerBackTitleStyle,
href: back?.href,
})
: null;
const rightButton = headerRight
? headerRight({
tintColor: iconTintColor,
pressColor: headerPressColor,
pressOpacity: headerPressOpacity,
canGoBack: Boolean(back),
})
: null;
const headerTitle =
typeof customTitle !== 'function'
? (props: React.ComponentProps<typeof HeaderTitle>) => (
<HeaderTitle {...props} />
)
: customTitle;
return (
<Animated.View
pointerEvents="box-none"
style={[{ height, minHeight, maxHeight, opacity, transform }]}
>
<Animated.View
pointerEvents="box-none"
style={[StyleSheet.absoluteFill, backgroundContainerStyle]}
>
{headerBackground ? (
headerBackground({ style: backgroundStyle })
) : (
<HeaderBackground
pointerEvents={
// Allow touch through the header when background color is transparent
headerTransparent &&
(backgroundStyle.backgroundColor === 'transparent' ||
Color(backgroundStyle.backgroundColor).alpha() === 0)
? 'none'
: 'auto'
}
style={backgroundStyle}
/>
)}
</Animated.View>
<View pointerEvents="none" style={{ height: headerStatusBarHeight }} />
<View
pointerEvents="box-none"
style={[
styles.content,
Platform.OS === 'ios' && frame.width >= IPAD_MINI_MEDIUM_WIDTH
? styles.large
: null,
]}
>
<Animated.View
pointerEvents="box-none"
style={[
styles.start,
!searchBarVisible && headerTitleAlign === 'center' && styles.expand,
{ marginStart: insets.left },
leftContainerStyle,
]}
>
{leftButton}
</Animated.View>
{Platform.OS === 'ios' || !searchBarVisible ? (
<>
<Animated.View
pointerEvents="box-none"
style={[
styles.title,
{
// Avoid the title from going offscreen or overlapping buttons
maxWidth:
headerTitleAlign === 'center'
? layout.width -
((leftButton
? headerBackButtonDisplayMode !== 'minimal'
? 80
: 32
: 16) +
(rightButton || headerSearchBarOptions ? 16 : 0) +
Math.max(insets.left, insets.right)) *
2
: layout.width -
((leftButton ? 52 : 16) +
(rightButton || headerSearchBarOptions ? 52 : 16) +
insets.left -
insets.right),
},
headerTitleAlign === 'left' && leftButton
? { marginStart: 4 }
: { marginHorizontal: 16 },
titleContainerStyle,
]}
>
{headerTitle({
children: title,
allowFontScaling: titleAllowFontScaling,
tintColor: headerTintColor,
onLayout: onTitleLayout,
style: titleStyle,
})}
</Animated.View>
<Animated.View
pointerEvents="box-none"
style={[
styles.end,
styles.expand,
{ marginEnd: insets.right },
rightContainerStyle,
]}
>
{rightButton}
{headerSearchBarOptions ? (
<HeaderButton
tintColor={iconTintColor}
pressColor={headerPressColor}
pressOpacity={headerPressOpacity}
onPress={() => {
setSearchBarVisible(true);
headerSearchBarOptions?.onOpen?.();
}}
>
<HeaderIcon source={searchIcon} tintColor={iconTintColor} />
</HeaderButton>
) : null}
</Animated.View>
</>
) : null}
{Platform.OS === 'ios' || searchBarVisible ? (
<HeaderSearchBar
{...headerSearchBarOptions}
visible={searchBarVisible}
onClose={() => {
setSearchBarVisible(false);
headerSearchBarOptions?.onClose?.();
}}
tintColor={headerTintColor}
style={[
Platform.OS === 'ios'
? [
StyleSheet.absoluteFill,
{ paddingTop: headerStatusBarHeight ? 0 : 4 },
{ backgroundColor: backgroundColor ?? colors.card },
]
: !leftButton && { marginStart: 8 },
]}
/>
) : null}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
flexDirection: 'row',
alignItems: 'stretch',
},
large: {
marginHorizontal: 5,
},
title: {
justifyContent: 'center',
},
start: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
end: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
expand: {
flexGrow: 1,
flexBasis: 0,
},
});
@@ -0,0 +1,249 @@
import { useLocale, useTheme } from '@react-navigation/native';
import * as React from 'react';
import {
Animated,
Image,
Platform,
type StyleProp,
StyleSheet,
type TextStyle,
View,
} from 'react-native';
import backIcon from '../assets/back-icon.png';
import backIconMask from '../assets/back-icon-mask.png';
import { MaskedView } from '../MaskedView';
import type { HeaderBackButtonProps } from '../types';
import { HeaderButton } from './HeaderButton';
import { HeaderIcon, ICON_MARGIN } from './HeaderIcon';
export function HeaderBackButton({
disabled,
allowFontScaling,
backImage,
label,
labelStyle,
displayMode = Platform.OS === 'ios' ? 'default' : 'minimal',
onLabelLayout,
onPress,
pressColor,
pressOpacity,
screenLayout,
tintColor,
titleLayout,
truncatedLabel = 'Back',
accessibilityLabel = label && label !== 'Back' ? `${label}, back` : 'Go back',
testID,
style,
href,
}: HeaderBackButtonProps) {
const { colors, fonts } = useTheme();
const { direction } = useLocale();
const [labelWidth, setLabelWidth] = React.useState<number | null>(null);
const [truncatedLabelWidth, setTruncatedLabelWidth] = React.useState<
number | null
>(null);
const renderBackImage = () => {
if (backImage) {
return backImage({ tintColor: tintColor ?? colors.text });
} else {
return (
<HeaderIcon
source={backIcon}
tintColor={tintColor}
style={[
styles.icon,
displayMode !== 'minimal' && styles.iconWithLabel,
]}
/>
);
}
};
const renderLabel = () => {
if (displayMode === 'minimal') {
return null;
}
const availableSpace =
titleLayout && screenLayout
? (screenLayout.width - titleLayout.width) / 2 -
(ICON_WIDTH + ICON_MARGIN)
: null;
const potentialLabelText =
displayMode === 'default' ? label : truncatedLabel;
const finalLabelText =
availableSpace && labelWidth && truncatedLabelWidth
? availableSpace > labelWidth
? potentialLabelText
: availableSpace > truncatedLabelWidth
? truncatedLabel
: null
: potentialLabelText;
const commonStyle: Animated.WithAnimatedValue<StyleProp<TextStyle>> = [
fonts.regular,
styles.label,
labelStyle,
];
const hiddenStyle: Animated.WithAnimatedValue<StyleProp<TextStyle>> = [
commonStyle,
{
position: 'absolute',
top: 0,
left: 0,
opacity: 0,
},
];
const labelElement = (
<View style={styles.labelWrapper}>
{label && displayMode === 'default' ? (
<Animated.Text
style={hiddenStyle}
numberOfLines={1}
onLayout={(e) => setLabelWidth(e.nativeEvent.layout.width)}
>
{label}
</Animated.Text>
) : null}
{truncatedLabel ? (
<Animated.Text
style={hiddenStyle}
numberOfLines={1}
onLayout={(e) => setTruncatedLabelWidth(e.nativeEvent.layout.width)}
>
{truncatedLabel}
</Animated.Text>
) : null}
{finalLabelText ? (
<Animated.Text
accessible={false}
onLayout={onLabelLayout}
style={[tintColor ? { color: tintColor } : null, commonStyle]}
numberOfLines={1}
allowFontScaling={!!allowFontScaling}
>
{finalLabelText}
</Animated.Text>
) : null}
</View>
);
if (backImage || Platform.OS !== 'ios') {
// When a custom backimage is specified, we can't mask the label
// Otherwise there might be weird effect due to our mask not being the same as the image
return labelElement;
}
return (
<MaskedView
maskElement={
<View
style={[
styles.iconMaskContainer,
// Extend the mask to the center of the screen so that label isn't clipped during animation
screenLayout ? { minWidth: screenLayout.width / 2 - 27 } : null,
]}
>
<Image
source={backIconMask}
resizeMode="contain"
style={[styles.iconMask, direction === 'rtl' && styles.flip]}
/>
<View style={styles.iconMaskFillerRect} />
</View>
}
>
{labelElement}
</MaskedView>
);
};
const handlePress = () => {
if (onPress) {
requestAnimationFrame(() => onPress());
}
};
return (
<HeaderButton
disabled={disabled}
href={href}
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={handlePress}
pressColor={pressColor}
pressOpacity={pressOpacity}
style={[styles.container, style]}
>
<React.Fragment>
{renderBackImage()}
{renderLabel()}
</React.Fragment>
</HeaderButton>
);
}
const ICON_WIDTH = Platform.OS === 'ios' ? 13 : 24;
const ICON_MARGIN_END = Platform.OS === 'ios' ? 22 : 3;
const styles = StyleSheet.create({
container: {
paddingHorizontal: 0,
minWidth: StyleSheet.hairlineWidth, // Avoid collapsing when title is long
...Platform.select({
ios: null,
default: {
marginVertical: 3,
marginHorizontal: 11,
},
}),
},
label: {
fontSize: 17,
// Title and back label are a bit different width due to title being bold
// Adjusting the letterSpacing makes them coincide better
letterSpacing: 0.35,
},
labelWrapper: {
// These styles will make sure that the label doesn't fill the available space
// Otherwise it messes with the measurement of the label
flexDirection: 'row',
alignItems: 'flex-start',
marginEnd: ICON_MARGIN,
},
icon: {
width: ICON_WIDTH,
marginEnd: ICON_MARGIN_END,
},
iconWithLabel:
Platform.OS === 'ios'
? {
marginEnd: 6,
}
: {},
iconMaskContainer: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
},
iconMaskFillerRect: {
flex: 1,
backgroundColor: '#000',
},
iconMask: {
height: 21,
width: 13,
marginStart: -14.5,
marginVertical: 12,
alignSelf: 'center',
},
flip: {
transform: 'scaleX(-1)',
},
});
@@ -0,0 +1,5 @@
import { getNamedContext } from '../getNamedContext';
export const HeaderBackContext = getNamedContext<
{ title: string | undefined; href: string | undefined } | undefined
>('HeaderBackContext', undefined);
@@ -0,0 +1,60 @@
import { useTheme } from '@react-navigation/native';
import * as React from 'react';
import {
Animated,
Platform,
type StyleProp,
StyleSheet,
type ViewProps,
type ViewStyle,
} from 'react-native';
type Props = Omit<ViewProps, 'style'> & {
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
children?: React.ReactNode;
};
export function HeaderBackground({ style, ...rest }: Props) {
const { colors, dark } = useTheme();
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: colors.card,
borderBottomColor: colors.border,
...(Platform.OS === 'ios' && {
shadowColor: dark
? 'rgba(255, 255, 255, 0.45)'
: 'rgba(0, 0, 0, 1)',
}),
},
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
...Platform.select({
android: {
elevation: 4,
},
ios: {
shadowOpacity: 0.3,
shadowRadius: 0,
shadowOffset: {
width: 0,
height: StyleSheet.hairlineWidth,
},
},
default: {
borderBottomWidth: StyleSheet.hairlineWidth,
},
}),
},
});
@@ -0,0 +1,65 @@
import * as React from 'react';
import { Platform, StyleSheet } from 'react-native';
import { PlatformPressable } from '../PlatformPressable';
import type { HeaderButtonProps } from '../types';
function HeaderButtonInternal(
{
disabled,
onPress,
pressColor,
pressOpacity,
accessibilityLabel,
testID,
style,
href,
children,
}: HeaderButtonProps,
ref: React.Ref<React.ComponentRef<typeof PlatformPressable>>
) {
return (
<PlatformPressable
ref={ref}
disabled={disabled}
href={href}
aria-label={accessibilityLabel}
testID={testID}
onPress={onPress}
pressColor={pressColor}
pressOpacity={pressOpacity}
android_ripple={androidRipple}
style={[styles.container, disabled && styles.disabled, style]}
hitSlop={Platform.select({
ios: undefined,
default: { top: 16, right: 16, bottom: 16, left: 16 },
})}
>
{children}
</PlatformPressable>
);
}
export const HeaderButton = React.forwardRef(HeaderButtonInternal);
HeaderButton.displayName = 'HeaderButton';
const androidRipple = {
borderless: true,
foreground: Platform.OS === 'android' && Platform.Version >= 23,
radius: 20,
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
// Roundness for iPad hover effect
borderRadius: 10,
borderCurve: 'continuous',
},
disabled: {
opacity: 0.5,
},
});
@@ -0,0 +1,6 @@
import { getNamedContext } from '../getNamedContext';
export const HeaderHeightContext = getNamedContext<number | undefined>(
'HeaderHeightContext',
undefined
);
@@ -0,0 +1,32 @@
import { useLocale, useTheme } from '@react-navigation/native';
import { Image, type ImageProps, Platform, StyleSheet } from 'react-native';
export function HeaderIcon({ source, style, ...rest }: ImageProps) {
const { colors } = useTheme();
const { direction } = useLocale();
return (
<Image
source={source}
resizeMode="contain"
fadeDuration={0}
tintColor={colors.text}
style={[styles.icon, direction === 'rtl' && styles.flip, style]}
{...rest}
/>
);
}
export const ICON_SIZE = Platform.OS === 'ios' ? 21 : 24;
export const ICON_MARGIN = Platform.OS === 'ios' ? 8 : 3;
const styles = StyleSheet.create({
icon: {
width: ICON_SIZE,
height: ICON_SIZE,
margin: ICON_MARGIN,
},
flip: {
transform: 'scaleX(-1)',
},
});
@@ -0,0 +1,323 @@
import { useNavigation, useTheme } from '@react-navigation/native';
import Color from 'color';
import * as React from 'react';
import {
Animated,
Image,
Platform,
type StyleProp,
StyleSheet,
TextInput,
View,
type ViewStyle,
} from 'react-native';
import clearIcon from '../assets/clear-icon.png';
import closeIcon from '../assets/close-icon.png';
import searchIcon from '../assets/search-icon.png';
import { PlatformPressable } from '../PlatformPressable';
import { Text } from '../Text';
import type { HeaderSearchBarOptions, HeaderSearchBarRef } from '../types';
import { HeaderButton } from './HeaderButton';
import { HeaderIcon } from './HeaderIcon';
type Props = Omit<HeaderSearchBarOptions, 'ref'> & {
visible: boolean;
onClose: () => void;
tintColor?: string;
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
};
const INPUT_TYPE_TO_MODE = {
text: 'text',
number: 'numeric',
phone: 'tel',
email: 'email',
} as const;
const useNativeDriver = Platform.OS !== 'web';
function HeaderSearchBarInternal(
{
visible,
inputType,
autoFocus = true,
autoCapitalize,
placeholder = 'Search',
cancelButtonText = 'Cancel',
enterKeyHint = 'search',
onChangeText,
onClose,
tintColor,
style,
...rest
}: Props,
ref: React.ForwardedRef<HeaderSearchBarRef>
) {
const navigation = useNavigation();
const { dark, colors, fonts } = useTheme();
const [value, setValue] = React.useState('');
const [rendered, setRendered] = React.useState(visible);
const [visibleAnim] = React.useState(
() => new Animated.Value(visible ? 1 : 0)
);
const [clearVisibleAnim] = React.useState(() => new Animated.Value(0));
const visibleValueRef = React.useRef(visible);
const clearVisibleValueRef = React.useRef(false);
const inputRef = React.useRef<TextInput>(null);
React.useEffect(() => {
// Avoid act warning in tests just by rendering header
if (visible === visibleValueRef.current) {
return;
}
Animated.timing(visibleAnim, {
toValue: visible ? 1 : 0,
duration: 100,
useNativeDriver,
}).start(({ finished }) => {
if (finished) {
setRendered(visible);
visibleValueRef.current = visible;
}
});
return () => {
visibleAnim.stopAnimation();
};
}, [visible, visibleAnim]);
const hasText = value !== '';
React.useEffect(() => {
if (clearVisibleValueRef.current === hasText) {
return;
}
Animated.timing(clearVisibleAnim, {
toValue: hasText ? 1 : 0,
duration: 100,
useNativeDriver,
}).start(({ finished }) => {
if (finished) {
clearVisibleValueRef.current = hasText;
}
});
}, [clearVisibleAnim, hasText]);
const clearText = React.useCallback(() => {
inputRef.current?.clear();
inputRef.current?.focus();
setValue('');
}, []);
const onClear = React.useCallback(() => {
clearText();
// FIXME: figure out how to create a SyntheticEvent
// @ts-expect-error: we don't have the native event here
onChangeText?.({ nativeEvent: { text: '' } });
}, [clearText, onChangeText]);
const cancelSearch = React.useCallback(() => {
onClear();
onClose();
}, [onClear, onClose]);
React.useEffect(
() => navigation?.addListener('blur', cancelSearch),
[cancelSearch, navigation]
);
React.useImperativeHandle(
ref,
() => ({
focus: () => {
inputRef.current?.focus();
},
blur: () => {
inputRef.current?.blur();
},
setText: (text: string) => {
inputRef.current?.setNativeProps({ text });
setValue(text);
},
clearText,
cancelSearch,
}),
[cancelSearch, clearText]
);
if (!visible && !rendered) {
return null;
}
const textColor = tintColor ?? colors.text;
return (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
aria-live="polite"
aria-hidden={!visible}
style={[styles.container, { opacity: visibleAnim }, style]}
>
<View style={styles.searchbarContainer}>
<HeaderIcon
source={searchIcon}
tintColor={textColor}
style={styles.inputSearchIcon}
/>
<TextInput
{...rest}
ref={inputRef}
onChange={onChangeText}
onChangeText={setValue}
autoFocus={autoFocus}
autoCapitalize={
autoCapitalize === 'systemDefault' ? undefined : autoCapitalize
}
inputMode={INPUT_TYPE_TO_MODE[inputType ?? 'text']}
enterKeyHint={enterKeyHint}
placeholder={placeholder}
placeholderTextColor={Color(textColor).alpha(0.5).string()}
cursorColor={colors.primary}
selectionHandleColor={colors.primary}
selectionColor={Color(colors.primary).alpha(0.3).string()}
style={[
fonts.regular,
styles.searchbar,
{
backgroundColor: Platform.select({
ios: dark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
default: 'transparent',
}),
color: textColor,
borderBottomColor: Color(textColor).alpha(0.2).string(),
},
]}
/>
{Platform.OS === 'ios' ? (
<PlatformPressable
onPress={onClear}
style={[
{
opacity: clearVisibleAnim,
transform: [{ scale: clearVisibleAnim }],
},
styles.clearButton,
]}
>
<Image
source={clearIcon}
resizeMode="contain"
tintColor={textColor}
style={styles.clearIcon}
/>
</PlatformPressable>
) : null}
</View>
{Platform.OS !== 'ios' ? (
<HeaderButton
onPress={() => {
if (value) {
onClear();
} else {
onClose();
}
}}
style={styles.closeButton}
>
<HeaderIcon source={closeIcon} tintColor={textColor} />
</HeaderButton>
) : null}
{Platform.OS === 'ios' ? (
<PlatformPressable onPress={cancelSearch} style={styles.cancelButton}>
<Text
style={[
fonts.regular,
{ color: tintColor ?? colors.primary },
styles.cancelText,
]}
>
{cancelButtonText}
</Text>
</PlatformPressable>
) : null}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
alignItems: 'stretch',
},
inputSearchIcon: {
position: 'absolute',
opacity: 0.5,
left: Platform.select({ ios: 16, default: 4 }),
top: Platform.select({ ios: -1, default: 17 }),
...Platform.select({
ios: {
height: 18,
width: 18,
},
default: {},
}),
},
closeButton: {
position: 'absolute',
opacity: 0.5,
right: Platform.select({ ios: 0, default: 8 }),
top: Platform.select({ ios: -2, default: 17 }),
},
clearButton: {
position: 'absolute',
right: 0,
top: -7,
bottom: 0,
justifyContent: 'center',
padding: 8,
},
clearIcon: {
height: 16,
width: 16,
opacity: 0.5,
},
cancelButton: {
alignSelf: 'center',
top: -4,
},
cancelText: {
fontSize: 17,
marginHorizontal: 12,
},
searchbarContainer: {
flex: 1,
},
searchbar: Platform.select({
ios: {
flex: 1,
fontSize: 17,
paddingHorizontal: 32,
marginLeft: 16,
marginTop: -1,
marginBottom: 4,
borderRadius: 8,
borderCurve: 'continuous',
},
default: {
flex: 1,
fontSize: 18,
paddingHorizontal: 36,
marginRight: 8,
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 1,
},
}),
});
export const HeaderSearchBar = React.forwardRef(HeaderSearchBarInternal);
@@ -0,0 +1,3 @@
import { getNamedContext } from '../getNamedContext';
export const HeaderShownContext = getNamedContext('HeaderShownContext', false);
@@ -0,0 +1,48 @@
import { useTheme } from '@react-navigation/native';
import {
Animated,
Platform,
type StyleProp,
StyleSheet,
type TextProps,
type TextStyle,
} from 'react-native';
type Props = Omit<TextProps, 'style'> & {
tintColor?: string;
children?: string;
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
};
export function HeaderTitle({ tintColor, style, ...rest }: Props) {
const { colors, fonts } = useTheme();
return (
<Animated.Text
role="heading"
aria-level="1"
numberOfLines={1}
{...rest}
style={[
{ color: tintColor === undefined ? colors.text : tintColor },
Platform.select({ ios: fonts.bold, default: fonts.medium }),
styles.title,
style,
]}
/>
);
}
const styles = StyleSheet.create({
title: Platform.select({
ios: {
fontSize: 17,
},
android: {
fontSize: 20,
},
default: {
fontSize: 18,
},
}),
});
@@ -0,0 +1,43 @@
import { PixelRatio, Platform } from 'react-native';
import type { Layout } from '../types';
export function getDefaultHeaderHeight(
layout: Layout,
modalPresentation: boolean,
topInset: number
): number {
let headerHeight;
// On models with Dynamic Island the status bar height is smaller than the safe area top inset.
const hasDynamicIsland = Platform.OS === 'ios' && topInset > 50;
const statusBarHeight = hasDynamicIsland
? topInset - (5 + 1 / PixelRatio.get())
: topInset;
const isLandscape = layout.width > layout.height;
if (Platform.OS === 'ios') {
if (Platform.isPad || Platform.isTV) {
if (modalPresentation) {
headerHeight = 56;
} else {
headerHeight = 50;
}
} else {
if (isLandscape) {
headerHeight = 32;
} else {
if (modalPresentation) {
headerHeight = 56;
} else {
headerHeight = 44;
}
}
}
} else {
headerHeight = 64;
}
return headerHeight + statusBarHeight;
}
@@ -0,0 +1,12 @@
import type { HeaderOptions } from '../types';
export function getHeaderTitle(
options: { title?: string; headerTitle?: HeaderOptions['headerTitle'] },
fallback: string
): string {
return typeof options.headerTitle === 'string'
? options.headerTitle
: options.title !== undefined
? options.title
: fallback;
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import { HeaderHeightContext } from './HeaderHeightContext';
export function useHeaderHeight() {
const height = React.useContext(HeaderHeightContext);
if (height === undefined) {
throw new Error(
"Couldn't find the header height. Are you inside a screen in a navigator with a header?"
);
}
return height;
}
@@ -0,0 +1,31 @@
import {
type StyleProp,
StyleSheet,
type TextProps,
type TextStyle,
} from 'react-native';
import { Text } from '../Text';
type Props = Omit<TextProps, 'style'> & {
tintColor?: string;
children?: string;
style?: StyleProp<TextStyle>;
};
export function Label({ tintColor, style, ...rest }: Props) {
return (
<Text
numberOfLines={1}
{...rest}
style={[styles.label, tintColor != null && { color: tintColor }, style]}
/>
);
}
const styles = StyleSheet.create({
label: {
textAlign: 'center',
backgroundColor: 'transparent',
},
});
@@ -0,0 +1,10 @@
export function getLabel(
options: { label?: string; title?: string },
fallback: string
): string {
return options.label !== undefined
? options.label
: options.title !== undefined
? options.title
: fallback;
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react';
type Props = {
/**
* Whether lazy rendering is enabled.
*/
enabled: boolean;
/**
* Whether the component is visible.
*/
visible: boolean;
/**
* Content to render.
*/
children: React.ReactElement;
};
/**
* Render content lazily based on visibility.
*
* When enabled:
* - If content is visible, it will render immediately
* - If content is not visible, it won't render until it becomes visible
*
* Otherwise:
* - If content is visible, it will render immediately
* - If content is not visible, it will defer rendering until idle
*
* Once rendered, the content remains rendered.
*/
export function Lazy({ enabled, visible, children }: Props) {
const [rendered, setRendered] = React.useState(enabled ? visible : false);
const shouldRenderInIdle = !(enabled || visible || rendered);
React.useEffect(() => {
if (shouldRenderInIdle === false) {
return;
}
const id = requestIdleCallback(() => {
setRendered(true);
});
return () => cancelIdleCallback(id);
}, [shouldRenderInIdle]);
if (visible && rendered === false) {
setRendered(true);
return children;
}
if (rendered) {
return children;
}
return null;
}
@@ -0,0 +1 @@
export { MaskedView } from './MaskedViewNative';
@@ -0,0 +1 @@
export { MaskedView } from './MaskedViewNative';
@@ -0,0 +1,13 @@
/**
* Use a stub for MaskedView on all Platforms that don't support it.
*/
import type * as React from 'react';
type Props = {
maskElement: React.ReactElement;
children: React.ReactElement;
};
export function MaskedView({ children }: Props) {
return children;
}
@@ -0,0 +1,33 @@
/**
* The native MaskedView that we explicitly re-export for supported platforms: Android, iOS.
*/
import * as React from 'react';
import { UIManager } from 'react-native';
type MaskedViewType =
typeof import('@react-native-masked-view/masked-view').default;
type Props = React.ComponentProps<MaskedViewType> & {
children: React.ReactElement;
};
let RNCMaskedView: MaskedViewType | undefined;
try {
// Add try/catch to support usage even if it's not installed, since it's optional.
// Newer versions of Metro will handle it properly.
RNCMaskedView = require('@react-native-masked-view/masked-view').default;
} catch (e) {
// Ignore
}
const isMaskedViewAvailable =
UIManager.getViewManagerConfig('RNCMaskedView') != null;
export function MaskedView({ children, ...rest }: Props) {
if (isMaskedViewAvailable && RNCMaskedView) {
return <RNCMaskedView {...rest}>{children}</RNCMaskedView>;
}
return children;
}
@@ -0,0 +1,19 @@
import { type StyleProp, StyleSheet, type TextStyle } from 'react-native';
import { Text } from './Text';
type Props = {
color?: string;
size?: number;
style?: StyleProp<TextStyle>;
};
export function MissingIcon({ color, size, style }: Props) {
return <Text style={[styles.icon, { color, fontSize: size }, style]}></Text>;
}
const styles = StyleSheet.create({
icon: {
backgroundColor: 'transparent',
},
});
@@ -0,0 +1,214 @@
import { useTheme } from '@react-navigation/native';
import * as React from 'react';
import {
Animated,
Easing,
type GestureResponderEvent,
Platform,
Pressable,
type PressableProps,
type StyleProp,
type ViewStyle,
} from 'react-native';
type HoverEffectProps = {
color?: string;
hoverOpacity?: number;
activeOpacity?: number;
};
export type Props = Omit<PressableProps, 'style' | 'onPress'> & {
href?: string;
pressColor?: string;
pressOpacity?: number;
hoverEffect?: HoverEffectProps;
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
onPress?: (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
) => void;
children: React.ReactNode;
};
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const ANDROID_VERSION_LOLLIPOP = 21;
const ANDROID_SUPPORTS_RIPPLE =
Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
const useNativeDriver = Platform.OS !== 'web';
/**
* PlatformPressable provides an abstraction on top of Pressable to handle platform differences.
*/
function PlatformPressableInternal(
{
disabled,
onPress,
onPressIn,
onPressOut,
android_ripple,
pressColor,
pressOpacity = 0.3,
hoverEffect,
style,
children,
...rest
}: Props,
ref: React.Ref<React.ComponentRef<typeof AnimatedPressable>>
) {
const { dark } = useTheme();
const [opacity] = React.useState(() => new Animated.Value(1));
const animateTo = (toValue: number, duration: number) => {
if (ANDROID_SUPPORTS_RIPPLE) {
return;
}
Animated.timing(opacity, {
toValue,
duration,
easing: Easing.inOut(Easing.quad),
useNativeDriver,
}).start();
};
const handlePress = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
) => {
if (Platform.OS === 'web' && rest.href !== null) {
// ignore clicks with modifier keys
const hasModifierKey =
('metaKey' in e && e.metaKey) ||
('altKey' in e && e.altKey) ||
('ctrlKey' in e && e.ctrlKey) ||
('shiftKey' in e && e.shiftKey);
// only handle left clicks
const isLeftClick =
'button' in e ? e.button == null || e.button === 0 : true;
// let browser handle "target=_blank" etc.
const isSelfTarget =
e.currentTarget && 'target' in e.currentTarget
? [undefined, null, '', 'self'].includes(e.currentTarget.target)
: true;
if (!hasModifierKey && isLeftClick && isSelfTarget) {
e.preventDefault();
// call `onPress` only when browser default is prevented
// this prevents app from handling the click when a link is being opened
onPress?.(e);
}
} else {
onPress?.(e);
}
};
const handlePressIn = (e: GestureResponderEvent) => {
animateTo(pressOpacity, 0);
onPressIn?.(e);
};
const handlePressOut = (e: GestureResponderEvent) => {
animateTo(1, 200);
onPressOut?.(e);
};
return (
<AnimatedPressable
ref={ref}
accessible
role={Platform.OS === 'web' && rest.href != null ? 'link' : 'button'}
onPress={disabled ? undefined : handlePress}
onPressIn={disabled ? undefined : handlePressIn}
onPressOut={disabled ? undefined : handlePressOut}
android_ripple={
ANDROID_SUPPORTS_RIPPLE && !disabled
? {
color:
pressColor !== undefined
? pressColor
: dark
? 'rgba(255, 255, 255, .32)'
: 'rgba(0, 0, 0, .32)',
...android_ripple,
}
: undefined
}
style={[
{
cursor:
(Platform.OS === 'web' || Platform.OS === 'ios') && !disabled
? // Pointer cursor on web
// Hover effect on iPad and visionOS
'pointer'
: 'auto',
opacity: !ANDROID_SUPPORTS_RIPPLE && !disabled ? opacity : 1,
},
style,
]}
{...rest}
>
{!disabled ? <HoverEffect {...hoverEffect} /> : null}
{children}
</AnimatedPressable>
);
}
export const PlatformPressable = React.forwardRef(PlatformPressableInternal);
PlatformPressable.displayName = 'PlatformPressable';
const css = String.raw;
const CLASS_NAME = `__react-navigation_elements_Pressable_hover`;
const CSS_TEXT = css`
.${CLASS_NAME} {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
background-color: var(--overlay-color);
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
a:hover > .${CLASS_NAME}, button:hover > .${CLASS_NAME} {
opacity: var(--overlay-hover-opacity);
}
a:active > .${CLASS_NAME}, button:active > .${CLASS_NAME} {
opacity: var(--overlay-active-opacity);
}
`;
const HoverEffect = ({
color,
hoverOpacity = 0.08,
activeOpacity = 0.16,
}: HoverEffectProps) => {
if (Platform.OS !== 'web' || color == null) {
return null;
}
return (
<>
<style href={CLASS_NAME} precedence="elements">
{CSS_TEXT}
</style>
<div
className={CLASS_NAME}
style={{
// @ts-expect-error: CSS variables are not typed
'--overlay-color': color,
'--overlay-hover-opacity': hoverOpacity,
'--overlay-active-opacity': activeOpacity,
}}
/>
</>
);
};
@@ -0,0 +1,76 @@
import * as React from 'react';
import {
Platform,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native';
type Props = {
visible: boolean;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
const FAR_FAR_AWAY = 30000; // this should be big enough to move the whole view out of its container
export function ResourceSavingView({
visible,
children,
style,
...rest
}: Props) {
if (Platform.OS === 'web') {
return (
<View
// @ts-expect-error: hidden exists on web, but not in React Native
hidden={!visible}
style={[
{ display: visible ? 'flex' : 'none' },
styles.container,
style,
]}
pointerEvents={visible ? 'auto' : 'none'}
{...rest}
>
{children}
</View>
);
}
return (
<View
style={[styles.container, style]}
// box-none doesn't seem to work properly on Android
pointerEvents={visible ? 'auto' : 'none'}
>
<View
collapsable={false}
removeClippedSubviews={
// On iOS & macOS, set removeClippedSubviews to true only when not focused
// This is an workaround for a bug where the clipped view never re-appears
Platform.OS === 'ios' || Platform.OS === 'macos' ? !visible : true
}
pointerEvents={visible ? 'auto' : 'none'}
style={visible ? styles.attached : styles.detached}
>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
overflow: 'hidden',
},
attached: {
flex: 1,
},
detached: {
flex: 1,
top: FAR_FAR_AWAY,
},
});
@@ -0,0 +1,80 @@
import * as React from 'react';
import {
Dimensions,
Platform,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native';
import {
initialWindowMetrics,
SafeAreaInsetsContext,
SafeAreaProvider,
} from 'react-native-safe-area-context';
import { FrameSizeProvider } from './useFrameSize';
type Props = {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
};
const { width = 0, height = 0 } = Dimensions.get('window');
// To support SSR on web, we need to have empty insets for initial values
// Otherwise there can be mismatch between SSR and client output
// We also need to specify empty values to support tests environments
const initialMetrics =
Platform.OS === 'web' || initialWindowMetrics == null
? {
frame: { x: 0, y: 0, width, height },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
}
: initialWindowMetrics;
export function SafeAreaProviderCompat({ children, style }: Props) {
const insets = React.useContext(SafeAreaInsetsContext);
return (
<FrameSizeProvider
initialFrame={initialMetrics.frame}
render={({ ref, onLayout }) => {
if (insets) {
// If we already have insets, don't wrap the stack in another safe area provider
// This avoids an issue with updates at the cost of potentially incorrect values
// https://github.com/react-navigation/react-navigation/issues/174
return (
<View
ref={ref}
onLayout={onLayout}
style={[styles.container, style]}
>
{children}
</View>
);
}
// SafeAreaProvider doesn't forward ref
// So we only pass onLayout to it
return (
<SafeAreaProvider
initialMetrics={initialMetrics}
style={style}
onLayout={onLayout}
>
{children}
</SafeAreaProvider>
);
}}
/>
);
}
SafeAreaProviderCompat.initialMetrics = initialMetrics;
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
+130
View File
@@ -0,0 +1,130 @@
import {
NavigationContext,
type NavigationProp,
NavigationRouteContext,
type ParamListBase,
type RouteProp,
} from '@react-navigation/native';
import * as React from 'react';
import {
Animated,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Background } from './Background';
import { getDefaultHeaderHeight } from './Header/getDefaultHeaderHeight';
import { HeaderHeightContext } from './Header/HeaderHeightContext';
import { HeaderShownContext } from './Header/HeaderShownContext';
import { useFrameSize } from './useFrameSize';
type Props = {
focused: boolean;
modal?: boolean;
navigation: NavigationProp<ParamListBase>;
route: RouteProp<ParamListBase>;
header: React.ReactNode;
headerShown?: boolean;
headerStatusBarHeight?: number;
headerTransparent?: boolean;
style?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
children: React.ReactNode;
};
export function Screen(props: Props) {
const insets = useSafeAreaInsets();
const isParentHeaderShown = React.useContext(HeaderShownContext);
const parentHeaderHeight = React.useContext(HeaderHeightContext);
const {
focused,
modal = false,
header,
headerShown = true,
headerTransparent,
headerStatusBarHeight = isParentHeaderShown ? 0 : insets.top,
navigation,
route,
children,
style,
} = props;
const defaultHeaderHeight = useFrameSize((size) =>
getDefaultHeaderHeight(size, modal, headerStatusBarHeight)
);
const headerRef = React.useRef<View>(null);
const [headerHeight, setHeaderHeight] = React.useState(defaultHeaderHeight);
React.useLayoutEffect(() => {
headerRef.current?.measure((_x, _y, _width, height) => {
setHeaderHeight(height);
});
}, [route.name]);
return (
<Background
aria-hidden={!focused}
style={[styles.container, style]}
// On Fabric we need to disable collapsing for the background to ensure
// that we won't render unnecessary views due to the view flattening.
collapsable={false}
>
{headerShown ? (
<NavigationContext.Provider value={navigation}>
<NavigationRouteContext.Provider value={route}>
<View
ref={headerRef}
pointerEvents="box-none"
onLayout={(e) => {
const { height } = e.nativeEvent.layout;
setHeaderHeight(height);
}}
style={[
styles.header,
headerTransparent ? styles.absolute : null,
]}
>
{header}
</View>
</NavigationRouteContext.Provider>
</NavigationContext.Provider>
) : null}
<View style={styles.content}>
<HeaderShownContext.Provider
value={isParentHeaderShown || headerShown !== false}
>
<HeaderHeightContext.Provider
value={headerShown ? headerHeight : (parentHeaderHeight ?? 0)}
>
{children}
</HeaderHeightContext.Provider>
</HeaderShownContext.Provider>
</View>
</Background>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
},
header: {
zIndex: 1,
},
absolute: {
position: 'absolute',
top: 0,
start: 0,
end: 0,
},
});
+14
View File
@@ -0,0 +1,14 @@
import { useTheme } from '@react-navigation/native';
// eslint-disable-next-line no-restricted-imports
import { Text as NativeText, type TextProps } from 'react-native';
export function Text({ style, ...rest }: TextProps) {
const { colors, fonts } = useTheme();
return (
<NativeText
{...rest}
style={[{ color: colors.text }, fonts.regular, style]}
/>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1,15 @@
const APPROX_APP_BAR_HEIGHT = 56;
const DEFAULT_DRAWER_WIDTH = 360;
export const getDefaultSidebarWidth = ({ width }: { width: number }) => {
/**
* Default sidebar width is 360dp
* On screens smaller than 320dp, ideally the drawer would collapse to a tab bar
* https://m3.material.io/components/navigation-drawer/specs
*/
if (width - APPROX_APP_BAR_HEIGHT <= 360) {
return width - APPROX_APP_BAR_HEIGHT;
}
return DEFAULT_DRAWER_WIDTH;
};
@@ -0,0 +1,29 @@
import * as React from 'react';
const contexts = '__react_navigation__elements_contexts';
declare global {
var __react_navigation__elements_contexts: Map<string, React.Context<any>>;
}
// We use a global variable to keep our contexts so that we can reuse same contexts across packages
globalThis[contexts] =
globalThis[contexts] ?? new Map<string, React.Context<any>>();
export function getNamedContext<T>(
name: string,
initialValue: T
): React.Context<T> {
let context = globalThis[contexts].get(name);
if (context) {
return context;
}
context = React.createContext<T>(initialValue);
context.displayName = name;
globalThis[contexts].set(name, context);
return context;
}
+41
View File
@@ -0,0 +1,41 @@
import backIcon from './assets/back-icon.png';
import backIconMask from './assets/back-icon-mask.png';
import clearIcon from './assets/clear-icon.png';
import closeIcon from './assets/close-icon.png';
import searchIcon from './assets/search-icon.png';
export { Background } from './Background';
export { Badge } from './Badge';
export { Button } from './Button';
export { getDefaultSidebarWidth } from './getDefaultSidebarWidth';
export { getDefaultHeaderHeight } from './Header/getDefaultHeaderHeight';
export { getHeaderTitle } from './Header/getHeaderTitle';
export { Header } from './Header/Header';
export { HeaderBackButton } from './Header/HeaderBackButton';
export { HeaderBackContext } from './Header/HeaderBackContext';
export { HeaderBackground } from './Header/HeaderBackground';
export { HeaderButton } from './Header/HeaderButton';
export { HeaderHeightContext } from './Header/HeaderHeightContext';
export { HeaderShownContext } from './Header/HeaderShownContext';
export { HeaderTitle } from './Header/HeaderTitle';
export { useHeaderHeight } from './Header/useHeaderHeight';
export { getLabel } from './Label/getLabel';
export { Label } from './Label/Label';
export { Lazy } from './Lazy';
export { MissingIcon } from './MissingIcon';
export { PlatformPressable } from './PlatformPressable';
export { ResourceSavingView } from './ResourceSavingView';
export { SafeAreaProviderCompat } from './SafeAreaProviderCompat';
export { Screen } from './Screen';
export { Text } from './Text';
export { useFrameSize } from './useFrameSize';
export const Assets = [
backIcon,
backIconMask,
searchIcon,
closeIcon,
clearIcon,
];
export * from './types';
+343
View File
@@ -0,0 +1,343 @@
import type {
Animated,
LayoutChangeEvent,
StyleProp,
TextInputProps,
TextStyle,
ViewStyle,
} from 'react-native';
export type HeaderBackButtonDisplayMode = 'default' | 'generic' | 'minimal';
export type Layout = { width: number; height: number };
export type HeaderSearchBarRef = {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
clearText: () => void;
cancelSearch: () => void;
};
export type HeaderSearchBarOptions = {
/**
* Ref to imperatively update the search bar.
*
* Supported operations:
* - `focus` - focuses the search bar
* - `blur` - removes focus from the search bar
* - `setText` - sets the search bar's content to given value
* - `clearText` - removes any text present in the search bar input field
* - `cancelSearch` - cancel the search and close the search bar
*/
ref?: React.Ref<HeaderSearchBarRef>;
/**
* The auto-capitalization behavior
*/
autoCapitalize?:
| 'none'
| 'words'
| 'sentences'
| 'characters'
| 'systemDefault';
/**
* Automatically focuses search input on mount
*/
autoFocus?: boolean;
/**
* The text to be used instead of default `Cancel` button text
*
* @platform ios
*/
cancelButtonText?: string;
/**
* Sets type of the input. Defaults to `text`.
*/
inputType?: 'text' | 'phone' | 'number' | 'email';
/**
* Determines how the return key should look. Defaults to `search`.
*/
enterKeyHint?: TextInputProps['enterKeyHint'];
/**
* A callback that gets called when search input has lost focus
*/
onBlur?: TextInputProps['onBlur'];
/**
* A callback that gets called when the text changes.
* It receives the current text value of the search input.
*/
onChangeText?: TextInputProps['onChange'];
/**
* Callback that is called when the submit button is pressed.
* It receives the current text value of the search input.
*/
onSubmitEditing?: TextInputProps['onSubmitEditing'];
/**
* A callback that gets called when search input is opened
*/
onOpen?: () => void;
/**
* A callback that gets called when search input is closed
*/
onClose?: () => void;
/**
* A callback that gets called when search input has received focus
*/
onFocus?: TextInputProps['onFocus'];
/**
* Text displayed when search field is empty
*/
placeholder?: string;
};
export type HeaderOptions = {
/**
* String or a function that returns a React Element to be used by the header.
* Defaults to screen `title` or route name.
*
* It receives `allowFontScaling`, `tintColor`, `style` and `children` in the options object as an argument.
* The title string is passed in `children`.
*/
headerTitle?: string | ((props: HeaderTitleProps) => React.ReactNode);
/**
* How to align the the header title.
* Defaults to `center` on iOS and `left` on Android.
*/
headerTitleAlign?: 'left' | 'center';
/**
* Style object for the title component.
*/
headerTitleStyle?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
/**
* Style object for the container of the `headerTitle` element.
*/
headerTitleContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* Whether header title font should scale to respect Text Size accessibility settings. Defaults to `false`.
*/
headerTitleAllowFontScaling?: boolean;
/**
* Options to render a search bar.
*/
headerSearchBarOptions?: HeaderSearchBarOptions;
/**
* Function which returns a React Element to display on the left side of the header.
*/
headerLeft?: (
props: HeaderBackButtonProps & {
/**
* Whether it's possible to navigate back.
*/
canGoBack?: boolean;
}
) => React.ReactNode;
/**
* How the back button displays icon and title.
*
* Supported values:
* - "default" - Displays one of the following depending on the available space: previous screen's title, truncated title (e.g. 'Back') or no title (only icon).
* - "generic" Displays one of the following depending on the available space: truncated title (e.g. 'Back') or no title (only icon).
* - "minimal" Always displays only the icon without a title.
*
* Defaults to "default" on iOS, and "minimal" on other platforms.
*/
headerBackButtonDisplayMode?: HeaderBackButtonDisplayMode;
/**
* Style object for header back title. Supported properties:
* - fontFamily
* - fontSize
*/
headerBackTitleStyle?: StyleProp<{
fontFamily?: string;
fontSize?: number;
}>;
/**
* Style object for the container of the `headerLeft` element`.
*/
headerLeftContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* Function which returns a React Element to display on the right side of the header.
*/
headerRight?: (props: {
tintColor?: string;
pressColor?: string;
pressOpacity?: number;
canGoBack: boolean;
}) => React.ReactNode;
/**
* Style object for the container of the `headerRight` element.
*/
headerRightContainerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* Color for material ripple (Android >= 5.0 only).
*/
headerPressColor?: string;
/**
* Color for material ripple (Android >= 5.0 only).
*/
headerPressOpacity?: number;
/**
* Tint color for the header.
*/
headerTintColor?: string;
/**
* Function which returns a React Element to render as the background of the header.
* This is useful for using backgrounds such as an image, a gradient, blur effect etc.
* You can use this with `headerTransparent` to render a blur view, for example, to create a translucent header.
*/
headerBackground?: (props: {
style: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
}) => React.ReactNode;
/**
* Style object for the container of the `headerBackground` element.
*/
headerBackgroundContainerStyle?: Animated.WithAnimatedValue<
StyleProp<ViewStyle>
>;
/**
* Defaults to `false`. If `true`, the header will not have a background unless you explicitly provide it with `headerBackground`.
* The header will also float over the screen so that it overlaps the content underneath.
* This is useful if you want to render a semi-transparent header or a blurred background.
*/
headerTransparent?: boolean;
/**
* Style object for the header. You can specify a custom background color here, for example.
*/
headerStyle?: Animated.WithAnimatedValue<StyleProp<ViewStyle>>;
/**
* Whether to hide the elevation shadow (Android) or the bottom border (iOS) on the header.
*
* This is a short-hand for the following styles:
*
* ```js
* {
* elevation: 0,
* shadowOpacity: 0,
* borderBottomWidth: 0,
* }
* ```
*
* If the above styles are specified in `headerStyle` along with `headerShadowVisible: false`,
* then `headerShadowVisible: false` will take precedence.
*/
headerShadowVisible?: boolean;
/**
* Extra padding to add at the top of header to account for translucent status bar.
* By default, it uses the top value from the safe area insets of the device.
* Pass 0 or a custom value to disable the default behaviour, and customize the height.
*/
headerStatusBarHeight?: number;
};
export type HeaderTitleProps = {
/**
* The title text of the header.
*/
children: string;
/**
* Whether title font should scale to respect Text Size accessibility settings.
*/
allowFontScaling?: boolean;
/**
* Tint color for the header.
*/
tintColor?: string;
/**
* Callback to trigger when the size of the title element changes.
*/
onLayout?: (e: LayoutChangeEvent) => void;
/**
* Style object for the title element.
*/
style?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
};
export type HeaderButtonProps = {
/**
* Callback to call when the button is pressed.
*/
onPress?: () => void;
/**
* The `href` to use for the anchor tag on web
*/
href?: string;
/**
* Whether the button is disabled.
*/
disabled?: boolean;
/**
* Accessibility label for the button for screen readers.
*/
accessibilityLabel?: string;
/**
* ID to locate this button in tests.
*/
testID?: string;
/**
* Tint color for the header button.
*/
tintColor?: string;
/**
* Color for material ripple (Android >= 5.0 only).
*/
pressColor?: string;
/**
* Opacity when the button is pressed, used when ripple is not supported.
*/
pressOpacity?: number;
/**
* Style object for the button.
*/
style?: StyleProp<ViewStyle>;
/**
* Content to render for the button. Usually the icon.
*/
children: React.ReactNode;
};
export type HeaderBackButtonProps = Omit<HeaderButtonProps, 'children'> & {
/**
* Function which returns a React Element to display custom image in header's back button.
*/
backImage?: (props: { tintColor: string }) => React.ReactNode;
/**
* Label text for the button. Usually the title of the previous screen.
* By default, this is only shown on iOS.
*/
label?: string;
/**
* Label text to show when there isn't enough space for the full label.
*/
truncatedLabel?: string;
/**
* How the back button displays icon and title.
*
* Supported values:
* - "default" - Displays one of the following depending on the available space: previous screen's title, truncated title (e.g. 'Back') or no title (only icon).
* - "generic" Displays one of the following depending on the available space: truncated title (e.g. 'Back') or no title (only icon).
* - "minimal" Always displays only the icon without a title.
*
* Defaults to "default" on iOS, and "minimal" on other platforms.
*/
displayMode?: HeaderBackButtonDisplayMode;
/**
* Style object for the label.
*/
labelStyle?: Animated.WithAnimatedValue<StyleProp<TextStyle>>;
/**
* Whether label font should scale to respect Text Size accessibility settings.
*/
allowFontScaling?: boolean;
/**
* Callback to trigger when the size of the label changes.
*/
onLabelLayout?: (e: LayoutChangeEvent) => void;
/**
* Layout of the screen.
*/
screenLayout?: Layout;
/**
* Layout of the title element in the header.
*/
titleLayout?: Layout;
};
@@ -0,0 +1,218 @@
import * as React from 'react';
import { type LayoutChangeEvent, Platform, View } from 'react-native';
import useLatestCallback from 'use-latest-callback';
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector';
type Frame = {
width: number;
height: number;
};
type Listener = () => void;
type RemoveListener = () => void;
type FrameContextType = {
getCurrent: () => Frame;
subscribe: (listener: Listener) => RemoveListener;
subscribeThrottled: (listener: Listener) => RemoveListener;
};
const FrameContext = React.createContext<FrameContextType | undefined>(
undefined
);
export function useFrameSize<T>(
selector: (frame: Frame) => T,
throttle?: boolean
): T {
const context = React.useContext(FrameContext);
if (context == null) {
throw new Error('useFrameSize must be used within a FrameSizeProvider');
}
const value = useSyncExternalStoreWithSelector(
throttle ? context.subscribeThrottled : context.subscribe,
context.getCurrent,
context.getCurrent,
selector
);
return value;
}
type FrameSizeProviderProps = {
initialFrame: Frame;
render: (props: {
ref: React.RefObject<View | null>;
onLayout: (event: LayoutChangeEvent) => void;
}) => React.ReactNode;
};
export function FrameSizeProvider({
initialFrame,
render,
}: FrameSizeProviderProps) {
const frameRef = React.useRef<Frame>({
width: initialFrame.width,
height: initialFrame.height,
});
const listeners = React.useRef<Set<Listener>>(new Set());
const getCurrent = useLatestCallback(() => frameRef.current);
const subscribe = useLatestCallback((listener: Listener): RemoveListener => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
});
const subscribeThrottled = useLatestCallback(
(listener: Listener): RemoveListener => {
const delay = 100; // Throttle delay in milliseconds
let timer: ReturnType<typeof setTimeout>;
let updated = false;
let waiting = false;
const throttledListener = () => {
clearTimeout(timer);
updated = true;
if (waiting) {
// Schedule a timer to call the listener at the end
timer = setTimeout(() => {
if (updated) {
updated = false;
listener();
}
}, delay);
} else {
waiting = true;
setTimeout(function () {
waiting = false;
}, delay);
// Call the listener immediately at start
updated = false;
listener();
}
};
const unsubscribe = subscribe(throttledListener);
return () => {
unsubscribe();
clearTimeout(timer);
};
}
);
const context = React.useMemo<FrameContextType>(
() => ({
getCurrent,
subscribe,
subscribeThrottled,
}),
[subscribe, subscribeThrottled, getCurrent]
);
const onChange = useLatestCallback((frame: Frame) => {
if (
frameRef.current.height === frame.height &&
frameRef.current.width === frame.width
) {
return;
}
frameRef.current = { width: frame.width, height: frame.height };
listeners.current.forEach((listener) => listener());
});
const viewRef = React.useRef<View>(null);
React.useEffect(() => {
if (Platform.OS === 'web') {
// We use ResizeObserver on web
return;
}
viewRef.current?.measure((_x, _y, width, height) => {
onChange({ width, height });
});
}, [onChange]);
const onLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
onChange({ width, height });
};
return (
<FrameContext.Provider value={context}>
{Platform.OS === 'web' ? (
<FrameSizeListenerWeb onChange={onChange} />
) : null}
{render({ ref: viewRef, onLayout })}
</FrameContext.Provider>
);
}
// FIXME: On the Web, `onLayout` doesn't fire on resize
// So we workaround this by using ResizeObserver
function FrameSizeListenerWeb({
onChange,
}: {
onChange: (frame: Frame) => void;
}) {
const elementRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (elementRef.current == null) {
return;
}
const rect = elementRef.current.getBoundingClientRect();
onChange({
width: rect.width,
height: rect.height,
});
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const { width, height } = entry.contentRect;
onChange({ width, height });
}
});
observer.observe(elementRef.current);
return () => {
observer.disconnect();
};
}, [onChange]);
return (
<div
ref={elementRef}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
pointerEvents: 'none',
visibility: 'hidden',
}}
/>
);
}