@expokit/cli 0.4.1 → 0.4.2
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 +17 -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:
|
|
@@ -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.2",
|
|
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 {
|