/** * useForgeBottomTabScreenOptions * * Returns a `screenOptions` object for **React Navigation bottom tab navigators** * (`@react-navigation/bottom-tabs` v7+), populated with CDX UI design-system * token values. * * CDX UI does not import `@react-navigation` — the consumer passes the returned * object to their own navigator. This keeps the dependency out of * `@cdx-ui/components` and works with any React Navigation tab variant: * `createNativeBottomTabNavigator` (iOS/Android) or `createBottomTabNavigator` * (web/JS). * * **Not compatible with:** * - Expo Router `` — uses different prop names * - React Router or DOM tab components — these are not React Navigation * * ## Basic usage * * ```tsx * import { useForgeBottomTabScreenOptions } from '@cdx-ui/components'; * import { createNativeBottomTabNavigator } from '@react-navigation/bottom-tabs/unstable'; * * const Tab = createNativeBottomTabNavigator(); * * function AppTabs() { * const screenOptions = useForgeBottomTabScreenOptions(); * return ( * * * * ); * } * ``` * * ## Overriding individual tokens * * Spread the returned object and override only the keys you need. * Nested style objects (`tabBarLabelStyle`, `tabBarStyle`, `tabBarBadgeStyle`) * must be spread individually to preserve unrelated DS defaults: * * ```tsx * const dsOptions = useForgeBottomTabScreenOptions(); * const screenOptions = { * ...dsOptions, * tabBarActiveTintColor: '#custom', * tabBarLabelStyle: { ...dsOptions.tabBarLabelStyle, fontFamily: 'MyFont' }, * }; * ``` * * ## Token variable names * * | screenOption | CSS variable | * | ---------------------------------- | ---------------------------------- | * | tabBarActiveTintColor | --color-content-action | * | tabBarInactiveTintColor | --color-content-secondary | * | tabBarStyle.backgroundColor | --color-surface-primary | * | tabBarLabelStyle.fontFamily | --font-body | * | tabBarLabelStyle.fontSize | --text-xs (via `text-xs` class) | * | tabBarLabelStyle.fontWeight | --font-weight-medium | * | tabBarBadgeStyle.backgroundColor | --color-surface-danger-strong | * * ## Platform notes * * - `tabBarInactiveTintColor` has no effect on iOS — inactive items always use * system grey. * - `tabBarStyle.backgroundColor` is ignored on iOS 26+ (liquid glass takes * over). It is honoured on iOS ≤18 and all Android versions. * - The hook resolves CSS variables via Uniwind's `useCSSVariable`. If the * Uniwind runtime is not set up (e.g. in a plain Jest test without a CSS * context), each variable resolves to `undefined` and the hardcoded fallback * is used instead — fallbacks intentionally match the CDX UI light-theme * values. * - `tabBarLabelStyle.fontSize` is resolved via Uniwind's * `useResolveClassNames('text-xs')` rather than reading the rem-valued * `--text-xs` variable directly. React Navigation v7 requires a numeric * `fontSize`, and letting Uniwind resolve the class means the rem→px * conversion uses the consumer's configured rem base (default 16px, * overridable via the `polyfills.rem` Metro option) instead of a constant * hardcoded here. */ import type { TextStyle } from 'react-native'; import { useCSSVariable, useResolveClassNames } from 'uniwind'; import type { BottomTabNavigationOptions } from '@react-navigation/bottom-tabs'; // ── Types ──────────────────────────────────────────────────────────────────── /** * The `screenOptions` shape returned by `useForgeBottomTabScreenOptions`. * * These are concrete types (not the broad `StyleProp<…>` React Navigation * uses internally) so consumers get precise IDE completion. The compile-time * drift guard below ensures this type stays assignable to * `BottomTabNavigationOptions` — the TypeScript compiler will error if CDX UI * adds a key or value that React Navigation no longer accepts. * * Pass directly as `screenOptions` on a `createNativeBottomTabNavigator` or * `createBottomTabNavigator` navigator, or spread and override individual keys. */ export interface ForgeReactNavigationBottomTabScreenOptions { tabBarActiveTintColor: string; tabBarInactiveTintColor: string; tabBarStyle: { backgroundColor: string }; tabBarLabelStyle: { fontFamily: string; /** Pixel size — React Navigation requires a number for tab label fontSize. */ fontSize: number; fontWeight: TextStyle['fontWeight']; }; tabBarBadgeStyle: { backgroundColor: string }; } /** * Compile-time drift guard. * * `ForgeReactNavigationBottomTabScreenOptions` must be assignable to * `BottomTabNavigationOptions`. TypeScript checks this assignment at build * time — if React Navigation removes or incompatibly narrows one of our keys, * this line becomes a type error before it reaches consumers. * * The `null as unknown as ...` cast keeps the runtime value `null` so tree- * shakers eliminate the variable from the final bundle entirely. */ const _rnDriftGuard: BottomTabNavigationOptions = // eslint-disable-next-line @typescript-eslint/no-explicit-any null as any as ForgeReactNavigationBottomTabScreenOptions; // ── Fallbacks ──────────────────────────────────────────────────────────────── // // Resolved light-theme values for each token. Used when useCSSVariable returns // undefined (no Uniwind CSS context in scope, e.g. Jest). const FALLBACKS = { activeTintColor: '#346099', // --color-brand-700 (--color-content-action) inactiveTintColor: '#4a4c4f', // --color-base-800 (--color-content-secondary) fontFamily: 'Inter', // --font-sans (--font-body) fontSize: 12, // px (--text-xs) fontWeight: '500' as TextStyle['fontWeight'], // (--font-weight-medium) backgroundColor: '#ffffff', // --color-white (--color-surface-primary) badgeBackgroundColor: '#c10007', // --color-red-700 (--color-surface-danger-strong) } as const; /** * Coerces a Uniwind-resolved `fontSize` to a finite pixel number, falling * back when the style could not be resolved (e.g. no Uniwind runtime in Jest). * * `useResolveClassNames('text-xs')` performs the rem→px conversion using * Uniwind's configured rem base, so the value is already numeric on native. * React Native Web may surface string values; unit-less and `px` strings are * accepted, but anything else (`rem`, `em`, `%`) falls back rather than * guessing a rem base here. */ function toPx(value: string | number | undefined, fallbackPx: number): number { if (typeof value === 'number') { return Number.isFinite(value) ? value : fallbackPx; } if (typeof value !== 'string') return fallbackPx; const trimmed = value.trim(); if (!/^-?\d*\.?\d+(px)?$/.test(trimmed)) return fallbackPx; return parseFloat(trimmed); } /** * Coerces a Uniwind `useCSSVariable` fontWeight value to a string. * * `--font-weight-*` variables are emitted as unit-less numbers (e.g. `500`), * and `useCSSVariable` may return them as numbers. React Native's `TextStyle` * accepts numeric fontWeight, but react-native-screens' native bottom tabs * declare the corresponding host prop (`tabBarItemTitleFontWeight`) as a * String — passing a number crashes Android Fabric with * "java.lang.Double cannot be cast to java.lang.String". */ function toFontWeight( value: string | number | undefined, fallback: TextStyle['fontWeight'], ): TextStyle['fontWeight'] { if (value === undefined) return fallback; return String(value) as TextStyle['fontWeight']; } // ── Hook ───────────────────────────────────────────────────────────────────── /** * Returns a `screenOptions` object for **React Navigation bottom tab * navigators** (`@react-navigation/bottom-tabs` v7+), populated with CDX UI * design-system token values. * * Must be called inside a React component or custom hook (React hook rules * apply). */ /** * Deep-merges consumer overrides into a base `ForgeReactNavigationBottomTabScreenOptions` * object. Nested style objects (`tabBarLabelStyle`, `tabBarStyle`, * `tabBarBadgeStyle`) are merged property-by-property so consumers can override * individual style properties without losing unrelated DS defaults. * * Use with `useForgeBottomTabScreenOptions` to apply targeted overrides: * * ```tsx * const dsOptions = useForgeBottomTabScreenOptions(); * const screenOptions = mergeForgeBottomTabScreenOptions(dsOptions, { * tabBarActiveTintColor: '#custom', * tabBarLabelStyle: { fontFamily: 'MyFont' }, // fontSize + fontWeight preserved * }); * ``` */ export function mergeForgeBottomTabScreenOptions( base: ForgeReactNavigationBottomTabScreenOptions, overrides: Partial, ): ForgeReactNavigationBottomTabScreenOptions { return { ...base, ...overrides, tabBarLabelStyle: { ...base.tabBarLabelStyle, ...overrides.tabBarLabelStyle }, tabBarStyle: { ...base.tabBarStyle, ...overrides.tabBarStyle }, tabBarBadgeStyle: { ...base.tabBarBadgeStyle, ...overrides.tabBarBadgeStyle }, }; } export function useForgeBottomTabScreenOptions(): ForgeReactNavigationBottomTabScreenOptions { const activeTintColor = useCSSVariable('--color-content-action'); const inactiveTintColor = useCSSVariable('--color-content-secondary'); const fontFamily = useCSSVariable('--font-body'); const labelTextStyle = useResolveClassNames('text-xs'); const fontWeight = useCSSVariable('--font-weight-medium'); const backgroundColor = useCSSVariable('--color-surface-primary'); const badgeBackgroundColor = useCSSVariable('--color-surface-danger-strong'); return { tabBarActiveTintColor: String(activeTintColor ?? FALLBACKS.activeTintColor), tabBarInactiveTintColor: String(inactiveTintColor ?? FALLBACKS.inactiveTintColor), tabBarStyle: { backgroundColor: String(backgroundColor ?? FALLBACKS.backgroundColor), }, tabBarLabelStyle: { fontFamily: String(fontFamily ?? FALLBACKS.fontFamily), fontSize: toPx(labelTextStyle?.fontSize, FALLBACKS.fontSize), fontWeight: toFontWeight(fontWeight, FALLBACKS.fontWeight), }, tabBarBadgeStyle: { backgroundColor: String(badgeBackgroundColor ?? FALLBACKS.badgeBackgroundColor), }, }; }