@oxyhq/services
Version:
428 lines (421 loc) • 12.5 kB
JavaScript
"use strict";
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { useMemo } from 'react';
import AnimatedReanimated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import OxyIcon from "./icon/OxyIcon.js";
import { fontFamilies } from "../styles/fonts.js";
import { useColorScheme } from "../hooks/useColorScheme.js";
import { normalizeColorScheme } from "../utils/themeUtils.js";
import { Colors } from "../constants/theme.js";
// Calculate header height based on platform and variant
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export const getHeaderHeight = (variant = 'default', safeAreaTop = 0) => {
const paddingTop = Platform.OS === 'ios' ? Math.max(safeAreaTop, 50) : 16;
const paddingBottom = 12;
const contentHeight = variant === 'minimal' ? 36 : 40;
return paddingTop + contentHeight + paddingBottom;
};
const Header = ({
title,
subtitle,
onBack,
onClose,
rightAction,
rightActions,
theme,
showBackButton = true,
showCloseButton = false,
showThemeToggle = false,
onThemeToggle,
variant = 'default',
elevation = 'subtle',
subtitleVariant = 'default',
titleAlignment = 'left',
scrollY
}) => {
// Use theme colors directly from Colors constant (like Accounts sidebar)
// Ensure colorScheme is always 'light' or 'dark' with proper fallback chain
const colorScheme = normalizeColorScheme(useColorScheme(), theme);
const colors = Colors[colorScheme];
const insets = useSafeAreaInsets();
const headerHeight = getHeaderHeight(variant, insets.top);
// Animated style for sticky behavior on native
// Only create animated style if scrollY is provided and we're on native platform
const animatedHeaderStyle = useAnimatedStyle(() => {
if (Platform.OS === 'web' || !scrollY) {
return {};
}
// Sticky behavior: header scrolls with content initially, then sticks at top
// When scrollY = 0, translateY = 0 (header at normal position)
// When scrollY > 0, translateY becomes negative to keep header at top
// Clamp to prevent header from going above viewport
const translateY = interpolate(scrollY.value, [0, headerHeight], [0, -headerHeight], Extrapolation.CLAMP);
return {
transform: [{
translateY
}]
};
}, [scrollY, headerHeight]);
const handleBackPress = () => {
if (!onBack) return;
// Navigate immediately and synchronously - this prioritizes navigation
// over keyboard dismiss. The keyboard will close naturally after screen changes.
onBack();
};
const renderBackButton = () => {
if (!showBackButton || !onBack) return null;
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.backButton, {
backgroundColor: colors.card
}],
onPress: handleBackPress,
activeOpacity: 0.7,
children: /*#__PURE__*/_jsx(OxyIcon, {
name: "chevron-back",
size: 18,
color: colors.tint
})
});
};
const renderCloseButton = () => {
if (!showCloseButton || !onClose) return null;
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.closeButton, {
backgroundColor: colors.card
}],
onPress: onClose,
activeOpacity: 0.7,
children: /*#__PURE__*/_jsx(Ionicons, {
name: "close",
size: 18,
color: colors.text
})
});
};
const renderRightActionButton = (action, idx) => {
const isTextAction = action.text;
return /*#__PURE__*/_jsx(TouchableOpacity, {
style: [styles.rightActionButton, isTextAction ? styles.textActionButton : styles.iconActionButton, {
backgroundColor: isTextAction ? colors.tint : colors.card,
opacity: action.disabled ? 0.5 : 1
}],
onPress: action.onPress,
disabled: action.disabled || action.loading,
activeOpacity: 0.7,
children: action.loading ? /*#__PURE__*/_jsxs(View, {
style: styles.loadingContainer,
children: [/*#__PURE__*/_jsx(View, {
style: [styles.loadingDot, {
backgroundColor: isTextAction ? '#FFFFFF' : colors.tint
}]
}), /*#__PURE__*/_jsx(View, {
style: [styles.loadingDot, {
backgroundColor: isTextAction ? '#FFFFFF' : colors.tint
}]
}), /*#__PURE__*/_jsx(View, {
style: [styles.loadingDot, {
backgroundColor: isTextAction ? '#FFFFFF' : colors.tint
}]
})]
}) : isTextAction ? /*#__PURE__*/_jsx(Text, {
style: [styles.actionText, {
color: '#FFFFFF'
}],
children: action.text
}) : /*#__PURE__*/_jsx(Ionicons, {
name: action.icon,
size: 18,
color: colors.tint
})
}, action.key || idx);
};
const renderRightActions = () => {
const actions = [];
// Add existing right actions
if (rightActions?.length) {
actions.push(...rightActions);
} else if (rightAction) {
actions.push(rightAction);
}
// Add theme toggle button if enabled
if (showThemeToggle && onThemeToggle) {
actions.push({
icon: colorScheme === 'dark' ? 'sunny' : 'moon',
onPress: onThemeToggle,
key: 'theme-toggle'
});
}
if (actions.length === 0) return null;
if (actions.length > 1) {
return /*#__PURE__*/_jsx(View, {
style: styles.rightActionsRow,
children: actions.map((a, i) => renderRightActionButton(a, i))
});
}
return renderRightActionButton(actions[0], 0);
};
const renderTitle = () => {
const titleStyle = variant === 'large' ? styles.titleLarge : variant === 'minimal' ? styles.titleMinimal : styles.titleDefault;
const subtitleStyle = variant === 'large' ? styles.subtitleLarge : variant === 'minimal' ? styles.subtitleMinimal : subtitleVariant === 'small' ? styles.subtitleSmall : subtitleVariant === 'large' ? styles.subtitleLarge : subtitleVariant === 'muted' ? styles.subtitleMuted : styles.subtitleDefault;
const getTitleAlignment = () => {
switch (titleAlignment) {
case 'center':
return styles.titleContainerCenter;
case 'right':
return styles.titleContainerRight;
default:
return styles.titleContainerLeft;
}
};
return /*#__PURE__*/_jsxs(View, {
style: [styles.titleContainer, getTitleAlignment(), variant === 'minimal' && styles.titleContainerMinimal],
children: [/*#__PURE__*/_jsx(Text, {
style: [titleStyle, {
color: colors.text
}],
children: title
}), subtitle && /*#__PURE__*/_jsx(Text, {
style: [subtitleStyle, {
color: colors.secondaryText
}],
children: subtitle
})]
});
};
const getElevationStyle = () => {
const isDark = colorScheme === 'dark';
switch (elevation) {
case 'none':
return {};
case 'subtle':
return Platform.select({
web: {
boxShadow: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.1)'
},
default: {
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 1
},
shadowOpacity: isDark ? 0.3 : 0.1,
shadowRadius: 3,
elevation: 2
}
});
case 'prominent':
return Platform.select({
web: {
boxShadow: isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.15)'
},
default: {
shadowColor: '#000000',
shadowOffset: {
width: 0,
height: 4
},
shadowOpacity: isDark ? 0.4 : 0.15,
shadowRadius: 12,
elevation: 8
}
});
default:
return {};
}
};
const getBackgroundStyle = () => {
if (variant === 'gradient') {
return {
backgroundColor: colors.background,
// Add gradient overlay effect
borderBottomWidth: 1,
borderBottomColor: colors.border
};
}
return {
backgroundColor: colors.background,
borderBottomWidth: elevation === 'none' ? 0 : 1,
borderBottomColor: colors.border
};
};
const backgroundStyle = getBackgroundStyle();
const elevationStyle = getElevationStyle();
const containerStyle = useMemo(() => [styles.container, {
paddingTop: Platform.OS === 'ios' ? Math.max(insets.top, 50) : 16
},
// When header is inside ScrollView (has scrollY), don't use absolute positioning
!scrollY && Platform.OS !== 'web' ? {
position: 'absolute',
top: 0,
left: 0,
right: 0
} : {}, backgroundStyle, elevationStyle], [insets.top, backgroundStyle, elevationStyle, scrollY]);
const HeaderContainer = Platform.OS === 'web' || !scrollY ? View : AnimatedReanimated.View;
// Only apply animated styles when HeaderContainer is an animated component
const shouldUseAnimatedStyle = Platform.OS !== 'web' && scrollY !== undefined;
const headerStyle = shouldUseAnimatedStyle ? [containerStyle, animatedHeaderStyle] : containerStyle;
return /*#__PURE__*/_jsx(HeaderContainer, {
style: headerStyle,
children: /*#__PURE__*/_jsxs(View, {
style: [styles.content, variant === 'minimal' && styles.contentMinimal],
children: [renderBackButton(), renderTitle(), renderRightActions(), renderCloseButton()]
})
});
};
const styles = StyleSheet.create({
container: {
paddingBottom: 12,
zIndex: 1000,
...Platform.select({
web: {
position: 'sticky',
top: 0,
left: 0,
right: 0
},
default: {
// Position will be set dynamically based on scrollY prop
}
})
},
content: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
position: 'relative',
minHeight: 40
},
contentMinimal: {
paddingHorizontal: 12,
minHeight: 36
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10
},
closeButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10
},
titleContainer: {
flex: 1,
alignItems: 'flex-start',
justifyContent: 'center'
},
titleContainerLeft: {
alignItems: 'flex-start'
},
titleContainerCenter: {
alignItems: 'center'
},
titleContainerRight: {
alignItems: 'flex-end'
},
titleContainerMinimal: {
alignItems: 'center',
marginHorizontal: 16
},
titleDefault: {
fontSize: 18,
fontWeight: '700',
fontFamily: fontFamilies.interBold,
letterSpacing: -0.5,
lineHeight: 22
},
titleLarge: {
fontSize: 28,
fontWeight: '800',
fontFamily: fontFamilies.interExtraBold,
letterSpacing: -1,
lineHeight: 34,
marginBottom: 3
},
titleMinimal: {
fontSize: 16,
fontWeight: '600',
fontFamily: fontFamilies.interSemiBold,
letterSpacing: -0.3,
lineHeight: 20
},
subtitleDefault: {
fontSize: 14,
fontWeight: '400',
lineHeight: 17,
marginTop: 1
},
subtitleLarge: {
fontSize: 16,
fontWeight: '400',
lineHeight: 19,
marginTop: 3
},
subtitleMinimal: {
fontSize: 13,
fontWeight: '400',
lineHeight: 15,
marginTop: 1
},
subtitleSmall: {
fontSize: 12,
fontWeight: '400',
lineHeight: 14,
marginTop: 0
},
subtitleMuted: {
fontSize: 14,
fontWeight: '400',
lineHeight: 17,
marginTop: 1,
opacity: 0.7
},
rightActionButton: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10
},
iconActionButton: {
width: 32,
height: 32,
borderRadius: 16
},
textActionButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 18,
minWidth: 56
},
actionText: {
fontSize: 14,
fontWeight: '600',
fontFamily: fontFamilies.interSemiBold,
letterSpacing: -0.2
},
loadingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 2
},
loadingDot: {
width: 4,
height: 4,
borderRadius: 2,
opacity: 0.6
},
rightActionsRow: {
flexDirection: 'row',
alignItems: 'center'
}
});
export default Header;
//# sourceMappingURL=Header.js.map