diff --git a/assets/native-tabs.module.css b/assets/native-tabs.module.css index f29cec5..0d71dad 100644 --- a/assets/native-tabs.module.css +++ b/assets/native-tabs.module.css @@ -22,22 +22,27 @@ } .navigationMenuRoot { - top: 24px; + bottom: 24px; left: 50%; transform: translateX(-50%); position: fixed; z-index: 10; display: flex; - background-color: var(--expo-router-tabs-background-color, #272727); - height: 40px; - border-radius: 25px; + background-color: var(--expo-router-tabs-background-color, rgba(30, 30, 30, 0.88)); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + height: 56px; + border-radius: 28px; align-items: center; - justify-content: flex-start; - padding: 5px; + justify-content: center; + padding: 4px; box-sizing: border-box; margin: 0; - max-width: 90vw; - overflow-x: auto; + max-width: 95vw; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08); + gap: 2px; } .navigationMenuTrigger { @@ -48,36 +53,64 @@ height: 100%; background-color: transparent; border: none; - margin: 0; - height: 100%; - border-radius: 20px; - padding: 0 20px; + border-radius: 24px; + padding: 6px 16px; + transition: background-color 0.2s ease, backdrop-filter 0.2s ease; cursor: pointer; - outline-color: var(--expo-router-tabs-tab-outline-color, #444444); + outline-color: var(--expo-router-tabs-tab-outline-color, rgba(255, 255, 255, 0.2)); position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; +} + +.tabIcon { + display: flex; + align-items: center; + justify-content: center; + color: var(--expo-router-tabs-icon-color, rgba(255, 255, 255, 0.6)); + font-size: 18px; + width: 20px; + height: 20px; +} + +.tabIcon > * { + color: inherit; +} + +.navigationMenuTrigger[data-state="active"] .tabIcon { + color: var(--expo-router-tabs-active-icon-color, #ffffff); } .navigationMenuTrigger[data-state="active"] { - background-color: var(--expo-router-tabs-active-background-color, #444444); + background-color: var(--expo-router-tabs-active-background-color, rgba(255, 255, 255, 0.15)); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); } .tabText { font-weight: var(--expo-router-tabs-font-weight, 500); - font-size: var(--expo-router-tabs-font-size, 15px); + font-size: var(--expo-router-tabs-font-size, 11px); font-family: var(--expo-router-tabs-font-family); font-style: var(--expo-router-tabs-font-style, normal); opacity: var(--expo-router-tabs-text-opacity, 1); - color: var(--expo-router-tabs-text-color, #8b8b8b); + color: var(--expo-router-tabs-text-color, rgba(255, 255, 255, 0.6)); white-space: nowrap; } .navigationMenuTrigger[data-state="active"] .tabText { color: var(--expo-router-tabs-active-text-color, #ffffff); - font-size: var(--expo-router-tabs-active-font-size, var(--expo-router-tabs-font-size, 15px)); +} + +.navigationMenuTrigger:not([data-state="active"]):hover { + background-color: rgba(255, 255, 255, 0.08); } .navigationMenuTrigger:not([data-state="active"]) .tabText:hover { - opacity: var(--expo-router-tabs-text-hover-opacity, 0.6); + opacity: var(--expo-router-tabs-text-hover-opacity, 0.9); } .tabBadge { @@ -107,3 +140,101 @@ min-height: var(--expo-router-tabs-local-badge-size); border-radius: calc(var(--expo-router-tabs-local-badge-size) / 2); } + +/* More screen styles - Apple Settings inspired */ +.moreScreen { + flex: 1; + display: flex; + flex-direction: column; + background-color: #000000; + max-height: 100%; + max-width: 100%; + overflow-y: auto; + padding-bottom: 120px; +} + +.moreScreenHeader { + padding: 60px 20px 8px; +} + +.moreScreenTitle { + font-family: var(--expo-router-tabs-font-family); + font-size: 34px; + font-weight: 700; + color: #ffffff; + margin: 0; + letter-spacing: 0.37px; +} + +.moreScreenContent { + display: flex; + flex-direction: column; + padding: 20px 20px 0; +} + +.moreScreenGroup { + background-color: #1c1c1e; + border-radius: 10px; + overflow: hidden; +} + +.moreScreenItem { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px 8px 12px; + min-height: 44px; + border: none; + background-color: transparent; + cursor: pointer; + font-family: var(--expo-router-tabs-font-family); + text-align: left; + transition: background-color 0.1s ease; + position: relative; + width: 100%; + box-sizing: border-box; +} + +.moreScreenItem:hover { + background-color: rgba(255, 255, 255, 0.08); +} + +.moreScreenItem:active { + background-color: rgba(255, 255, 255, 0.12); +} + +/* Separator line between items */ +.moreScreenItem:not(:last-child)::after { + content: ''; + position: absolute; + bottom: 0; + left: 54px; + right: 0; + height: 0.5px; + background-color: rgba(84, 84, 88, 0.65); +} + +.moreScreenItemIcon { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 6px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0) 100%), #636366; + color: #ffffff; + flex-shrink: 0; +} + +.moreScreenItemLabel { + flex: 1; + font-size: 17px; + font-weight: 400; + color: #ffffff; + letter-spacing: -0.41px; +} + +.moreScreenItemChevron { + color: rgba(235, 235, 245, 0.3); + flex-shrink: 0; +} diff --git a/build/getLinkingConfig.js b/build/getLinkingConfig.js index 2a0ee73..302afc0 100644 --- a/build/getLinkingConfig.js +++ b/build/getLinkingConfig.js @@ -8,6 +8,7 @@ const constants_1 = require("./constants"); const getReactNavigationConfig_1 = require("./getReactNavigationConfig"); const getRoutesRedirects_1 = require("./getRoutesRedirects"); const linking_1 = require("./link/linking"); +const isAnythingApp = expo_modules_core_1.Platform.OS === 'ios' && !globalThis.expo?.modules?.ExpoGo; function getNavigationConfig(routes, metaOnly, { sitemap, notFound }) { const config = (0, getReactNavigationConfig_1.getReactNavigationConfig)(routes, metaOnly); const sitemapRoute = (() => { @@ -61,7 +62,10 @@ function getLinkingConfig(routes, context, getRouteInfo, { metaOnly = true, serv // Expo Router calls `getInitialURL` twice, which may confuse the user if they provide a custom `getInitialURL`. // Therefor we memoize the result. if (!hasCachedInitialUrl) { - if (expo_modules_core_1.Platform.OS === 'web') { + if (isAnythingApp) { + initialUrl = '/'; + } + else if (expo_modules_core_1.Platform.OS === 'web') { initialUrl = serverUrl ?? (0, linking_1.getInitialURL)(); } else { diff --git a/build/link/linking.js b/build/link/linking.js index b9535b5..ec99c96 100644 --- a/build/link/linking.js +++ b/build/link/linking.js @@ -47,6 +47,7 @@ Object.defineProperty(exports, "getStateFromPath", { enumerable: true, get: func const useLinking_1 = require("../fork/useLinking"); const getRoutesRedirects_1 = require("../getRoutesRedirects"); const isExpoGo = typeof expo !== 'undefined' && globalThis.expo?.modules?.ExpoGo; +const isAnythingApp = react_native_1.Platform.OS === 'ios' && !globalThis.expo?.modules?.ExpoGo; // A custom getInitialURL is used on native to ensure the app always starts at // the root path if it's launched from something other than a deep link. // This helps keep the native functionality working like the web functionality. @@ -124,7 +125,12 @@ function subscribe(nativeLinking, redirects) { } }; } - const subscription = Linking.addEventListener('url', callback); + let subscription; + + if (!isAnythingApp) { + subscription = Linking.addEventListener('url', callback); + } + return () => { // https://github.com/facebook/react-native/commit/6d1aca806cee86ad76de771ed3a1cc62982ebcd7 subscription?.remove?.(); diff --git a/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js b/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js index cd3d597..8bc0b00 100644 --- a/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js +++ b/build/native-tabs/NativeBottomTabs/NativeTabTrigger.js @@ -129,6 +129,16 @@ function appendLabelOptions(options, props) { else { options.title = props.children; options.selectedLabelStyle = props.selectedStyle; + // Extract label color for web + if (props.style?.color) { + options.webLabelColor = props.style.color; + } + if (props.color) { + options.webLabelColor = props.color; + } + if (props.selectedStyle?.color) { + options.webLabelSelectedColor = props.selectedStyle.color; + } } } function appendIconOptions(options, props) { @@ -136,6 +146,21 @@ function appendIconOptions(options, props) { const icon = convertIconSrcToIconOption(props); options.icon = icon?.icon; options.selectedIcon = icon?.selectedIcon; + // Preserve icon info for web rendering + const srcValue = typeof props.src === 'object' && 'selected' in props.src ? props.src.default : props.src; + if ((0, react_1.isValidElement)(srcValue) && srcValue.type === elements_1.VectorIcon) { + options.webIconFamily = srcValue.props.family; + options.webIconName = srcValue.props.name; + // Extract colors from VectorIcon props for web + if (srcValue.props.color) { + options.webIconColor = srcValue.props.color; + } + if (srcValue.props.selectedColor) { + options.webIconSelectedColor = srcValue.props.selectedColor; + } + } else { + options.webIcon = srcValue; + } } else if ('sf' in props && process.env.EXPO_OS === 'ios') { if (typeof props.sf === 'string') { @@ -169,6 +194,13 @@ function appendIconOptions(options, props) { options.selectedIcon = undefined; } options.selectedIconColor = props.selectedColor; + // Extract icon color for web + if (props.color) { + options.webIconColor = props.color; + } + if (props.selectedColor) { + options.webIconSelectedColor = props.selectedColor; + } } function convertIconSrcToIconOption(icon) { if (icon && icon.src) { diff --git a/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js b/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js index d3d738b..a27f83d 100644 --- a/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js +++ b/build/native-tabs/NativeBottomTabs/NativeTabsView.web.js @@ -41,16 +41,45 @@ const react_tabs_1 = require("@radix-ui/react-tabs"); const react_1 = __importStar(require("react")); const utils_1 = require("./utils"); const native_tabs_module_css_1 = __importDefault(require("../../../assets/native-tabs.module.css")); + +const MAX_VISIBLE_TABS = 5; + function NativeTabsView(props) { const { builder, focusedIndex } = props; const { state, descriptors, navigation } = builder; const { routes } = state; + const [showMoreScreen, setShowMoreScreen] = (0, react_1.useState)(false); const defaultTabName = (0, react_1.useMemo)(() => state.routes[focusedIndex]?.name ?? state.routes[0].name, []); const value = state.routes[focusedIndex]?.name ?? state.routes[0].name; const currentTabKey = state.routes[focusedIndex]?.key ?? state.routes[0].key; - const items = routes - .filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options)) - .map((route) => ()); + + const visibleRoutes = routes.filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options)); + const hasOverflow = visibleRoutes.length > MAX_VISIBLE_TABS; + const primaryRoutes = hasOverflow ? visibleRoutes.slice(0, MAX_VISIBLE_TABS - 1) : visibleRoutes; + const overflowRoutes = hasOverflow ? visibleRoutes.slice(MAX_VISIBLE_TABS - 1) : []; + + // Check if an overflow tab is currently active + const isOverflowTabActive = overflowRoutes.some(route => route.name === value); + + const items = primaryRoutes.map((route) => ( + setShowMoreScreen(false)} + forceInactive={showMoreScreen} + isActive={route.name === value && !showMoreScreen} + /> + )); + const children = routes .filter(({ key }) => (0, utils_1.shouldTabBeVisible)(descriptors[key].options)) .map((route) => { @@ -58,26 +87,116 @@ function NativeTabsView(props) { {descriptors[route.key].render()} ); }); - return ( { - navigation.dispatch({ - type: 'JUMP_TO', - target: state.key, - payload: { - name: value, - }, - }); + + const handleNavigate = (routeName) => { + navigation.dispatch({ + type: 'JUMP_TO', + target: state.key, + payload: { + name: routeName, + }, + }); + setShowMoreScreen(false); + }; + + return ( { + handleNavigate(newValue); }} style={convertNativeTabsPropsToStyleVars(props, descriptors[currentTabKey]?.options)}> + + {/* More Screen - shown when More tab is active */} + {showMoreScreen && ( +
+
+

More

+
+
+
+ {overflowRoutes.map((route) => ( + + ))} +
+
+
+ )} + + {/* Tab Content - hidden when More screen is shown */} + {!showMoreScreen && children} + {items} + {hasOverflow && ( + + )} - {children}
); } + +function OverflowTabIcon(props) { + const { webIcon, webIconFamily, webIconName } = props; + if (webIconFamily && webIconName) { + const IconComponent = webIconFamily; + return ; + } else if (webIcon) { + return webIcon; + } + return null; +} + function TabItem(props) { - const { title, badgeValue, route } = props; + const { title, badgeValue, route, webIcon, webIconFamily, webIconName, webIconColor, webIconSelectedColor, webLabelColor, webLabelSelectedColor, onClick, forceInactive, isActive } = props; const isBadgeEmpty = badgeValue === ' '; - return ( - {title} + const dataState = forceInactive ? "inactive" : (isActive ? "active" : "inactive"); + + // Resolve colors based on active state + const resolvedIconColor = isActive && webIconSelectedColor ? webIconSelectedColor : webIconColor; + const resolvedLabelColor = isActive && webLabelSelectedColor ? webLabelSelectedColor : webLabelColor; + + const iconStyle = resolvedIconColor ? { color: resolvedIconColor } : {}; + const labelStyle = resolvedLabelColor ? { color: resolvedLabelColor } : {}; + + let iconElement = null; + if (webIconFamily && webIconName) { + const IconComponent = webIconFamily; + iconElement = (); + } else if (webIcon) { + iconElement = ({webIcon}); + } + return ( + {iconElement} + {title} {badgeValue && (
{badgeValue}
)}