@hzblj/kara-router 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @hzblj/kara-router
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`f919c7b`](https://github.com/hzblj/kara-ui/commit/f919c7b06f3798cebd58eef1581076bf1ee61cd1) Thanks [@hzblj](https://github.com/hzblj)! - First release of the kara-ui design system: theme and tokens, the `sx` engine, atoms, molecules, organisms and `Screen`, native tabs (`@hzblj/kara-ui/router`), the KaraUI native module (Liquid Glass, scroll edge effect, native countdown, context menu with preview, rich tooltip, FAB menu), and `KaraProvider` with theme overrides — colours, the app's own fonts, the Material seed, and Material You dynamic colour on Android. Text uses the platform's face by default. Install `@hzblj/kara-ui`; the scoped packages are its building blocks.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`f919c7b`](https://github.com/hzblj/kara-ui/commit/f919c7b06f3798cebd58eef1581076bf1ee61cd1)]:
12
+ - @hzblj/kara-theme@0.1.0
13
+ - @hzblj/kara-components@0.1.0
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,67 @@
1
+ "use strict";
2
+
3
+ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
4
+ import { MATERIAL_ICONS, SF_SYMBOLS } from '@hzblj/kara-components';
5
+ import { NativeTabs } from 'expo-router/unstable-native-tabs';
6
+ import { Children, isValidElement } from 'react';
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ const {
9
+ Icon,
10
+ Label,
11
+ VectorIcon
12
+ } = NativeTabs.Trigger;
13
+ const sfSymbol = name => SF_SYMBOLS[name];
14
+ const materialIcon = name => /*#__PURE__*/_jsx(VectorIcon, {
15
+ family: MaterialIcons,
16
+ name: MATERIAL_ICONS[name]
17
+ });
18
+
19
+ /**
20
+ * Declares one tab and renders nothing itself — `Tabs` reads these props and emits the trigger.
21
+ * The navigator collects its tabs by matching child element type exactly, so a component that
22
+ * merely *renders* a trigger is invisible to it and the tab bar comes up empty.
23
+ */
24
+ const TabsScreen = () => null;
25
+ const renderTrigger = ({
26
+ hidden,
27
+ icon,
28
+ name,
29
+ selectedIcon,
30
+ testID,
31
+ title
32
+ }) => /*#__PURE__*/_jsxs(NativeTabs.Trigger, {
33
+ hidden: hidden,
34
+ name: name,
35
+ testID: testID,
36
+ children: [/*#__PURE__*/_jsx(Icon, {
37
+ sf: selectedIcon ? {
38
+ default: sfSymbol(icon),
39
+ selected: sfSymbol(selectedIcon)
40
+ } : sfSymbol(icon),
41
+ src: selectedIcon ? {
42
+ default: materialIcon(icon),
43
+ selected: materialIcon(selectedIcon)
44
+ } : materialIcon(icon)
45
+ }), /*#__PURE__*/_jsx(Label, {
46
+ children: title
47
+ })]
48
+ }, name);
49
+ const TabsRoot = ({
50
+ children,
51
+ minimizeBehavior = 'never'
52
+ }) => /*#__PURE__*/_jsx(NativeTabs, {
53
+ minimizeBehavior: minimizeBehavior,
54
+ children: Children.map(children, child => /*#__PURE__*/isValidElement(child) && child.type === TabsScreen ? renderTrigger(child.props) : child)
55
+ });
56
+
57
+ /**
58
+ * Native bottom tabs — a SwiftUI `TabView` on iOS and a Material navigation bar on Android, both
59
+ * driven by Expo Router's own navigator. Nothing here may come from the standalone
60
+ * `@react-navigation/*` packages: Expo Router 57 vendors its own fork of them, so a navigator built
61
+ * against the npm copies registers into a context tree that never exists and throws
62
+ * "Couldn't register the navigator" the moment this layout mounts.
63
+ */
64
+ export const Tabs = Object.assign(TabsRoot, {
65
+ Screen: TabsScreen
66
+ });
67
+ //# sourceMappingURL=tabs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["MaterialIcons","MATERIAL_ICONS","SF_SYMBOLS","NativeTabs","Children","isValidElement","jsx","_jsx","jsxs","_jsxs","Icon","Label","VectorIcon","Trigger","sfSymbol","name","materialIcon","family","TabsScreen","renderTrigger","hidden","icon","selectedIcon","testID","title","children","sf","default","selected","src","TabsRoot","minimizeBehavior","map","child","type","props","Tabs","Object","assign","Screen"],"sourceRoot":"../../../src","sources":["tabs/tabs.tsx"],"mappings":";;AAAA,OAAOA,aAAa,MAAM,kCAAkC;AAC5D,SAAuBC,cAAc,EAAEC,UAAU,QAAO,wBAAwB;AAChF,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,KAAK;EAAEC;AAAU,CAAC,GAAGT,UAAU,CAACU,OAAO;AAEpD,MAAMC,QAAQ,GAAIC,IAAc,IAAKb,UAAU,CAACa,IAAI,CAAa;AAEjE,MAAMC,YAAY,GAAID,IAAc,iBAAKR,IAAA,CAACK,UAAU;EAACK,MAAM,EAAEjB,aAAc;EAACe,IAAI,EAAEd,cAAc,CAACc,IAAI;AAAE,CAAE,CAAC;;AAE1G;AACA;AACA;AACA;AACA;AACA,MAAMG,UAAmC,GAAGA,CAAA,KAAM,IAAI;AAEtD,MAAMC,aAAa,GAAGA,CAAC;EAACC,MAAM;EAAEC,IAAI;EAAEN,IAAI;EAAEO,YAAY;EAAEC,MAAM;EAAEC;AAAsB,CAAC,kBACvFf,KAAA,CAACN,UAAU,CAACU,OAAO;EAACO,MAAM,EAAEA,MAAO;EAAYL,IAAI,EAAEA,IAAK;EAACQ,MAAM,EAAEA,MAAO;EAAAE,QAAA,gBACxElB,IAAA,CAACG,IAAI;IACHgB,EAAE,EAAEJ,YAAY,GAAG;MAACK,OAAO,EAAEb,QAAQ,CAACO,IAAI,CAAC;MAAEO,QAAQ,EAAEd,QAAQ,CAACQ,YAAY;IAAC,CAAC,GAAGR,QAAQ,CAACO,IAAI,CAAE;IAChGQ,GAAG,EAAEP,YAAY,GAAG;MAACK,OAAO,EAAEX,YAAY,CAACK,IAAI,CAAC;MAAEO,QAAQ,EAAEZ,YAAY,CAACM,YAAY;IAAC,CAAC,GAAGN,YAAY,CAACK,IAAI;EAAE,CAC9G,CAAC,eACFd,IAAA,CAACI,KAAK;IAAAc,QAAA,EAAED;EAAK,CAAQ,CAAC;AAAA,GALiBT,IAMrB,CACrB;AAED,MAAMe,QAAQ,GAAGA,CAAC;EAACL,QAAQ;EAAEM,gBAAgB,GAAG;AAAkB,CAAC,kBACjExB,IAAA,CAACJ,UAAU;EAAC4B,gBAAgB,EAAEA,gBAAiB;EAAAN,QAAA,EAC5CrB,QAAQ,CAAC4B,GAAG,CAACP,QAAQ,EAAEQ,KAAK,IAC3B,aAAA5B,cAAc,CAAkB4B,KAAK,CAAC,IAAIA,KAAK,CAACC,IAAI,KAAKhB,UAAU,GAAGC,aAAa,CAACc,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,EAAErB;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,33 @@
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
+ icon: IconName;
11
+ name: string;
12
+ /** Swapped in while the tab is active — the filled/solid counterpart of `icon` on iOS. */
13
+ selectedIcon?: IconName;
14
+ testID?: string;
15
+ title: string;
16
+ };
17
+ /** Maps to `UITabBarController.minimizeBehavior`. iOS 26+; ignored everywhere else. */
18
+ export type TabsMinimizeBehavior = 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp';
19
+ export type TabsProps = {
20
+ children: ReactNode;
21
+ /**
22
+ * Whether the iOS 26 tab bar shrinks to a pill while scrolling.
23
+ *
24
+ * Off by default, unlike the system, which minimizes on scroll. Minimizing changes the tab bar's
25
+ * height mid-gesture, and a native tabs screen re-insets the first scroll view inside it to match —
26
+ * so the content shifts under the finger every time the scroll direction changes, and every list in
27
+ * the app inherits that. Opt in per screen once the content is built to absorb it.
28
+ */
29
+ minimizeBehavior?: TabsMinimizeBehavior;
30
+ };
31
+ export type TabsComponent = ((props: TabsProps) => ReactNode) & {
32
+ Screen: (props: TabsScreenProps) => ReactNode;
33
+ };
package/package.json CHANGED
@@ -1,6 +1,88 @@
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"
3
+ "version": "0.1.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": "^0.1.0",
63
+ "@hzblj/kara-theme": "^0.1.0",
64
+ "sf-symbols-typescript": "^2.2.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@expo/vector-icons": "*",
68
+ "expo-router": "*",
69
+ "react": "*",
70
+ "react-native": "*"
71
+ },
72
+ "devDependencies": {
73
+ "@expo/ui": "~57.0.18",
74
+ "@expo/vector-icons": "^15.1.1",
75
+ "@storybook/react-native": "^10.6.0",
76
+ "@types/react": "~19.2.18",
77
+ "expo": "~57.0.22",
78
+ "expo-router": "~57.0.21",
79
+ "react": "19.2.3",
80
+ "react-native": "0.86.3",
81
+ "react-native-builder-bob": "^0.43.0",
82
+ "storybook": "^10.6.0",
83
+ "typescript": "~7.0.2"
84
+ },
85
+ "publishConfig": {
86
+ "access": "public"
87
+ }
6
88
  }
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,46 @@
1
+ import MaterialIcons from '@expo/vector-icons/MaterialIcons'
2
+ import {type IconName, MATERIAL_ICONS, SF_SYMBOLS} from '@hzblj/kara-components'
3
+ import {NativeTabs} from 'expo-router/unstable-native-tabs'
4
+ import {Children, isValidElement} from 'react'
5
+ import type {SFSymbol} from 'sf-symbols-typescript'
6
+ import type {TabsComponent, TabsProps, TabsScreenProps} from './tabs.types'
7
+
8
+ const {Icon, Label, VectorIcon} = NativeTabs.Trigger
9
+
10
+ const sfSymbol = (name: IconName) => SF_SYMBOLS[name] as SFSymbol
11
+
12
+ const materialIcon = (name: IconName) => <VectorIcon family={MaterialIcons} name={MATERIAL_ICONS[name]} />
13
+
14
+ /**
15
+ * Declares one tab and renders nothing itself — `Tabs` reads these props and emits the trigger.
16
+ * The navigator collects its tabs by matching child element type exactly, so a component that
17
+ * merely *renders* a trigger is invisible to it and the tab bar comes up empty.
18
+ */
19
+ const TabsScreen: TabsComponent['Screen'] = () => null
20
+
21
+ const renderTrigger = ({hidden, icon, name, selectedIcon, testID, title}: TabsScreenProps) => (
22
+ <NativeTabs.Trigger hidden={hidden} key={name} name={name} testID={testID}>
23
+ <Icon
24
+ sf={selectedIcon ? {default: sfSymbol(icon), selected: sfSymbol(selectedIcon)} : sfSymbol(icon)}
25
+ src={selectedIcon ? {default: materialIcon(icon), selected: materialIcon(selectedIcon)} : materialIcon(icon)}
26
+ />
27
+ <Label>{title}</Label>
28
+ </NativeTabs.Trigger>
29
+ )
30
+
31
+ const TabsRoot = ({children, minimizeBehavior = 'never'}: TabsProps) => (
32
+ <NativeTabs minimizeBehavior={minimizeBehavior}>
33
+ {Children.map(children, child =>
34
+ isValidElement<TabsScreenProps>(child) && child.type === TabsScreen ? renderTrigger(child.props) : child
35
+ )}
36
+ </NativeTabs>
37
+ )
38
+
39
+ /**
40
+ * Native bottom tabs — a SwiftUI `TabView` on iOS and a Material navigation bar on Android, both
41
+ * driven by Expo Router's own navigator. Nothing here may come from the standalone
42
+ * `@react-navigation/*` packages: Expo Router 57 vendors its own fork of them, so a navigator built
43
+ * against the npm copies registers into a context tree that never exists and throws
44
+ * "Couldn't register the navigator" the moment this layout mounts.
45
+ */
46
+ export const Tabs: TabsComponent = Object.assign(TabsRoot, {Screen: TabsScreen})
@@ -0,0 +1,37 @@
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
+ icon: IconName
12
+ name: string
13
+ /** Swapped in while the tab is active — the filled/solid counterpart of `icon` on iOS. */
14
+ selectedIcon?: IconName
15
+ testID?: string
16
+ title: string
17
+ }
18
+
19
+ /** Maps to `UITabBarController.minimizeBehavior`. iOS 26+; ignored everywhere else. */
20
+ export type TabsMinimizeBehavior = 'automatic' | 'never' | 'onScrollDown' | 'onScrollUp'
21
+
22
+ export type TabsProps = {
23
+ children: ReactNode
24
+ /**
25
+ * Whether the iOS 26 tab bar shrinks to a pill while scrolling.
26
+ *
27
+ * Off by default, unlike the system, which minimizes on scroll. Minimizing changes the tab bar's
28
+ * height mid-gesture, and a native tabs screen re-insets the first scroll view inside it to match —
29
+ * so the content shifts under the finger every time the scroll direction changes, and every list in
30
+ * the app inherits that. Opt in per screen once the content is built to absorb it.
31
+ */
32
+ minimizeBehavior?: TabsMinimizeBehavior
33
+ }
34
+
35
+ export type TabsComponent = ((props: TabsProps) => ReactNode) & {
36
+ Screen: (props: TabsScreenProps) => ReactNode
37
+ }