@expokit/cli 0.4.1 → 0.4.3
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 +23 -2
- package/bin/expo-kit.js +26 -3
- package/bin/lib.mjs +14 -0
- package/bin/liquidglass.mjs +281 -0
- package/package.json +17 -3
- package/registry/_docs/liquidglass-tab.json +1 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +3 -94
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +2 -1
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +2 -2
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +1 -0
- package/registry/liquidglass-tab/files/src/components/LiquidGlassTabs.tsx +110 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -6
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +12 -4
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +6 -3
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +4 -2
package/README.md
CHANGED
|
@@ -9,6 +9,23 @@
|
|
|
9
9
|
The CLI for **[ExpoKit](https://expokit.in)** — animated UI components for Expo and React Native. It copies
|
|
10
10
|
the component source into your app (like shadcn/ui), so you own the code.
|
|
11
11
|
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://expokit.in/showcase"><img src="https://expokit.in/readme/charts.gif" alt="ExpoKit charts on iPhone: scrubbing an area chart and a bar chart, dragging a gauge, tapping through a donut" width="720"></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://expokit.in/docs/components"><img src="https://expokit.in/readme/screens.jpg" alt="ExpoKit on iPhone in dark and light mode: payment card, flip card, dashboard block and checkout block" width="720"></a>
|
|
18
|
+
<br>
|
|
19
|
+
<sub>Payment card and flip card are free; the dashboard and checkout screens are <a href="https://expokit.in/pricing">Pro</a> blocks.</sub>
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
- **Buttons, inputs, cards, sheets, tabs, charts and full screens**, all animated, with light and dark mode
|
|
23
|
+
- **Built for touch**: springs, drag gestures and haptics, on iOS, Android, web and in Expo Go
|
|
24
|
+
- **You own the code**: plain typed files in your app, with no runtime library to lock you in
|
|
25
|
+
|
|
26
|
+
**[Browse every component with live previews →](https://expokit.in/docs/components)** ·
|
|
27
|
+
**[Try them on your phone in Expo Go →](https://expokit.in/docs/run-on-your-phone)**
|
|
28
|
+
|
|
12
29
|
## Usage
|
|
13
30
|
|
|
14
31
|
In your Expo app's folder:
|
|
@@ -33,12 +50,16 @@ Everything else — live previews, props, installation, theming, Pro, AI / MCP s
|
|
|
33
50
|
|
|
34
51
|
## Security
|
|
35
52
|
|
|
36
|
-
- **No dependencies, no install scripts
|
|
53
|
+
- **No dependencies, no install scripts.**
|
|
54
|
+
- **Anonymous install counts only** — after a successful `add`, the CLI sends the item's name (`{"items":["button"]}`)
|
|
55
|
+
to expokit.in so the site can show how often each component is installed. No token, files, paths or machine details;
|
|
56
|
+
repeat adds count once a day. Turn it off with `DO_NOT_TRACK=1` or `EXPO_KIT_TELEMETRY=0`; it never runs in CI,
|
|
57
|
+
`--dry-run` or `update`.
|
|
37
58
|
- **Writes only inside your app** — `components.json`, the `@/*` alias in `tsconfig.json` and the component files.
|
|
38
59
|
`--dry-run` shows every change first; `add` never overwrites a file you changed unless you pass `--force`.
|
|
39
60
|
`paths` in `components.json` that point outside the app are refused. Symlinks inside the app are followed, not
|
|
40
61
|
checked, so a cloned repo that symlinks a folder elsewhere can still send writes there.
|
|
41
|
-
- **Free components need no network** — they ship inside this package. Pro components are downloaded over HTTPS from
|
|
62
|
+
- **Free components need no network to install** — they ship inside this package. Pro components are downloaded over HTTPS from
|
|
42
63
|
expokit.in; your token is stored readable only by you, sent only to that site and never through a redirect.
|
|
43
64
|
In scripts, pipe it to `login` or set `EXPO_KIT_TOKEN` (`--token` leaves it in your shell history).
|
|
44
65
|
- **Runs one command:** `npx expo install` for the packages a component needs — without `EXPO_KIT_TOKEN` in its
|
package/bin/expo-kit.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
retryDelayMs,
|
|
35
35
|
retryHint,
|
|
36
36
|
tabComponentName,
|
|
37
|
+
telemetryEnabled,
|
|
37
38
|
needsDevBuild,
|
|
38
39
|
pickMainExport,
|
|
39
40
|
} from './lib.mjs';
|
|
@@ -942,12 +943,33 @@ function cmdInit(cwd, flags, dir) {
|
|
|
942
943
|
log(`Done. Add items with: ${RUN} add <name>`);
|
|
943
944
|
}
|
|
944
945
|
|
|
945
|
-
|
|
946
|
+
/**
|
|
947
|
+
* After a successful add: tells the site which item was installed — just its name, no token or project
|
|
948
|
+
* data (README "Privacy"). Off with DO_NOT_TRACK=1 / EXPO_KIT_TELEMETRY=0 and in CI. Never fails the add.
|
|
949
|
+
*/
|
|
950
|
+
async function reportInstalled(name) {
|
|
951
|
+
if (!telemetryEnabled(process.env)) return;
|
|
952
|
+
try {
|
|
953
|
+
parseSiteUrl(SITE_URL);
|
|
954
|
+
const res = await fetch(`${SITE_URL}/api/cli/installed`, {
|
|
955
|
+
method: 'POST',
|
|
956
|
+
headers: { 'content-type': 'application/json' },
|
|
957
|
+
body: JSON.stringify({ items: [name] }),
|
|
958
|
+
signal: AbortSignal.timeout(1500),
|
|
959
|
+
});
|
|
960
|
+
await res.arrayBuffer();
|
|
961
|
+
} catch {
|
|
962
|
+
// offline, slow or blocked: the count just misses this one
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** `report: false` for re-adds that aren't new installs (`update --force`). */
|
|
967
|
+
async function cmdAdd(args, cwd, { report = true } = {}) {
|
|
946
968
|
const { flags, positional } = parseFlags(args);
|
|
947
969
|
// `add a b c` → add each in turn with the same flags.
|
|
948
970
|
if (positional.length > 1 && !positional.includes('--list')) {
|
|
949
971
|
const flagArgs = args.filter((a) => a.startsWith('--'));
|
|
950
|
-
for (const item of positional) await cmdAdd([item, ...flagArgs], cwd);
|
|
972
|
+
for (const item of positional) await cmdAdd([item, ...flagArgs], cwd, { report });
|
|
951
973
|
return;
|
|
952
974
|
}
|
|
953
975
|
const name = positional[0];
|
|
@@ -1049,6 +1071,7 @@ async function cmdAdd(args, cwd) {
|
|
|
1049
1071
|
const depsOk = runExpoInstall(projectRoot, meta.dependencies ?? [], false);
|
|
1050
1072
|
|
|
1051
1073
|
if (depsOk) markTemplateInstalled(projectRoot, config, resolved);
|
|
1074
|
+
if (report) await reportInstalled(resolved);
|
|
1052
1075
|
|
|
1053
1076
|
console.log('');
|
|
1054
1077
|
if (resolved === 'liquidglass-tab') {
|
|
@@ -1092,7 +1115,7 @@ async function cmdUpdate(args, cwd) {
|
|
|
1092
1115
|
if (flags.force) {
|
|
1093
1116
|
for (const resolved of targets) {
|
|
1094
1117
|
log(`Full overwrite ${resolved} (--force${flags.dryRun ? ', dry run' : ''})…\n`);
|
|
1095
|
-
await cmdAdd([resolved, '--force', ...(flags.dryRun ? ['--dry-run'] : [])], cwd);
|
|
1118
|
+
await cmdAdd([resolved, '--force', ...(flags.dryRun ? ['--dry-run'] : [])], cwd, { report: false });
|
|
1096
1119
|
}
|
|
1097
1120
|
return;
|
|
1098
1121
|
}
|
package/bin/lib.mjs
CHANGED
|
@@ -318,6 +318,7 @@ export function kitVersion(text) {
|
|
|
318
318
|
*/
|
|
319
319
|
export const EXPO_GO_PACKAGES = new Set([
|
|
320
320
|
'@expo/vector-icons',
|
|
321
|
+
'@react-native-async-storage/async-storage',
|
|
321
322
|
'@shopify/react-native-skia',
|
|
322
323
|
'expo-blur',
|
|
323
324
|
'expo-charts',
|
|
@@ -325,6 +326,7 @@ export const EXPO_GO_PACKAGES = new Set([
|
|
|
325
326
|
'expo-haptics',
|
|
326
327
|
'expo-router',
|
|
327
328
|
'expo-splash-screen',
|
|
329
|
+
'expo-status-bar',
|
|
328
330
|
'react-native-gesture-handler',
|
|
329
331
|
'react-native-reanimated',
|
|
330
332
|
'react-native-safe-area-context',
|
|
@@ -366,6 +368,18 @@ export function mergeDependencies(...lists) {
|
|
|
366
368
|
|
|
367
369
|
export const DEFAULT_SITE_URL = 'https://expokit.in';
|
|
368
370
|
|
|
371
|
+
const OFF = /^(0|false|off|no)$/i;
|
|
372
|
+
const set = (v) => v !== undefined && v !== '' && !OFF.test(v);
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Anonymous install counts (README "Privacy"): on unless DO_NOT_TRACK is set, EXPO_KIT_TELEMETRY is
|
|
376
|
+
* 0/false/off/no, or it's a CI run (builds re-adding items aren't new installs).
|
|
377
|
+
*/
|
|
378
|
+
export function telemetryEnabled(env) {
|
|
379
|
+
if (set(env.DO_NOT_TRACK) || set(env.CI)) return false;
|
|
380
|
+
return !(env.EXPO_KIT_TELEMETRY !== undefined && OFF.test(env.EXPO_KIT_TELEMETRY));
|
|
381
|
+
}
|
|
382
|
+
|
|
369
383
|
export function isLoopbackHost(hostname) {
|
|
370
384
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
|
|
371
385
|
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the liquidglass-tab template (no I/O): which Expo Router API shape to write for
|
|
3
|
+
* the installed SDK, starter-app detection, and tabs.ts generation for an existing app.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CliError } from './lib.mjs';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Expo SDK → API shape
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** Newest SDK this CLI knows the API of; also assumed when neither expo nor expo-router is installed. */
|
|
13
|
+
export const LATEST_KNOWN_SDK = 58;
|
|
14
|
+
export const MIN_SDK = 54;
|
|
15
|
+
|
|
16
|
+
/** "~6.0.24", "^54.0.0", "6.0.24", "54" → [6, 0, 24]; "latest", "*", "file:…" → null. */
|
|
17
|
+
export function parseVersion(spec) {
|
|
18
|
+
const m = /^(?:[\^~]|>=?|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(spec ?? '').trim());
|
|
19
|
+
return m ? [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)] : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** expo-router majors before it switched to SDK numbering (55.x = SDK 55). */
|
|
23
|
+
const ROUTER_TO_SDK = { 6: 54, 5: 53, 4: 52, 3: 50, 2: 49 };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to write for an installed expo / expo-router (versions or package.json ranges):
|
|
27
|
+
* SDK 54 NativeTabs + standalone Icon/Label/Badge/VectorIcon; Tabs from 'expo-router'
|
|
28
|
+
* SDK 55 NativeTabs.Trigger.*; Tabs from 'expo-router'
|
|
29
|
+
* SDK 56–57 NativeTabs.Trigger.*; Tabs from 'expo-router/js-tabs'; themes from 'expo-router'
|
|
30
|
+
* SDK 58+ as 56–57, NativeTabs from 'expo-router/native-tabs'
|
|
31
|
+
*/
|
|
32
|
+
export function routerTarget({ router, expo } = {}) {
|
|
33
|
+
const r = parseVersion(router);
|
|
34
|
+
const e = parseVersion(expo);
|
|
35
|
+
let sdk = null;
|
|
36
|
+
let source = null;
|
|
37
|
+
if (r) {
|
|
38
|
+
sdk = r[0] >= 50 ? r[0] : (ROUTER_TO_SDK[r[0]] ?? (r[0] < 2 ? 48 : null));
|
|
39
|
+
source = `expo-router ${r.join('.')}`;
|
|
40
|
+
}
|
|
41
|
+
if (sdk === null && e) {
|
|
42
|
+
sdk = e[0];
|
|
43
|
+
source = `expo ${e.join('.')}`;
|
|
44
|
+
}
|
|
45
|
+
const assumed = sdk === null;
|
|
46
|
+
if (assumed) sdk = LATEST_KNOWN_SDK;
|
|
47
|
+
if (sdk < MIN_SDK) return { sdk, source, unsupported: true };
|
|
48
|
+
const legacyNav = sdk <= 55;
|
|
49
|
+
return {
|
|
50
|
+
sdk,
|
|
51
|
+
source: source ?? `assumed SDK ${sdk}`,
|
|
52
|
+
assumed,
|
|
53
|
+
icons: sdk === 54 ? 'standalone' : 'trigger',
|
|
54
|
+
nativeTabs: sdk >= 58 ? 'expo-router/native-tabs' : 'expo-router/unstable-native-tabs',
|
|
55
|
+
tabs: legacyNav ? 'expo-router' : 'expo-router/js-tabs',
|
|
56
|
+
tabBarProps: legacyNav ? '@react-navigation/bottom-tabs' : 'expo-router/js-tabs',
|
|
57
|
+
themes: legacyNav ? '@react-navigation/native' : 'expo-router',
|
|
58
|
+
/** Imported directly on SDK 54–55 (expo-router depends on them; strict installers need them listed). */
|
|
59
|
+
deps: legacyNav ? ['@react-navigation/bottom-tabs', '@react-navigation/native'] : [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function replaceOnce(text, re, replacement, what) {
|
|
64
|
+
if (!re.test(text)) throw new CliError(`liquidglass-tab template changed shape (${what}) — update the CLI: npx @expokit/cli@latest`);
|
|
65
|
+
return text.replace(re, replacement);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Registry `src/`-relative paths in comments ("src/app/(tabs)", "src/lib/tabs.ts") → the project's folders. */
|
|
69
|
+
export function localizePaths(text, paths = {}) {
|
|
70
|
+
const map = { app: paths.app, components: paths.components, lib: paths.lib };
|
|
71
|
+
return text.replace(/\bsrc\/(app|components|lib)\//g, (all, folder) => (map[folder] ? `${map[folder].replace(/\/+$/, '')}/` : all));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A template file rewritten for `target` (from routerTarget). Files without SDK-specific imports come back unchanged. */
|
|
75
|
+
export function adaptLiquidGlassFile(rel, text, target) {
|
|
76
|
+
if (rel === 'components/LiquidGlassTabs.tsx') {
|
|
77
|
+
const standalone = target.icons === 'standalone';
|
|
78
|
+
const header =
|
|
79
|
+
`// Expo SDK ${target.sdk}${standalone ? ': standalone Icon / Label / Badge / VectorIcon (NativeTabs.Trigger.* is SDK 55+)' : ''}.\n` +
|
|
80
|
+
`// After upgrading Expo, run \`npx @expokit/cli@latest update\` to get the matching imports.\n` +
|
|
81
|
+
`import { Tabs } from '${target.tabs}';\n` +
|
|
82
|
+
(standalone
|
|
83
|
+
? `import { Badge, Icon, Label, NativeTabs, VectorIcon } from '${target.nativeTabs}';\n`
|
|
84
|
+
: `import { NativeTabs } from '${target.nativeTabs}';\n`);
|
|
85
|
+
let out = replaceOnce(
|
|
86
|
+
text,
|
|
87
|
+
/\/\/ Written for your Expo SDK[^\n]*\n\/\/[^\n]*\nimport \{ Tabs \} from 'expo-router\/js-tabs';\nimport \{ NativeTabs \} from 'expo-router\/unstable-native-tabs';\n/,
|
|
88
|
+
header,
|
|
89
|
+
'LiquidGlassTabs imports'
|
|
90
|
+
);
|
|
91
|
+
if (standalone) out = out.replace(/\bNativeTabs\.Trigger\.(Icon|Label|Badge|VectorIcon)\b/g, '$1');
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
if (rel === 'components/GlassTabBar.tsx') {
|
|
95
|
+
return replaceOnce(
|
|
96
|
+
text,
|
|
97
|
+
/\/\/ '@react-navigation\/bottom-tabs' on SDK[^\n]*\nimport type \{ BottomTabBarProps \} from 'expo-router\/js-tabs';\n/,
|
|
98
|
+
`import type { BottomTabBarProps } from '${target.tabBarProps}';\n`,
|
|
99
|
+
'GlassTabBar imports'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (rel === 'app/_layout.tsx') {
|
|
103
|
+
const imports =
|
|
104
|
+
target.themes === 'expo-router'
|
|
105
|
+
? "// SDK 56+: navigation themes come from expo-router itself. Using @react-navigation/native's\n" +
|
|
106
|
+
'// ThemeProvider here does nothing (expo-router ships its own copy) and causes a white flash.\n' +
|
|
107
|
+
"import { DarkTheme, DefaultTheme, Slot, ThemeProvider } from 'expo-router';\n"
|
|
108
|
+
: "import { Slot } from 'expo-router';\n" +
|
|
109
|
+
`// SDK ${target.sdk}: navigation themes come from React Navigation (expo-router re-exports them from SDK 56).\n` +
|
|
110
|
+
"import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';\n";
|
|
111
|
+
return replaceOnce(
|
|
112
|
+
text,
|
|
113
|
+
/\/\/ Navigation themes:[^\n]*\n\/\/[^\n]*\nimport \{ DarkTheme, DefaultTheme, Slot, ThemeProvider \} from 'expo-router';\n/,
|
|
114
|
+
imports,
|
|
115
|
+
'root layout imports'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Existing apps: routes → tabs.ts
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const ROUTE_EXT = /\.(tsx|ts|jsx|js)$/;
|
|
126
|
+
const PLATFORM_EXT = /\.(ios|android|web|native)$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tab names of a navigator from the entries of its folder (`{ name, dir }`): route files minus
|
|
130
|
+
* platform suffixes, and sub-folders. Skips layouts, `+not-found`-style special files and `_`/`.` names.
|
|
131
|
+
*/
|
|
132
|
+
export function routeNamesFromEntries(entries) {
|
|
133
|
+
const names = [];
|
|
134
|
+
for (const { name, dir } of entries) {
|
|
135
|
+
if (name.startsWith('.') || name.startsWith('_') || name.startsWith('+')) continue;
|
|
136
|
+
let route;
|
|
137
|
+
if (dir) route = name;
|
|
138
|
+
else {
|
|
139
|
+
if (!ROUTE_EXT.test(name) || name.endsWith('.d.ts')) continue;
|
|
140
|
+
route = name.replace(ROUTE_EXT, '').replace(PLATFORM_EXT, '');
|
|
141
|
+
}
|
|
142
|
+
if (!names.includes(route)) names.push(route);
|
|
143
|
+
}
|
|
144
|
+
return names;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** "index" → "Home", "my-cards" → "My cards", "[id]" → "Id". */
|
|
148
|
+
export function tabTitle(name) {
|
|
149
|
+
if (name === 'index') return 'Home';
|
|
150
|
+
const words = String(name).replace(/[[\]().]/g, ' ').split(/[\s_-]+/).filter(Boolean);
|
|
151
|
+
if (!words.length) return String(name);
|
|
152
|
+
const text = words.join(' ').toLowerCase();
|
|
153
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Tab order and titles from an existing layout: `<Tabs.Screen name="x" options={{ title: 'X' }}>` or
|
|
158
|
+
* `<NativeTabs.Trigger name="x"><…Label>X</…Label>`. Best effort — unknown shapes give [].
|
|
159
|
+
*/
|
|
160
|
+
export function readTabsFromLayout(text) {
|
|
161
|
+
const src = String(text ?? '');
|
|
162
|
+
const re = /<(Tabs\.Screen|NativeTabs\.Trigger)\b[^>]*?\bname=["'{]+([^"'}]+)["'}]+/g;
|
|
163
|
+
const hits = [...src.matchAll(re)];
|
|
164
|
+
return hits.map((m, i) => {
|
|
165
|
+
const end = i + 1 < hits.length ? hits[i + 1].index : src.length;
|
|
166
|
+
const block = src.slice(m.index, end);
|
|
167
|
+
const title =
|
|
168
|
+
/\btitle:\s*["']([^"']+)["']/.exec(block)?.[1] ??
|
|
169
|
+
/<(?:NativeTabs\.Trigger\.)?Label\b[^>]*>\s*([^<{]+?)\s*<\//.exec(block)?.[1] ??
|
|
170
|
+
null;
|
|
171
|
+
return { name: m[2], title };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** [match words, SF Symbol default/selected, Ionicons default/selected] — a sensible icon per common tab name. */
|
|
176
|
+
const ICON_GUESSES = [
|
|
177
|
+
[['index', 'home', 'feed'], 'house', 'house.fill', 'home-outline', 'home'],
|
|
178
|
+
[['explore', 'discover', 'browse'], 'safari', 'safari.fill', 'compass-outline', 'compass'],
|
|
179
|
+
[['search', 'find'], 'magnifyingglass', 'magnifyingglass', 'search-outline', 'search'],
|
|
180
|
+
[['profile', 'account', 'me', 'you', 'user'], 'person.crop.circle', 'person.crop.circle.fill', 'person-circle-outline', 'person-circle'],
|
|
181
|
+
[['settings', 'preferences'], 'gearshape', 'gearshape.fill', 'settings-outline', 'settings'],
|
|
182
|
+
[['alerts', 'notifications', 'activity'], 'bell', 'bell.fill', 'notifications-outline', 'notifications'],
|
|
183
|
+
[['messages', 'chat', 'chats', 'inbox'], 'bubble.left.and.bubble.right', 'bubble.left.and.bubble.right.fill', 'chatbubbles-outline', 'chatbubbles'],
|
|
184
|
+
[['cart', 'bag', 'shop', 'store', 'orders'], 'bag', 'bag.fill', 'bag-outline', 'bag'],
|
|
185
|
+
[['favorites', 'favourites', 'likes', 'saved'], 'heart', 'heart.fill', 'heart-outline', 'heart'],
|
|
186
|
+
[['cards', 'wallet', 'pay', 'payments'], 'creditcard', 'creditcard.fill', 'card-outline', 'card'],
|
|
187
|
+
[['calendar', 'schedule', 'events'], 'calendar', 'calendar', 'calendar-outline', 'calendar'],
|
|
188
|
+
[['library', 'books'], 'books.vertical', 'books.vertical.fill', 'library-outline', 'library'],
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
export function guessTabIcon(name) {
|
|
192
|
+
const key = String(name).toLowerCase().replace(/[^a-z]/g, '');
|
|
193
|
+
const hit = ICON_GUESSES.find(([words]) => words.includes(key)) ?? ICON_GUESSES.find(([words]) => words.some((w) => key.includes(w)));
|
|
194
|
+
const [, sf, sfSelected, ion, ionSelected] = hit ?? [null, 'circle', 'circle.fill', 'ellipse-outline', 'ellipse'];
|
|
195
|
+
return { sf, sfSelected, ion, ionSelected };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
199
|
+
|
|
200
|
+
/** One TABS entry (same layout as the template's, so parseTabNamesFromConfig reads it back). */
|
|
201
|
+
export function tabEntrySource({ name, title }) {
|
|
202
|
+
const icon = guessTabIcon(name);
|
|
203
|
+
const sf = icon.sf === icon.sfSelected ? q(icon.sf) : `{ default: ${q(icon.sf)}, selected: ${q(icon.sfSelected)} }`;
|
|
204
|
+
const lines = [
|
|
205
|
+
' {',
|
|
206
|
+
` name: ${q(name)},`,
|
|
207
|
+
` title: ${q(title ?? tabTitle(name))},`,
|
|
208
|
+
...(/^\[.*\]$/.test(name) ? [' hidden: true,'] : []),
|
|
209
|
+
` sf: ${sf},`,
|
|
210
|
+
` icon: { set: 'Ionicons', name: ${q(icon.ion)}, selected: ${q(icon.ionSelected)} },`,
|
|
211
|
+
' },',
|
|
212
|
+
];
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Tabs for an existing app: its route names, ordered and titled like its current layout when that
|
|
218
|
+
* layout names them, `index` first otherwise.
|
|
219
|
+
*/
|
|
220
|
+
export function tabsFromRoutes(routes, layoutTabs = []) {
|
|
221
|
+
const known = layoutTabs.filter((t) => routes.includes(t.name));
|
|
222
|
+
const rest = routes.filter((r) => !known.some((t) => t.name === r)).sort((a, b) => (a === 'index' ? -1 : b === 'index' ? 1 : a.localeCompare(b)));
|
|
223
|
+
return [...known.map((t) => ({ name: t.name, title: t.title ?? tabTitle(t.name) })), ...rest.map((name) => ({ name, title: tabTitle(name) }))];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The template's tabs.ts with its TABS list replaced by `tabs`. */
|
|
227
|
+
export function replaceTabsList(tabsTs, tabs) {
|
|
228
|
+
const body = tabs.map(tabEntrySource).join('\n');
|
|
229
|
+
return replaceOnce(tabsTs, /export const TABS: TabItem\[\] = \[[\s\S]*?\n\];/, `export const TABS: TabItem[] = [\n${body}\n];`, 'tabs.ts TABS');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// create-expo-app starters (safe to replace: nothing of the user's in them yet)
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/** SDK 54 starter app/(tabs)/_layout.tsx: JS Tabs with HapticTab + IconSymbol. */
|
|
237
|
+
export function isStarterTabsLayout(text) {
|
|
238
|
+
const s = String(text ?? '');
|
|
239
|
+
return /from ['"]@\/components\/haptic-?tab['"]/i.test(s) && /IconSymbol/.test(s) && !/Redirect|useAuth|useSession/.test(s);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** SDK 55+ starter components/app-tabs(.web).tsx: the starter's two tabs (index, explore). */
|
|
243
|
+
export function isStarterAppTabs(text) {
|
|
244
|
+
const s = String(text ?? '');
|
|
245
|
+
if (/Redirect|useAuth|useSession/.test(s)) return false;
|
|
246
|
+
const native = /tabIcons\/home\.png/.test(s) && /tabIcons\/explore\.png/.test(s);
|
|
247
|
+
const web = /from ['"]expo-router\/ui['"]/.test(s) && /TabList/.test(s) && /href=["']\/explore["']/.test(s);
|
|
248
|
+
return native || web;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** create-expo-app root layouts (SDK 54: Stack anchored on (tabs) + modal; SDK 55+: AnimatedSplashOverlay + AppTabs). */
|
|
252
|
+
export function isStarterRootLayout(text) {
|
|
253
|
+
const s = String(text ?? '');
|
|
254
|
+
const sdk54 = /anchor:\s*['"]\(tabs\)['"]/.test(s) && /name=["']modal["']/.test(s);
|
|
255
|
+
const sdk55 = /AnimatedSplashOverlay/.test(s) && /AppTabs/.test(s);
|
|
256
|
+
const providers = [...s.matchAll(/<(\w+Provider)\b/g)].map((m) => m[1]).filter((p) => p !== 'ThemeProvider');
|
|
257
|
+
return (sdk54 || sdk55) && !providers.length && !/Redirect/.test(s);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Files the template ships that a later `update` may refresh: anything that names its building blocks. */
|
|
261
|
+
export function isLiquidGlassSource(text) {
|
|
262
|
+
return /\b(USE_NATIVE_TABS|LiquidGlassTabs|GlassTabBar|TabScreenFromConfig|tabBarCollapse|asFamily|TAB_BAR)\b/.test(String(text ?? ''));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// package.json diff (what `npx expo install` changed)
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/** `{ added: [[name, version]], changed: [[name, from, to]] }` between two package.json objects. */
|
|
270
|
+
export function diffDependencies(before = {}, after = {}) {
|
|
271
|
+
const all = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
|
|
272
|
+
const a = all(before);
|
|
273
|
+
const b = all(after);
|
|
274
|
+
const added = [];
|
|
275
|
+
const changed = [];
|
|
276
|
+
for (const [name, version] of Object.entries(b)) {
|
|
277
|
+
if (!(name in a)) added.push([name, version]);
|
|
278
|
+
else if (a[name] !== version) changed.push([name, a[name], version]);
|
|
279
|
+
}
|
|
280
|
+
return { added, changed };
|
|
281
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expokit/cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"mcpName": "io.github.Anshkaran7/expokit",
|
|
5
|
+
"description": "Animated UI components for Expo and React Native — a shadcn-style CLI that copies buttons, inputs, cards, charts and full screens into your app",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"bin": {
|
|
@@ -17,9 +18,22 @@
|
|
|
17
18
|
"keywords": [
|
|
18
19
|
"expo",
|
|
19
20
|
"react-native",
|
|
20
|
-
"
|
|
21
|
+
"react-native-ui",
|
|
22
|
+
"ui-components",
|
|
23
|
+
"components",
|
|
24
|
+
"component-library",
|
|
25
|
+
"ui-kit",
|
|
21
26
|
"ui",
|
|
27
|
+
"design-system",
|
|
28
|
+
"animation",
|
|
29
|
+
"animated",
|
|
30
|
+
"haptics",
|
|
31
|
+
"dark-mode",
|
|
32
|
+
"ios",
|
|
33
|
+
"android",
|
|
22
34
|
"shadcn",
|
|
35
|
+
"shadcn-ui",
|
|
36
|
+
"cli",
|
|
23
37
|
"liquid-glass",
|
|
24
38
|
"native-tabs",
|
|
25
39
|
"expo-router"
|
|
@@ -1,97 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { DynamicColorIOS, Platform } from 'react-native';
|
|
3
|
-
import { Tabs } from 'expo-router/js-tabs';
|
|
4
|
-
// SDK 54–57 path. On SDK 58+ change this to 'expo-router/native-tabs'.
|
|
5
|
-
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
|
6
|
-
|
|
7
|
-
import { GlassTabBar } from '@/components/GlassTabBar';
|
|
8
|
-
import { USE_NATIVE_TABS } from '@/lib/tab-bar';
|
|
9
|
-
import {
|
|
10
|
-
asFamily,
|
|
11
|
-
resolveIconLibrary,
|
|
12
|
-
resolveIconNames,
|
|
13
|
-
TAB_BAR,
|
|
14
|
-
TABS,
|
|
15
|
-
type TabItem,
|
|
16
|
-
} from '@/lib/tabs';
|
|
17
|
-
|
|
18
|
-
type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
|
|
19
|
-
|
|
20
|
-
function dynamicColor(pair: { light: string; dark: string }) {
|
|
21
|
-
return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
|
|
25
|
-
function vectorSrc(tab: TabItem) {
|
|
26
|
-
const library = resolveIconLibrary(tab);
|
|
27
|
-
const { name, selected } = resolveIconNames(tab);
|
|
28
|
-
if (!library || !name) return undefined;
|
|
29
|
-
|
|
30
|
-
const family = asFamily(library);
|
|
31
|
-
const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
|
|
32
|
-
if (!selected) return icon;
|
|
33
|
-
return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Returns the Icon element itself (not a wrapper component): NativeTabs reads its
|
|
38
|
-
* children by type, so <NativeTabs.Trigger.Icon> must be a direct child of the Trigger.
|
|
39
|
-
*/
|
|
40
|
-
function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
|
|
41
|
-
const src = tab.src ?? vectorSrc(tab);
|
|
42
|
-
if (!tab.sf && !src) return null;
|
|
43
|
-
// sf wins on iOS, src is used on Android.
|
|
44
|
-
const props = { sf: tab.sf, src, selectedColor: tint } as IconProps;
|
|
45
|
-
return <NativeTabs.Trigger.Icon {...props} />;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function NativeTabLayout() {
|
|
49
|
-
const tint = dynamicColor(TAB_BAR.colors.tint);
|
|
50
|
-
const inactive = dynamicColor(TAB_BAR.colors.inactive);
|
|
51
|
-
const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<NativeTabs
|
|
55
|
-
tintColor={tint}
|
|
56
|
-
iconColor={inactive}
|
|
57
|
-
labelVisibilityMode={TAB_BAR.labelVisibilityMode}
|
|
58
|
-
minimizeBehavior={TAB_BAR.minimizeBehavior}
|
|
59
|
-
labelStyle={{ color: inactive, fontSize, fontWeight }}
|
|
60
|
-
{...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
|
|
61
|
-
>
|
|
62
|
-
{TABS.map((tab) => (
|
|
63
|
-
<NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
|
|
64
|
-
{nativeIcon(tab, tint)}
|
|
65
|
-
<NativeTabs.Trigger.Label
|
|
66
|
-
hidden={tab.hiddenLabel}
|
|
67
|
-
selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
|
|
68
|
-
>
|
|
69
|
-
{tab.title}
|
|
70
|
-
</NativeTabs.Trigger.Label>
|
|
71
|
-
{tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
|
|
72
|
-
</NativeTabs.Trigger>
|
|
73
|
-
))}
|
|
74
|
-
</NativeTabs>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function GlassTabLayout() {
|
|
79
|
-
return (
|
|
80
|
-
<Tabs
|
|
81
|
-
tabBar={(props) => <GlassTabBar {...props} />}
|
|
82
|
-
screenOptions={{ headerShown: false, animation: 'fade' }}
|
|
83
|
-
>
|
|
84
|
-
{TABS.map((tab) => (
|
|
85
|
-
<Tabs.Screen
|
|
86
|
-
key={tab.name}
|
|
87
|
-
name={tab.name}
|
|
88
|
-
options={{ title: tab.title, href: tab.hidden ? null : undefined }}
|
|
89
|
-
/>
|
|
90
|
-
))}
|
|
91
|
-
</Tabs>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
1
|
+
import { LiquidGlassTabs } from '@/components/LiquidGlassTabs';
|
|
94
2
|
|
|
3
|
+
// Tabs, icons and colours live in lib/tabs.ts. Auth redirects etc. go here, before the return.
|
|
95
4
|
export default function TabLayout() {
|
|
96
|
-
return
|
|
5
|
+
return <LiquidGlassTabs />;
|
|
97
6
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TabScreenFromConfig } from '@/components/TabScreen';
|
|
2
2
|
|
|
3
3
|
export default function AlertsScreen() {
|
|
4
|
-
|
|
4
|
+
// The button is pinned with useTabBarInset(), so it clears the floating bar on every OS.
|
|
5
|
+
return <TabScreenFromConfig tabName="alerts" action="Mark all as read" />;
|
|
5
6
|
}
|
|
@@ -3,8 +3,8 @@ import React, { useEffect } from 'react';
|
|
|
3
3
|
import { StyleSheet, useColorScheme } from 'react-native';
|
|
4
4
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
5
5
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
6
|
-
//
|
|
7
|
-
// ThemeProvider
|
|
6
|
+
// Navigation themes: from 'expo-router' on SDK 56+ (it ships its own copy, so the
|
|
7
|
+
// @react-navigation/native ThemeProvider would do nothing there), '@react-navigation/native' on SDK 54–55.
|
|
8
8
|
import { DarkTheme, DefaultTheme, Slot, ThemeProvider } from 'expo-router';
|
|
9
9
|
import * as SplashScreen from 'expo-splash-screen';
|
|
10
10
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from 'react-native';
|
|
15
15
|
import { BlurView } from 'expo-blur';
|
|
16
16
|
import * as Haptics from 'expo-haptics';
|
|
17
|
+
// '@react-navigation/bottom-tabs' on SDK 54–55 (the CLI writes the right one).
|
|
17
18
|
import type { BottomTabBarProps } from 'expo-router/js-tabs';
|
|
18
19
|
|
|
19
20
|
import { GLASS_BAR_HEIGHT, setTabBarCollapsed, tabBarCollapse } from '@/lib/tab-bar';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { DynamicColorIOS, Platform } from 'react-native';
|
|
3
|
+
// Written for your Expo SDK by `npx @expokit/cli add liquidglass-tab` (SDK 54–55: Tabs from
|
|
4
|
+
// 'expo-router'; SDK 58+: NativeTabs from 'expo-router/native-tabs'). `update` keeps it in sync.
|
|
5
|
+
import { Tabs } from 'expo-router/js-tabs';
|
|
6
|
+
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
|
7
|
+
|
|
8
|
+
import { GlassTabBar } from '@/components/GlassTabBar';
|
|
9
|
+
import { USE_NATIVE_TABS } from '@/lib/tab-bar';
|
|
10
|
+
import {
|
|
11
|
+
asFamily,
|
|
12
|
+
resolveIconLibrary,
|
|
13
|
+
resolveIconNames,
|
|
14
|
+
TAB_BAR,
|
|
15
|
+
TABS,
|
|
16
|
+
type TabItem,
|
|
17
|
+
} from '@/lib/tabs';
|
|
18
|
+
|
|
19
|
+
type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
|
|
20
|
+
|
|
21
|
+
function dynamicColor(pair: { light: string; dark: string }) {
|
|
22
|
+
return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
|
|
26
|
+
function vectorSrc(tab: TabItem) {
|
|
27
|
+
const library = resolveIconLibrary(tab);
|
|
28
|
+
const { name, selected } = resolveIconNames(tab);
|
|
29
|
+
if (!library || !name) return undefined;
|
|
30
|
+
|
|
31
|
+
const family = asFamily(library);
|
|
32
|
+
const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
|
|
33
|
+
if (!selected) return icon;
|
|
34
|
+
return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the Icon element itself (not a wrapper component): NativeTabs reads its
|
|
39
|
+
* children by type, so the Icon must be a direct child of the Trigger.
|
|
40
|
+
*/
|
|
41
|
+
function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
|
|
42
|
+
// SF Symbol on iOS when set; otherwise the image / vector icon (always on Android).
|
|
43
|
+
if (Platform.OS === 'ios' && tab.sf) {
|
|
44
|
+
return <NativeTabs.Trigger.Icon {...({ sf: tab.sf, selectedColor: tint } as IconProps)} />;
|
|
45
|
+
}
|
|
46
|
+
const src = tab.src ?? vectorSrc(tab);
|
|
47
|
+
if (!src) return null;
|
|
48
|
+
return <NativeTabs.Trigger.Icon {...({ src, selectedColor: tint } as IconProps)} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function NativeTabLayout() {
|
|
52
|
+
const tint = dynamicColor(TAB_BAR.colors.tint);
|
|
53
|
+
const inactive = dynamicColor(TAB_BAR.colors.inactive);
|
|
54
|
+
const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<NativeTabs
|
|
58
|
+
tintColor={tint}
|
|
59
|
+
iconColor={inactive}
|
|
60
|
+
labelVisibilityMode={TAB_BAR.labelVisibilityMode}
|
|
61
|
+
minimizeBehavior={TAB_BAR.minimizeBehavior}
|
|
62
|
+
labelStyle={{ color: inactive, fontSize, fontWeight }}
|
|
63
|
+
{...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
|
|
64
|
+
>
|
|
65
|
+
{TABS.map((tab) => (
|
|
66
|
+
<NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
|
|
67
|
+
{nativeIcon(tab, tint)}
|
|
68
|
+
<NativeTabs.Trigger.Label
|
|
69
|
+
hidden={tab.hiddenLabel}
|
|
70
|
+
selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
|
|
71
|
+
>
|
|
72
|
+
{tab.title}
|
|
73
|
+
</NativeTabs.Trigger.Label>
|
|
74
|
+
{tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
|
|
75
|
+
</NativeTabs.Trigger>
|
|
76
|
+
))}
|
|
77
|
+
</NativeTabs>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function GlassTabLayout() {
|
|
82
|
+
return (
|
|
83
|
+
<Tabs
|
|
84
|
+
tabBar={(props) => <GlassTabBar {...props} />}
|
|
85
|
+
screenOptions={{ headerShown: false, animation: 'fade' }}
|
|
86
|
+
>
|
|
87
|
+
{TABS.map((tab) => (
|
|
88
|
+
<Tabs.Screen
|
|
89
|
+
key={tab.name}
|
|
90
|
+
name={tab.name}
|
|
91
|
+
options={{ title: tab.title, href: tab.hidden ? null : undefined }}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</Tabs>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The whole tab bar, driven by lib/tabs.ts: system NativeTabs with real Liquid Glass on
|
|
100
|
+
* iOS 26+, the floating glass fallback everywhere else. Render it from the layout that owns
|
|
101
|
+
* your tab routes — app/(tabs)/_layout.tsx — after any redirects:
|
|
102
|
+
*
|
|
103
|
+
* export default function TabLayout() {
|
|
104
|
+
* if (!signedIn) return <Redirect href="/sign-in" />;
|
|
105
|
+
* return <LiquidGlassTabs />;
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
export function LiquidGlassTabs() {
|
|
109
|
+
return USE_NATIVE_TABS ? <NativeTabLayout /> : <GlassTabLayout />;
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import {
|
|
3
|
+
Pressable,
|
|
3
4
|
ScrollView,
|
|
4
5
|
StyleSheet,
|
|
5
6
|
Text,
|
|
@@ -15,13 +16,17 @@ interface TabScreenProps {
|
|
|
15
16
|
title: string;
|
|
16
17
|
description: string;
|
|
17
18
|
accent: string;
|
|
19
|
+
/** Label of a button pinned to the bottom of the screen, above the tab bar. */
|
|
20
|
+
action?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
23
|
+
export function TabScreen({ title, description, accent, action }: TabScreenProps) {
|
|
21
24
|
const isDark = useColorScheme() === 'dark';
|
|
22
25
|
const insets = useSafeAreaInsets();
|
|
23
26
|
const onScroll = useTabBarScroll();
|
|
24
|
-
const bottomInset = useTabBarInset();
|
|
27
|
+
const bottomInset = useTabBarInset(24);
|
|
28
|
+
// Bottom-anchored UI sits on top of the bar's footprint, not on the screen edge.
|
|
29
|
+
const actionBottom = useTabBarInset(12);
|
|
25
30
|
const bg = isDark ? '#0f0f1a' : '#f2f2f7';
|
|
26
31
|
const text = isDark ? '#fff' : '#1c1c1e';
|
|
27
32
|
const sub = isDark ? '#9898a8' : '#6c6c70';
|
|
@@ -32,11 +37,11 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
|
32
37
|
style={{ flex: 1, backgroundColor: bg }}
|
|
33
38
|
contentContainerStyle={[
|
|
34
39
|
styles.scroll,
|
|
35
|
-
{ paddingTop:
|
|
40
|
+
{ paddingTop: insets.top + 12, paddingBottom: bottomInset },
|
|
36
41
|
]}
|
|
37
42
|
showsVerticalScrollIndicator={false}
|
|
38
|
-
//
|
|
39
|
-
contentInsetAdjustmentBehavior=
|
|
43
|
+
// Pad by hand on every bar: useTabBarInset() already counts the floating bar and home indicator.
|
|
44
|
+
contentInsetAdjustmentBehavior="never"
|
|
40
45
|
scrollEventThrottle={16}
|
|
41
46
|
onScroll={onScroll}
|
|
42
47
|
>
|
|
@@ -64,12 +69,21 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
|
64
69
|
</Text>
|
|
65
70
|
</View>
|
|
66
71
|
))}
|
|
72
|
+
{action ? <View style={{ height: 64 }} /> : null}
|
|
67
73
|
</ScrollView>
|
|
74
|
+
{action ? (
|
|
75
|
+
<Pressable
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
style={({ pressed }) => [styles.action, { bottom: actionBottom, backgroundColor: accent, opacity: pressed ? 0.85 : 1 }]}
|
|
78
|
+
>
|
|
79
|
+
<Text style={styles.actionText}>{action}</Text>
|
|
80
|
+
</Pressable>
|
|
81
|
+
) : null}
|
|
68
82
|
</View>
|
|
69
83
|
);
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
export function TabScreenFromConfig({ tabName }: { tabName: string }) {
|
|
86
|
+
export function TabScreenFromConfig({ tabName, action }: { tabName: string; action?: string }) {
|
|
73
87
|
const tab = getTab(tabName);
|
|
74
88
|
if (!tab) {
|
|
75
89
|
return null;
|
|
@@ -83,6 +97,7 @@ export function TabScreenFromConfig({ tabName }: { tabName: string }) {
|
|
|
83
97
|
'Scroll to test native tab bar minimize behavior on iOS 26+.'
|
|
84
98
|
}
|
|
85
99
|
accent={tab.accent ?? '#6366f1'}
|
|
100
|
+
action={action}
|
|
86
101
|
/>
|
|
87
102
|
);
|
|
88
103
|
}
|
|
@@ -119,4 +134,14 @@ const styles = StyleSheet.create({
|
|
|
119
134
|
},
|
|
120
135
|
cardTitle: { fontSize: 17, fontWeight: '400' },
|
|
121
136
|
cardSub: { fontSize: 14, marginTop: 4, lineHeight: 20 },
|
|
137
|
+
action: {
|
|
138
|
+
position: 'absolute',
|
|
139
|
+
left: 20,
|
|
140
|
+
right: 20,
|
|
141
|
+
height: 50,
|
|
142
|
+
borderRadius: 25,
|
|
143
|
+
alignItems: 'center',
|
|
144
|
+
justifyContent: 'center',
|
|
145
|
+
},
|
|
146
|
+
actionText: { color: '#fff', fontSize: 16, fontWeight: '500' },
|
|
122
147
|
});
|
|
@@ -53,9 +53,17 @@ export function useTabBarScroll() {
|
|
|
53
53
|
}, []);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* How far the tab bar covers the bottom of a screen, plus `gap`. Both bars float over the
|
|
58
|
+
* content — iOS 26's Liquid Glass bar and the glass fallback — so use it for anything that
|
|
59
|
+
* reaches the bottom edge: a ScrollView's paddingBottom (with
|
|
60
|
+
* contentInsetAdjustmentBehavior="never"), a bottom-anchored button's `bottom`, or the
|
|
61
|
+
* paddingBottom of a full-screen layout. Android's system bar (TAB_BAR.legacy = 'native')
|
|
62
|
+
* sits below the content, so there it is just `gap`.
|
|
63
|
+
*/
|
|
64
|
+
export function useTabBarInset(gap = 8) {
|
|
58
65
|
const insets = useSafeAreaInsets();
|
|
59
|
-
if (USE_NATIVE_TABS) return
|
|
60
|
-
|
|
66
|
+
if (USE_NATIVE_TABS && Platform.OS === 'android') return gap;
|
|
67
|
+
// The system bar has no height API; it sits in the same spot as the fallback pill.
|
|
68
|
+
return GLASS_BAR_HEIGHT + Math.max(insets.bottom, 12) + gap;
|
|
61
69
|
}
|
|
@@ -19,7 +19,8 @@ import type { ColorValue, ImageSourcePropType } from 'react-native';
|
|
|
19
19
|
* Preset sets (no import needed): Ionicons, Feather, FontAwesome, …
|
|
20
20
|
* Custom font: iconFont() / icoMoon()
|
|
21
21
|
* Custom module: library + icon on TabItem
|
|
22
|
-
* PNG: src on TabItem
|
|
22
|
+
* PNG: src on TabItem — SVG kits (Hugeicons, Lucide, Tabler) → PNGs with
|
|
23
|
+
* npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
export type IconSetName =
|
|
@@ -85,8 +86,10 @@ export function asFamily(mod: unknown): VectorIconFamily {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
throw new Error(
|
|
88
|
-
'[expo-kit] This icon package has no getImageSource
|
|
89
|
-
'
|
|
89
|
+
'[expo-kit] This icon package has no getImageSource — SVG kits (Hugeicons, Lucide, Tabler) can not be drawn by the native tab bar.\n' +
|
|
90
|
+
'Render them to PNGs and use `src` on the tab:\n' +
|
|
91
|
+
' npx @expokit/cli@latest icons --from hugeicons --names Home01Icon,Search01Icon --out assets/tab-icons\n' +
|
|
92
|
+
'Or use an icon.set preset, iconFont() or icoMoon().'
|
|
90
93
|
);
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -14,12 +14,14 @@ export type { IconSetName, TabIconPreset } from '@/lib/tab-icons';
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Edit this file only — tabs, labels, icons, colors, and screen copy.
|
|
17
|
-
* Each tab needs a route file: src/app/(tabs)/{name}.tsx
|
|
17
|
+
* Each tab needs a route file: src/app/(tabs)/{name}.tsx — `npx @expokit/cli@latest update`
|
|
18
|
+
* creates a missing one.
|
|
18
19
|
*
|
|
19
20
|
* Icons (pick one per tab):
|
|
20
21
|
* icon: { set: 'Ionicons', name: 'home-outline', selected: 'home' }
|
|
21
22
|
* library + iconName (any package with getImageSource)
|
|
22
|
-
* src: require('../assets/icon.png')
|
|
23
|
+
* src: require('../assets/icon.png') — or { default, selected }. SVG kits (Hugeicons, Lucide,
|
|
24
|
+
* Tabler): npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
export interface TabItem {
|