Revamp the bottom tab bar
CI / checks (push) Successful in 2m6s

This commit is contained in:
ponas committed 2026-09-07 01:53:49 +02:00
1 parent b7657d9138
commit cf77dd7bf1
15 files changed
+178 -91

No files matched your search

+7
View File
@@ -55,6 +55,13 @@
{}
],
"expo-status-bar",
[
"expo-navigation-bar",
{
"enforceContrast": false,
"style": "dark"
}
],
[
"expo-splash-screen",
{
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- The calendar icon from neuf-www (web/src/components/general/Icon.tsx) -->
<svg viewBox="0 -960 960 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M204-90q-30.94 0-52.97-22.03Q129-134.06 129-165v-552q0-30.94 22.03-52.97Q173.06-792 204-792h45v-80h75v80h312v-80h75v80h45q30.94 0 52.97 22.03Q831-747.94 831-717v552q0 30.94-22.03 52.97Q786.94-90 756-90H204Zm0-75h552v-393H204v393Zm0-468h552v-84H204v84Zm0 0v-84 84Zm276.05 232q-16.55 0-27.8-11.2Q441-423.39 441-439.95q0-16.55 11.2-27.8Q463.39-479 479.95-479q16.55 0 27.8 11.2Q519-456.61 519-440.05q0 16.55-11.2 27.8Q496.61-401 480.05-401Zm-160 0q-16.55 0-27.8-11.2Q281-423.39 281-439.95q0-16.55 11.2-27.8Q303.39-479 319.95-479q16.55 0 27.8 11.2Q359-456.61 359-440.05q0 16.55-11.2 27.8Q336.61-401 320.05-401Zm320 0q-16.55 0-27.8-11.2Q601-423.39 601-439.95q0-16.55 11.2-27.8Q623.39-479 639.95-479q16.55 0 27.8 11.2Q679-456.61 679-440.05q0 16.55-11.2 27.8Q656.61-401 640.05-401Zm-160 160q-16.55 0-27.8-11.2Q441-263.39 441-279.95q0-16.55 11.2-27.8Q463.39-319 479.95-319q16.55 0 27.8 11.2Q519-296.61 519-280.05q0 16.55-11.2 27.8Q496.61-241 480.05-241Zm-160 0q-16.55 0-27.8-11.2Q281-263.39 281-279.95q0-16.55 11.2-27.8Q303.39-319 319.95-319q16.55 0 27.8 11.2Q359-296.61 359-280.05q0 16.55-11.2 27.8Q336.61-241 320.05-241Zm320 0q-16.55 0-27.8-11.2Q601-263.39 601-279.95q0-16.55 11.2-27.8Q623.39-319 639.95-319q16.55 0 27.8 11.2Q679-296.61 679-280.05q0 16.55-11.2 27.8Q656.61-241 640.05-241Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

-2
View File
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M676 862c-16 0-28-13-28-29V691c0-16 12-28 28-28h142c16 0 29 12 29 28v142c0 16-13 29-29 29H676zm142-171H676v142h142V691zM960 96c35 0 64 29 64 64v800c0 35-29 64-64 64H64c-35 0-64-29-64-64V160c0-35 29-64 64-64h256V32c0-18 14-32 32-32s32 14 32 32v64h256V32c0-18 14-32 32-32s32 14 32 32v64h256zM64 960h896V160H704v32c0 18-14 32-32 32s-32-14-32-32v-32H384v32c0 18-14 32-32 32s-32-14-32-32v-32H64v800z"/></svg>

Before

Width:  |  Height:  |  Size: 640 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Material Symbols Outlined "id_card" at weight 600, the set and weight neuf-www uses (Apache 2.0) -->
<svg viewBox="0 -960 960 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M585.09-432.48h169.82v-66.78H585.09v66.78Zm0-126.78h169.82v-66.78H585.09v66.78Zm-380 234.74h320v-10.52q0-41.44-42.5-68.22t-117.5-26.78q-75 0-117.5 26.78t-42.5 68.22v10.52Zm159.92-165.52q30.08 0 51.58-21.14t21.5-51.78q0-30.08-21.42-51.58t-51.5-21.5q-30.08 0-51.58 21.42t-21.5 51.5q0 30.64 21.42 51.86t51.5 21.22ZM145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-509.82H145.09v509.82Zm0 0v-509.82 509.82Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 855 B

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Material Symbols Outlined "info" at weight 600, the set and weight neuf-www uses (Apache 2.0) -->
<svg viewBox="0 -960 960 960" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M446.78-273.78h72.44V-520h-72.44v246.22Zm33.25-311.74q17.64 0 29.55-11.64 11.9-11.64 11.9-28.84 0-18.69-11.92-30.58-11.92-11.9-29.54-11.9-18.26 0-29.88 11.9-11.62 11.89-11.62 30.3 0 17.55 11.94 29.16 11.93 11.6 29.57 11.6Zm.31 519.65q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Zm.16-79.22q139.24 0 236.83-97.73 97.58-97.73 97.58-237.68 0-139.24-97.4-236.83-97.4-97.58-237.79-97.58-139.02 0-236.83 97.4-97.8 97.4-97.8 237.79 0 139.02 97.73 236.83 97.73 97.8 237.68 97.8ZM480-480Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<title>information-circle</title>
<g id="Layer_2" data-name="Layer 2">
<g id="invisible_box" data-name="invisible box">
<rect width="48" height="48" fill="none"/>
</g>
<g id="icons_Q2" data-name="icons Q2">
<path d="M24,2A22,22,0,1,0,46,24,21.9,21.9,0,0,0,24,2Zm0,40A18,18,0,1,1,42,24,18.1,18.1,0,0,1,24,42Z"/>
<path d="M24,20a2,2,0,0,0-2,2V34a2,2,0,0,0,4,0V22A2,2,0,0,0,24,20Z"/>
<circle cx="24" cy="14" r="2"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 687 B

-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g data-name="Layer 2">
<g data-name="person">
<rect width="24" height="24" opacity="0"/>
<path d="M12 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0-6a2 2 0 1 1-2 2 2 2 0 0 1 2-2z"/>
<path d="M12 13a7 7 0 0 0-7 7 1 1 0 0 0 2 0 5 5 0 0 1 10 0 1 1 0 0 0 2 0 7 7 0 0 0-7-7z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

+3
View File
@@ -29,6 +29,9 @@ jest.mock('@react-native-async-storage/async-storage', () =>
jest.mock('@stripe/stripe-react-native', () => require('@stripe/stripe-react-native/jest/mock.js'));
// Navigation sizes the tab bar from the bottom inset; the mock answers with zeros without a provider
jest.mock('react-native-safe-area-context', () => require('react-native-safe-area-context/jest/mock').default);
// The preset's RefreshControl mock drops its props, so onRefresh and testID never reach the host tree
jest.mock('react-native/Libraries/Components/RefreshControl/RefreshControl', () => {
const React = require('react');
+16 -1
View File
@@ -27,6 +27,7 @@
"expo-constants": "~57.0.17",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
"expo-navigation-bar": "~57.0.2",
"expo-notifications": "~57.0.17",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
@@ -54,7 +55,7 @@
"jest": "^29.7.0",
"jest-expo": "~57.0.5",
"prettier": "^3.9.6",
"test-renderer": "1.2",
"test-renderer": "~1.2.0",
"typescript": "~6.0.3"
}
},
@@ -8488,6 +8489,20 @@
"react-native": "*"
}
},
"node_modules/expo-navigation-bar": {
"version": "57.0.2",
"resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-57.0.2.tgz",
"integrity": "sha512-Ufe49dsTZjarA7gMMwwwnNJWpXo+oTyl5Ud0g3lsGVrj2HXjyw9cb9098N8+CoWpjfIa5PghuRYYXrcCrGQIHg==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.2"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-notifications": {
"version": "57.0.17",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.17.tgz",
+1
View File
@@ -34,6 +34,7 @@
"expo-constants": "~57.0.17",
"expo-dev-client": "~57.0.18",
"expo-font": "~57.0.3",
"expo-navigation-bar": "~57.0.2",
"expo-notifications": "~57.0.17",
"expo-splash-screen": "~57.0.8",
"expo-status-bar": "~57.0.1",
+16 -13
View File
@@ -5,6 +5,7 @@ import * as Notifications from 'expo-notifications';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-context';
import 'expo-dev-client';
import { AuthProvider } from '@/auth/AuthContext';
@@ -27,19 +28,21 @@ export default function App() {
useDeepLinking();
return (
<GestureHandlerRootView>
<StatusBar style="light" />
<PersistQueryClientProvider client={appQueryClient} persistOptions={persistOptions}>
<AuthProvider>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<AppInner />
</StripeProvider>
</AuthProvider>
</PersistQueryClientProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<StatusBar style="light" />
<PersistQueryClientProvider client={appQueryClient} persistOptions={persistOptions}>
<AuthProvider>
{/* FIXME: Read urlScheme and merchantIdentifier from .Config */}
<StripeProvider
publishableKey={process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''}
urlScheme={Constants.expoConfig?.scheme as string}
merchantIdentifier="merchant.no.neuf.chateau" // https://developer.apple.com/account/resources/identifiers/list/merchant
>
<AppInner />
</StripeProvider>
</AuthProvider>
</PersistQueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
+63 -40
View File
@@ -2,8 +2,11 @@ import { BottomTabNavigationOptions, createBottomTabNavigator } from '@react-nav
import { DefaultTheme, NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator, NativeStackNavigationOptions } from '@react-navigation/native-stack';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAuth } from '@/auth/AuthContext';
import TabButton from '@/components/TabButton';
import TabIcon, { PILL_SIZE } from '@/components/TabIcon';
import About from '@/modules/about/About';
import EventList from '@/modules/events/EventList';
import Login from '@/modules/login/Login';
@@ -14,9 +17,9 @@ import Settings from '@/modules/settings/Settings';
import SettingsHeaderButton from '@/modules/settings/SettingsHeaderButton';
import theme from '@/theme';
import type { EventStackParamList, HomeTabParamList, UserStackParamList } from '@/types/navigation';
import IconEvent from '@assets/icons/event.svg';
import IconInfo from '@assets/icons/information-circle.svg';
import IconPerson from '@assets/icons/person.svg';
import IconCalendar from '@assets/icons/calendar.svg';
import IconIdCard from '@assets/icons/id-card.svg';
import IconInfo from '@assets/icons/info.svg';
const defaultNavigationStyle = {
headerTintColor: theme.colors.white,
@@ -83,41 +86,61 @@ const UserStackScreen = () => {
type TabBarIconType = BottomTabNavigationOptions['tabBarIcon'];
const HomeTabs = createBottomTabNavigator<HomeTabParamList>();
const EventIcon: TabBarIconType = ({ color, size }) => <IconEvent width={size} height={size} fill={color} />;
const UserIcon: TabBarIconType = ({ color, size }) => <IconPerson width={size + 8} height={size + 8} fill={color} />;
const AboutIcon: TabBarIconType = ({ color, size }) => <IconInfo width={size} height={size} fill={color} />;
const Navigation = () => (
<NavigationContainer theme={navigationTheme}>
<HomeTabs.Navigator initialRouteName="EventStack">
<HomeTabs.Screen
name="EventStack"
component={EventStackScreen}
options={{
headerShown: false,
tabBarLabel: 'Program',
tabBarIcon: EventIcon,
}}
/>
<HomeTabs.Screen
name="UserStack"
component={UserStackScreen}
options={{
headerShown: false,
tabBarLabel: 'Medlemskap',
tabBarIcon: UserIcon,
}}
/>
<HomeTabs.Screen
name="About"
component={About}
options={{
title: 'Om oss',
tabBarLabel: 'Om oss',
tabBarIcon: AboutIcon,
...defaultNavigationStyle,
}}
/>
</HomeTabs.Navigator>
</NavigationContainer>
);
const EventIcon: TabBarIconType = (props) => <TabIcon icon={IconCalendar} {...props} />;
const UserIcon: TabBarIconType = (props) => <TabIcon icon={IconIdCard} {...props} />;
const AboutIcon: TabBarIconType = (props) => <TabIcon icon={IconInfo} {...props} />;
const tabBarOptions: BottomTabNavigationOptions = {
tabBarActiveTintColor: theme.tabBar.activeTint,
tabBarInactiveTintColor: theme.tabBar.inactiveTint,
tabBarLabelStyle: { fontSize: 11, fontWeight: '500' },
tabBarIconStyle: PILL_SIZE,
tabBarButton: TabButton,
};
const Navigation = () => {
const insets = useSafeAreaInsets();
// Setting a height replaces the navigator's own inset handling, so the inset is added back here
const tabBarStyle = {
height: theme.tabBar.height + insets.bottom,
backgroundColor: theme.tabBar.background,
borderTopWidth: 0,
elevation: 0,
};
return (
<NavigationContainer theme={navigationTheme}>
<HomeTabs.Navigator initialRouteName="EventStack" screenOptions={{ ...tabBarOptions, tabBarStyle }}>
<HomeTabs.Screen
name="EventStack"
component={EventStackScreen}
options={{
headerShown: false,
tabBarLabel: 'Program',
tabBarIcon: EventIcon,
}}
/>
<HomeTabs.Screen
name="UserStack"
component={UserStackScreen}
options={{
headerShown: false,
tabBarLabel: 'Medlemskap',
tabBarIcon: UserIcon,
}}
/>
<HomeTabs.Screen
name="About"
component={About}
options={{
title: 'Om oss',
tabBarLabel: 'Om oss',
tabBarIcon: AboutIcon,
...defaultNavigationStyle,
}}
/>
</HomeTabs.Navigator>
</NavigationContainer>
);
};
export default Navigation;
+15
View File
@@ -0,0 +1,15 @@
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import React from 'react';
import { Pressable } from 'react-native';
// The stock button's borderless Android ripple flashes a circle well past the tab; the pill is feedback enough
const TabButton = ({
android_ripple,
pressColor,
pressOpacity,
hoverEffect,
ref,
...props
}: BottomTabBarButtonProps) => <Pressable {...props} />;
export default TabButton;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import theme from '@/theme';
type SvgComponent = React.FunctionComponent<React.SVGAttributes<SVGElement>>;
export type TabIconProps = {
icon: SvgComponent;
focused: boolean;
color: string;
size: number;
};
export const PILL_SIZE = { width: 60, height: 32 };
const TabIcon = ({ icon: Icon, focused, color, size }: TabIconProps) => (
<View style={[styles.pill, focused && styles.pillActive]}>
<Icon width={size} height={size} color={focused ? theme.tabBar.pill.text : color} />
</View>
);
const styles = StyleSheet.create({
pill: {
...PILL_SIZE,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
},
pillActive: {
backgroundColor: theme.tabBar.pill.background,
},
});
export default TabIcon;
+10 -1
View File
@@ -38,6 +38,15 @@ const buttons = {
secondary: { background: colors.goldenBeige, text: colors.deepBrick },
};
// Bottom tab bar: the screen's own surface with no edge, the active icon in a pill of the secondary button colours
const tabBar = {
height: 56,
background: colors.betongGray,
activeTint: colors.chateauBlue,
inactiveTint: colors.chateauBlue05,
pill: buttons.secondary,
};
// neuf.no's type scale at phone widths: body 1.5 line height, headings 600 at 1.2
const typography = {
h1: { fontSize: 32, fontWeight: '600', lineHeight: 38 },
@@ -49,4 +58,4 @@ const typography = {
suphead: { fontSize: 14, fontWeight: '700', lineHeight: 21, textTransform: 'uppercase', letterSpacing: 0.7 },
} as const;
export default { card, colors, background, text, typography, buttons };
export default { card, colors, background, text, typography, buttons, tabBar };