@hzblj/kara-router 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
- # Temporary Holding Version
1
+ # @hzblj/kara-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The Expo Router pieces of kara-ui: native tabs (`Tabs`) and `navigationTheme`, which maps the active kara theme onto React Navigation's.
4
+
5
+ **Install [`@hzblj/kara-ui`](https://www.npmjs.com/package/@hzblj/kara-ui) instead.** This package is one of its building
6
+ blocks, published so the umbrella can depend on it; it is versioned together with it and has no standalone docs.
7
+
8
+ MIT
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ export * from "./navigation-theme.js";
4
+ export * from "./tabs/index.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../src","sources":["index.ts"],"mappings":";;AAAA,cAAc,uBAAoB;AAClC,cAAc,iBAAQ","ignoreList":[]}
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+
3
+ import { fontFace, resolvePaletteColor } from '@hzblj/kara-theme';
4
+ import { DefaultTheme } from 'expo-router';
5
+ /**
6
+ * React Navigation types every colour as `string`, but the values only ever end up in a React Native
7
+ * style — which accepts any `ColorValue`, including the opaque `PlatformColor` a system token resolves to
8
+ * on iOS. Narrowing here rather than at each call site keeps the assertion to one place.
9
+ */
10
+ const navColor = (theme, value) => resolvePaletteColor(theme, value);
11
+
12
+ /**
13
+ * The app's own face for that weight where the theme names one, and React Navigation's platform default otherwise —
14
+ * which is the platform's face too, so headers and tab labels follow the same typeface as every other piece of text.
15
+ */
16
+ const navFont = (theme, weight, fallback) => {
17
+ const fontFamily = fontFace(theme.fonts, weight);
18
+ return fontFamily ? {
19
+ fontFamily,
20
+ fontWeight: fallback.fontWeight
21
+ } : fallback;
22
+ };
23
+
24
+ /**
25
+ * Maps the active theme onto React Navigation's, for expo-router's `ThemeProvider`. Call it inside
26
+ * `KaraProvider` with `useAppTheme()`, so the system half of the palette is resolved for the same variant.
27
+ */
28
+ export const navigationTheme = theme => ({
29
+ colors: {
30
+ background: 'transparent',
31
+ border: navColor(theme, theme.border.secondary.base),
32
+ card: navColor(theme, theme.surface.primary),
33
+ notification: navColor(theme, theme.fill.accent.primary),
34
+ primary: navColor(theme, theme.fill.accent.primary),
35
+ text: navColor(theme, theme.content.primary)
36
+ },
37
+ dark: theme.dark,
38
+ fonts: {
39
+ bold: navFont(theme, 'bold', DefaultTheme.fonts.bold),
40
+ heavy: navFont(theme, 'bold', DefaultTheme.fonts.heavy),
41
+ medium: navFont(theme, 'medium', DefaultTheme.fonts.medium),
42
+ regular: navFont(theme, 'regular', DefaultTheme.fonts.regular)
43
+ }
44
+ });
45
+ //# sourceMappingURL=navigation-theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["fontFace","resolvePaletteColor","DefaultTheme","navColor","theme","value","navFont","weight","fallback","fontFamily","fonts","fontWeight","navigationTheme","colors","background","border","secondary","base","card","surface","primary","notification","fill","accent","text","content","dark","bold","heavy","medium","regular"],"sourceRoot":"../../src","sources":["navigation-theme.ts"],"mappings":";;AAAA,SAAyBA,QAAQ,EAAqBC,mBAAmB,QAAmB,mBAAmB;AAC/G,SAAQC,YAAY,QAAsC,aAAa;AAIvE;AACA;AACA;AACA;AACA;AACA,MAAMC,QAAQ,GAAGA,CAACC,KAAY,EAAEC,KAAmB,KAAaJ,mBAAmB,CAACG,KAAK,EAAEC,KAAK,CAAW;;AAE3G;AACA;AACA;AACA;AACA,MAAMC,OAAO,GAAGA,CAACF,KAAY,EAAEG,MAAkB,EAAEC,QAAwB,KAAqB;EAC9F,MAAMC,UAAU,GAAGT,QAAQ,CAACI,KAAK,CAACM,KAAK,EAAEH,MAAM,CAAC;EAEhD,OAAOE,UAAU,GAAG;IAACA,UAAU;IAAEE,UAAU,EAAEH,QAAQ,CAACG;EAAU,CAAC,GAAGH,QAAQ;AAC9E,CAAC;;AAED;AACA;AACA;AACA;AACA,OAAO,MAAMI,eAAe,GAAIR,KAAY,KAAuB;EACjES,MAAM,EAAE;IACNC,UAAU,EAAE,aAAa;IACzBC,MAAM,EAAEZ,QAAQ,CAACC,KAAK,EAAEA,KAAK,CAACW,MAAM,CAACC,SAAS,CAACC,IAAI,CAAC;IACpDC,IAAI,EAAEf,QAAQ,CAACC,KAAK,EAAEA,KAAK,CAACe,OAAO,CAACC,OAAO,CAAC;IAC5CC,YAAY,EAAElB,QAAQ,CAACC,KAAK,EAAEA,KAAK,CAACkB,IAAI,CAACC,MAAM,CAACH,OAAO,CAAC;IACxDA,OAAO,EAAEjB,QAAQ,CAACC,KAAK,EAAEA,KAAK,CAACkB,IAAI,CAACC,MAAM,CAACH,OAAO,CAAC;IACnDI,IAAI,EAAErB,QAAQ,CAACC,KAAK,EAAEA,KAAK,CAACqB,OAAO,CAACL,OAAO;EAC7C,CAAC;EACDM,IAAI,EAAEtB,KAAK,CAACsB,IAAI;EAChBhB,KAAK,EAAE;IACLiB,IAAI,EAAErB,OAAO,CAACF,KAAK,EAAE,MAAM,EAAEF,YAAY,CAACQ,KAAK,CAACiB,IAAI,CAAC;IACrDC,KAAK,EAAEtB,OAAO,CAACF,KAAK,EAAE,MAAM,EAAEF,YAAY,CAACQ,KAAK,CAACkB,KAAK,CAAC;IACvDC,MAAM,EAAEvB,OAAO,CAACF,KAAK,EAAE,QAAQ,EAAEF,YAAY,CAACQ,KAAK,CAACmB,MAAM,CAAC;IAC3DC,OAAO,EAAExB,OAAO,CAACF,KAAK,EAAE,SAAS,EAAEF,YAAY,CAACQ,KAAK,CAACoB,OAAO;EAC/D;AACF,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ export * from "./tabs.js";
4
+ export * from "./tabs.types.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../../src","sources":["tabs/index.ts"],"mappings":";;AAAA,cAAc,WAAQ;AACtB,cAAc,iBAAc","ignoreList":[]}
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+
3
+ import { iconResourceName } from '@hzblj/kara-native';
4
+ import { NativeTabs } from 'expo-router/unstable-native-tabs';
5
+ import { Children, isValidElement } from 'react';
6
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
+ const {
8
+ Icon,
9
+ Label
10
+ } = NativeTabs.Trigger;
11
+
12
+ /**
13
+ * Declares one tab and renders nothing itself — `Tabs` reads these props and emits the trigger.
14
+ * The navigator collects its tabs by matching child element type exactly, so a component that
15
+ * merely *renders* a trigger is invisible to it and the tab bar comes up empty.
16
+ */
17
+ const TabsScreen = () => null;
18
+
19
+ /**
20
+ * The tab's icon, natively on both sides: the app icon's image set in the iOS asset catalog, its vector drawable on
21
+ * Android — one name, the one the config plugin gave both. No icon font, no bitmap rendered in JavaScript.
22
+ */
23
+ const renderTrigger = ({
24
+ hidden,
25
+ icon,
26
+ name,
27
+ selectedIcon,
28
+ testID,
29
+ title
30
+ }) => {
31
+ const asset = iconResourceName(icon);
32
+ const selected = selectedIcon ? iconResourceName(selectedIcon) : undefined;
33
+ return /*#__PURE__*/_jsxs(NativeTabs.Trigger, {
34
+ hidden: hidden,
35
+ name: name,
36
+ testID: testID,
37
+ children: [/*#__PURE__*/_jsx(Icon, {
38
+ drawable: selected ? {
39
+ default: asset,
40
+ selected
41
+ } : asset,
42
+ xcasset: selected ? {
43
+ default: asset,
44
+ selected
45
+ } : asset
46
+ }), /*#__PURE__*/_jsx(Label, {
47
+ children: title
48
+ })]
49
+ }, name);
50
+ };
51
+ const TabsRoot = ({
52
+ children,
53
+ minimizeBehavior = 'never'
54
+ }) => /*#__PURE__*/_jsx(NativeTabs, {
55
+ minimizeBehavior: minimizeBehavior,
56
+ children: Children.map(children, child => /*#__PURE__*/isValidElement(child) && child.type === TabsScreen ? renderTrigger(child.props) : child)
57
+ });
58
+
59
+ /**
60
+ * Native bottom tabs — a SwiftUI `TabView` on iOS and a Material navigation bar on Android, both
61
+ * driven by Expo Router's own navigator. Nothing here may come from the standalone
62
+ * `@react-navigation/*` packages: Expo Router 57 vendors its own fork of them, so a navigator built
63
+ * against the npm copies registers into a context tree that never exists and throws
64
+ * "Couldn't register the navigator" the moment this layout mounts.
65
+ */
66
+ export const Tabs = Object.assign(TabsRoot, {
67
+ Screen: TabsScreen
68
+ });
69
+ //# sourceMappingURL=tabs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["iconResourceName","NativeTabs","Children","isValidElement","jsx","_jsx","jsxs","_jsxs","Icon","Label","Trigger","TabsScreen","renderTrigger","hidden","icon","name","selectedIcon","testID","title","asset","selected","undefined","children","drawable","default","xcasset","TabsRoot","minimizeBehavior","map","child","type","props","Tabs","Object","assign","Screen"],"sourceRoot":"../../../src","sources":["tabs/tabs.tsx"],"mappings":";;AAAA,SAAQA,gBAAgB,QAAO,oBAAoB;AACnD,SAAQC,UAAU,QAAO,kCAAkC;AAC3D,SAAQC,QAAQ,EAAEC,cAAc,QAAO,OAAO;AAAA,SAAAC,GAAA,IAAAC,IAAA,EAAAC,IAAA,IAAAC,KAAA;AAI9C,MAAM;EAACC,IAAI;EAAEC;AAAK,CAAC,GAAGR,UAAU,CAACS,OAAO;;AAExC;AACA;AACA;AACA;AACA;AACA,MAAMC,UAAmC,GAAGA,CAAA,KAAM,IAAI;;AAEtD;AACA;AACA;AACA;AACA,MAAMC,aAAa,GAAGA,CAAC;EAACC,MAAM;EAAEC,IAAI;EAAEC,IAAI;EAAEC,YAAY;EAAEC,MAAM;EAAEC;AAAsB,CAAC,KAAK;EAC5F,MAAMC,KAAK,GAAGnB,gBAAgB,CAACc,IAAI,CAAC;EACpC,MAAMM,QAAQ,GAAGJ,YAAY,GAAGhB,gBAAgB,CAACgB,YAAY,CAAC,GAAGK,SAAS;EAE1E,oBACEd,KAAA,CAACN,UAAU,CAACS,OAAO;IAACG,MAAM,EAAEA,MAAO;IAAYE,IAAI,EAAEA,IAAK;IAACE,MAAM,EAAEA,MAAO;IAAAK,QAAA,gBACxEjB,IAAA,CAACG,IAAI;MACHe,QAAQ,EAAEH,QAAQ,GAAG;QAACI,OAAO,EAAEL,KAAK;QAAEC;MAAQ,CAAC,GAAGD,KAAM;MACxDM,OAAO,EAAEL,QAAQ,GAAG;QAACI,OAAO,EAAEL,KAAK;QAAEC;MAAQ,CAAC,GAAGD;IAAM,CACxD,CAAC,eACFd,IAAA,CAACI,KAAK;MAAAa,QAAA,EAAEJ;IAAK,CAAQ,CAAC;EAAA,GALiBH,IAMrB,CAAC;AAEzB,CAAC;AAED,MAAMW,QAAQ,GAAGA,CAAC;EAACJ,QAAQ;EAAEK,gBAAgB,GAAG;AAAkB,CAAC,kBACjEtB,IAAA,CAACJ,UAAU;EAAC0B,gBAAgB,EAAEA,gBAAiB;EAAAL,QAAA,EAC5CpB,QAAQ,CAAC0B,GAAG,CAACN,QAAQ,EAAEO,KAAK,IAC3B,aAAA1B,cAAc,CAAkB0B,KAAK,CAAC,IAAIA,KAAK,CAACC,IAAI,KAAKnB,UAAU,GAAGC,aAAa,CAACiB,KAAK,CAACE,KAAK,CAAC,GAAGF,KACrG;AAAC,CACS,CACb;;AAED;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMG,IAAmB,GAAGC,MAAM,CAACC,MAAM,CAACR,QAAQ,EAAE;EAACS,MAAM,EAAExB;AAAU,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+
3
+ export {};
4
+ //# sourceMappingURL=tabs.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":[],"sourceRoot":"../../../src","sources":["tabs/tabs.types.ts"],"mappings":"","ignoreList":[]}
@@ -0,0 +1,2 @@
1
+ export * from './navigation-theme';
2
+ export * from './tabs';
@@ -0,0 +1,7 @@
1
+ import { type Theme } from '@hzblj/kara-theme';
2
+ import { type Theme as NavigationTheme } from 'expo-router';
3
+ /**
4
+ * Maps the active theme onto React Navigation's, for expo-router's `ThemeProvider`. Call it inside
5
+ * `KaraProvider` with `useAppTheme()`, so the system half of the palette is resolved for the same variant.
6
+ */
7
+ export declare const navigationTheme: (theme: Theme) => NavigationTheme;
@@ -0,0 +1,2 @@
1
+ export * from './tabs';
2
+ export * from './tabs.types';
@@ -0,0 +1,9 @@
1
+ import type { TabsComponent } from './tabs.types';
2
+ /**
3
+ * Native bottom tabs — a SwiftUI `TabView` on iOS and a Material navigation bar on Android, both
4
+ * driven by Expo Router's own navigator. Nothing here may come from the standalone
5
+ * `@react-navigation/*` packages: Expo Router 57 vendors its own fork of them, so a navigator built
6
+ * against the npm copies registers into a context tree that never exists and throws
7
+ * "Couldn't register the navigator" the moment this layout mounts.
8
+ */
9
+ export declare const Tabs: TabsComponent;
@@ -0,0 +1,34 @@
1
+ import type { IconName } from '@hzblj/kara-components';
2
+ import type { ReactNode } from 'react';
3
+ export type TabsScreenProps = {
4
+ /**
5
+ * Drops the tab from the bar *and* makes the route unreachable by navigation. Prefer this over
6
+ * conditionally rendering the screen: every child route needs a declared tab, and toggling this
7
+ * at runtime remounts the navigator, so pass a build constant.
8
+ */
9
+ hidden?: boolean;
10
+ /** One of the app's icons — the asset-catalog image on iOS, the vector drawable on Android. */
11
+ icon: IconName;
12
+ name: string;
13
+ /** Swapped in while the tab is active — the filled counterpart of `icon`, say. */
14
+ selectedIcon?: IconName;
15
+ testID?: string;
16
+ title: string;
17
+ };
18
+ /** Maps to `UITabBarController.minimizeBehavior`. iOS 26+; ignored everywhere else. */
19
+ export type TabsMinimizeBehavior = 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp';
20
+ export type TabsProps = {
21
+ children: ReactNode;
22
+ /**
23
+ * Whether the iOS 26 tab bar shrinks to a pill while scrolling.
24
+ *
25
+ * Off by default, unlike the system, which minimizes on scroll. Minimizing changes the tab bar's
26
+ * height mid-gesture, and a native tabs screen re-insets the first scroll view inside it to match —
27
+ * so the content shifts under the finger every time the scroll direction changes, and every list in
28
+ * the app inherits that. Opt in per screen once the content is built to absorb it.
29
+ */
30
+ minimizeBehavior?: TabsMinimizeBehavior;
31
+ };
32
+ export type TabsComponent = ((props: TabsProps) => ReactNode) & {
33
+ Screen: (props: TabsScreenProps) => ReactNode;
34
+ };
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@hzblj/kara-router",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Expo Router pieces of @hzblj/kara-ui: native tabs and a navigation theme mapped from the active kara theme.",
5
+ "author": "Jan Blazej",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hzblj/kara-ui.git",
10
+ "directory": "packages/router"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/hzblj/kara-ui/issues"
14
+ },
15
+ "homepage": "https://github.com/hzblj/kara-ui/tree/main/packages/router#readme",
16
+ "keywords": [
17
+ "react-native",
18
+ "expo",
19
+ "kara-ui"
20
+ ],
21
+ "main": "./lib/module/index.js",
22
+ "types": "./lib/typescript/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "source": "./src/index.ts",
26
+ "types": "./lib/typescript/index.d.ts",
27
+ "default": "./lib/module/index.js"
28
+ },
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "lib",
34
+ "!**/*.stories.tsx",
35
+ "!**/contract.check.ts",
36
+ "!**/story-decorators.tsx",
37
+ "!**/__tests__"
38
+ ],
39
+ "scripts": {
40
+ "build": "bob build && tsc -p ./tsconfig.build.json && node ../../scripts/fallback-declarations.mjs",
41
+ "clean": "rm -rf lib",
42
+ "prepack": "yarn run -T turbo run build --filter=$npm_package_name...",
43
+ "lint": "yarn run -T concurrently \"yarn biome:check\" \"yarn tsc:check\"",
44
+ "lint:fix": "yarn run -T biome check --write .",
45
+ "biome:check": "yarn run -T biome check .",
46
+ "tsc:check": "yarn run -T concurrently \"tsc -p ./tsconfig.ios.json\" \"tsc -p ./tsconfig.android.json\""
47
+ },
48
+ "react-native-builder-bob": {
49
+ "source": "src",
50
+ "output": "lib",
51
+ "exclude": "{**/__tests__/**,**/__fixtures__/**,**/__mocks__/**,**/*.stories.tsx,**/contract.check.ts,**/story-decorators.tsx}",
52
+ "targets": [
53
+ [
54
+ "module",
55
+ {
56
+ "esm": true
57
+ }
58
+ ]
59
+ ]
60
+ },
61
+ "dependencies": {
62
+ "@hzblj/kara-components": "workspace:^",
63
+ "@hzblj/kara-native": "workspace:^",
64
+ "@hzblj/kara-theme": "workspace:^"
65
+ },
66
+ "peerDependencies": {
67
+ "expo-router": "*",
68
+ "react": "*",
69
+ "react-native": "*"
70
+ },
71
+ "devDependencies": {
72
+ "@expo/ui": "~57.0.18",
73
+ "@storybook/react-native": "^10.6.0",
74
+ "@types/react": "~19.2.18",
75
+ "expo": "~57.0.22",
76
+ "expo-router": "~57.0.21",
77
+ "react": "19.2.3",
78
+ "react-native": "0.86.3",
79
+ "react-native-builder-bob": "^0.43.0",
80
+ "storybook": "^10.6.0",
81
+ "typescript": "~7.0.2"
82
+ },
83
+ "publishConfig": {
84
+ "access": "public"
85
+ }
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './navigation-theme'
2
+ export * from './tabs'
@@ -0,0 +1,43 @@
1
+ import {type FontWeight, fontFace, type PaletteColor, resolvePaletteColor, type Theme} from '@hzblj/kara-theme'
2
+ import {DefaultTheme, type Theme as NavigationTheme} from 'expo-router'
3
+
4
+ type NavigationFont = NavigationTheme['fonts']['regular']
5
+
6
+ /**
7
+ * React Navigation types every colour as `string`, but the values only ever end up in a React Native
8
+ * style — which accepts any `ColorValue`, including the opaque `PlatformColor` a system token resolves to
9
+ * on iOS. Narrowing here rather than at each call site keeps the assertion to one place.
10
+ */
11
+ const navColor = (theme: Theme, value: PaletteColor): string => resolvePaletteColor(theme, value) as string
12
+
13
+ /**
14
+ * The app's own face for that weight where the theme names one, and React Navigation's platform default otherwise —
15
+ * which is the platform's face too, so headers and tab labels follow the same typeface as every other piece of text.
16
+ */
17
+ const navFont = (theme: Theme, weight: FontWeight, fallback: NavigationFont): NavigationFont => {
18
+ const fontFamily = fontFace(theme.fonts, weight)
19
+
20
+ return fontFamily ? {fontFamily, fontWeight: fallback.fontWeight} : fallback
21
+ }
22
+
23
+ /**
24
+ * Maps the active theme onto React Navigation's, for expo-router's `ThemeProvider`. Call it inside
25
+ * `KaraProvider` with `useAppTheme()`, so the system half of the palette is resolved for the same variant.
26
+ */
27
+ export const navigationTheme = (theme: Theme): NavigationTheme => ({
28
+ colors: {
29
+ background: 'transparent',
30
+ border: navColor(theme, theme.border.secondary.base),
31
+ card: navColor(theme, theme.surface.primary),
32
+ notification: navColor(theme, theme.fill.accent.primary),
33
+ primary: navColor(theme, theme.fill.accent.primary),
34
+ text: navColor(theme, theme.content.primary),
35
+ },
36
+ dark: theme.dark,
37
+ fonts: {
38
+ bold: navFont(theme, 'bold', DefaultTheme.fonts.bold),
39
+ heavy: navFont(theme, 'bold', DefaultTheme.fonts.heavy),
40
+ medium: navFont(theme, 'medium', DefaultTheme.fonts.medium),
41
+ regular: navFont(theme, 'regular', DefaultTheme.fonts.regular),
42
+ },
43
+ })
@@ -0,0 +1,2 @@
1
+ export * from './tabs'
2
+ export * from './tabs.types'
@@ -0,0 +1,50 @@
1
+ import {iconResourceName} from '@hzblj/kara-native'
2
+ import {NativeTabs} from 'expo-router/unstable-native-tabs'
3
+ import {Children, isValidElement} from 'react'
4
+
5
+ import type {TabsComponent, TabsProps, TabsScreenProps} from './tabs.types'
6
+
7
+ const {Icon, Label} = NativeTabs.Trigger
8
+
9
+ /**
10
+ * Declares one tab and renders nothing itself — `Tabs` reads these props and emits the trigger.
11
+ * The navigator collects its tabs by matching child element type exactly, so a component that
12
+ * merely *renders* a trigger is invisible to it and the tab bar comes up empty.
13
+ */
14
+ const TabsScreen: TabsComponent['Screen'] = () => null
15
+
16
+ /**
17
+ * The tab's icon, natively on both sides: the app icon's image set in the iOS asset catalog, its vector drawable on
18
+ * Android — one name, the one the config plugin gave both. No icon font, no bitmap rendered in JavaScript.
19
+ */
20
+ const renderTrigger = ({hidden, icon, name, selectedIcon, testID, title}: TabsScreenProps) => {
21
+ const asset = iconResourceName(icon)
22
+ const selected = selectedIcon ? iconResourceName(selectedIcon) : undefined
23
+
24
+ return (
25
+ <NativeTabs.Trigger hidden={hidden} key={name} name={name} testID={testID}>
26
+ <Icon
27
+ drawable={selected ? {default: asset, selected} : asset}
28
+ xcasset={selected ? {default: asset, selected} : asset}
29
+ />
30
+ <Label>{title}</Label>
31
+ </NativeTabs.Trigger>
32
+ )
33
+ }
34
+
35
+ const TabsRoot = ({children, minimizeBehavior = 'never'}: TabsProps) => (
36
+ <NativeTabs minimizeBehavior={minimizeBehavior}>
37
+ {Children.map(children, child =>
38
+ isValidElement<TabsScreenProps>(child) && child.type === TabsScreen ? renderTrigger(child.props) : child
39
+ )}
40
+ </NativeTabs>
41
+ )
42
+
43
+ /**
44
+ * Native bottom tabs — a SwiftUI `TabView` on iOS and a Material navigation bar on Android, both
45
+ * driven by Expo Router's own navigator. Nothing here may come from the standalone
46
+ * `@react-navigation/*` packages: Expo Router 57 vendors its own fork of them, so a navigator built
47
+ * against the npm copies registers into a context tree that never exists and throws
48
+ * "Couldn't register the navigator" the moment this layout mounts.
49
+ */
50
+ export const Tabs: TabsComponent = Object.assign(TabsRoot, {Screen: TabsScreen})
@@ -0,0 +1,38 @@
1
+ import type {IconName} from '@hzblj/kara-components'
2
+ import type {ReactNode} from 'react'
3
+
4
+ export type TabsScreenProps = {
5
+ /**
6
+ * Drops the tab from the bar *and* makes the route unreachable by navigation. Prefer this over
7
+ * conditionally rendering the screen: every child route needs a declared tab, and toggling this
8
+ * at runtime remounts the navigator, so pass a build constant.
9
+ */
10
+ hidden?: boolean
11
+ /** One of the app's icons — the asset-catalog image on iOS, the vector drawable on Android. */
12
+ icon: IconName
13
+ name: string
14
+ /** Swapped in while the tab is active — the filled counterpart of `icon`, say. */
15
+ selectedIcon?: IconName
16
+ testID?: string
17
+ title: string
18
+ }
19
+
20
+ /** Maps to `UITabBarController.minimizeBehavior`. iOS 26+; ignored everywhere else. */
21
+ export type TabsMinimizeBehavior = 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'
22
+
23
+ export type TabsProps = {
24
+ children: ReactNode
25
+ /**
26
+ * Whether the iOS 26 tab bar shrinks to a pill while scrolling.
27
+ *
28
+ * Off by default, unlike the system, which minimizes on scroll. Minimizing changes the tab bar's
29
+ * height mid-gesture, and a native tabs screen re-insets the first scroll view inside it to match —
30
+ * so the content shifts under the finger every time the scroll direction changes, and every list in
31
+ * the app inherits that. Opt in per screen once the content is built to absorb it.
32
+ */
33
+ minimizeBehavior?: TabsMinimizeBehavior
34
+ }
35
+
36
+ export type TabsComponent = ((props: TabsProps) => ReactNode) & {
37
+ Screen: (props: TabsScreenProps) => ReactNode
38
+ }