@expokit/cli 0.2.0 → 0.3.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.
Files changed (110) hide show
  1. package/README.md +14 -0
  2. package/bin/expo-kit.js +86 -24
  3. package/bin/mcp.mjs +8 -1
  4. package/package.json +8 -3
  5. package/registry/_docs/accordion.json +16 -0
  6. package/registry/_docs/action-sheet.json +28 -0
  7. package/registry/_docs/alert.json +28 -0
  8. package/registry/_docs/avatar.json +24 -0
  9. package/registry/_docs/badge.json +35 -4
  10. package/registry/_docs/bottom-nav.json +24 -0
  11. package/registry/_docs/bottom-sheet.json +28 -0
  12. package/registry/_docs/bullet.json +3 -0
  13. package/registry/_docs/button-3d.json +16 -0
  14. package/registry/_docs/button.json +16 -0
  15. package/registry/_docs/calendar.json +16 -0
  16. package/registry/_docs/card-stack.json +16 -0
  17. package/registry/_docs/card.json +20 -0
  18. package/registry/_docs/carousel.json +20 -0
  19. package/registry/_docs/chat.json +184 -0
  20. package/registry/_docs/checkbox.json +16 -0
  21. package/registry/_docs/checkout.json +3 -0
  22. package/registry/_docs/chip-group.json +16 -0
  23. package/registry/_docs/color-swatch.json +16 -0
  24. package/registry/_docs/confetti.json +20 -0
  25. package/registry/_docs/coupon.json +16 -0
  26. package/registry/_docs/cube-pager.json +16 -0
  27. package/registry/_docs/dashboard.json +3 -0
  28. package/registry/_docs/date-picker.json +20 -0
  29. package/registry/_docs/dialog.json +24 -0
  30. package/registry/_docs/drawer.json +28 -0
  31. package/registry/_docs/fab.json +24 -0
  32. package/registry/_docs/flip-card.json +16 -0
  33. package/registry/_docs/floating-input.json +20 -0
  34. package/registry/_docs/gift-card.json +16 -0
  35. package/registry/_docs/glass-button.json +24 -0
  36. package/registry/_docs/glass-nav-bar.json +24 -0
  37. package/registry/_docs/glass-search-bar.json +24 -0
  38. package/registry/_docs/glass-segmented.json +28 -0
  39. package/registry/_docs/glass-sheet.json +28 -0
  40. package/registry/_docs/glass-tab-bar.json +24 -0
  41. package/registry/_docs/glass.json +16 -0
  42. package/registry/_docs/inbox.json +3 -0
  43. package/registry/_docs/isometric-bar.json +3 -0
  44. package/registry/_docs/kit.json +3 -0
  45. package/registry/_docs/kpi-card.json +3 -0
  46. package/registry/_docs/like-button.json +20 -0
  47. package/registry/_docs/liquidglass-tab.json +3 -0
  48. package/registry/_docs/list-item.json +16 -0
  49. package/registry/_docs/loyalty-card.json +16 -0
  50. package/registry/_docs/morph-button.json +20 -0
  51. package/registry/_docs/number-ticker.json +16 -0
  52. package/registry/_docs/offers.json +3 -0
  53. package/registry/_docs/onboarding.json +3 -0
  54. package/registry/_docs/otp-input.json +24 -0
  55. package/registry/_docs/pagination-dots.json +20 -0
  56. package/registry/_docs/parallax-layers.json +20 -0
  57. package/registry/_docs/parallax-scroll.json +24 -0
  58. package/registry/_docs/password-input.json +20 -0
  59. package/registry/_docs/payment-card.json +16 -0
  60. package/registry/_docs/pixel-bar.json +3 -0
  61. package/registry/_docs/polar-area.json +3 -0
  62. package/registry/_docs/profile.json +3 -0
  63. package/registry/_docs/progress-steps.json +24 -0
  64. package/registry/_docs/progress.json +20 -0
  65. package/registry/_docs/radio-group.json +20 -0
  66. package/registry/_docs/rating.json +24 -0
  67. package/registry/_docs/ring-carousel.json +20 -0
  68. package/registry/_docs/scratch-card.json +20 -0
  69. package/registry/_docs/search-bar.json +20 -0
  70. package/registry/_docs/search-results.json +3 -0
  71. package/registry/_docs/segmented-control.json +20 -0
  72. package/registry/_docs/select.json +26 -1
  73. package/registry/_docs/settings.json +3 -0
  74. package/registry/_docs/shimmer-button.json +20 -0
  75. package/registry/_docs/sign-in.json +3 -0
  76. package/registry/_docs/skeleton.json +20 -0
  77. package/registry/_docs/slide-to-confirm.json +24 -0
  78. package/registry/_docs/slider.json +21 -1
  79. package/registry/_docs/spin-wheel.json +24 -0
  80. package/registry/_docs/spinner.json +41 -11
  81. package/registry/_docs/stateful-button.json +184 -0
  82. package/registry/_docs/stepper.json +16 -0
  83. package/registry/_docs/swipe-deck.json +16 -0
  84. package/registry/_docs/swipeable-row.json +24 -1
  85. package/registry/_docs/switch.json +20 -0
  86. package/registry/_docs/tabs.json +24 -0
  87. package/registry/_docs/tag-input.json +20 -0
  88. package/registry/_docs/text-animation.json +141 -0
  89. package/registry/_docs/textarea.json +16 -0
  90. package/registry/_docs/ticket-card.json +20 -0
  91. package/registry/_docs/ticket-shape.json +3 -0
  92. package/registry/_docs/tilt-card.json +16 -0
  93. package/registry/_docs/time-picker.json +16 -0
  94. package/registry/_docs/timeline-chart.json +3 -0
  95. package/registry/_docs/timeline.json +16 -0
  96. package/registry/_docs/toast.json +24 -0
  97. package/registry/_docs/treemap.json +3 -0
  98. package/registry/_docs/waterfall.json +3 -0
  99. package/registry/_docs/wheel-list.json +20 -0
  100. package/registry/badge/files/src/components/ui/badge.tsx +327 -17
  101. package/registry/badge.json +1 -1
  102. package/registry/chat.json +18 -0
  103. package/registry/spinner/files/src/components/ui/spinner.tsx +435 -22
  104. package/registry/spinner.json +1 -1
  105. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
  106. package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
  107. package/registry/stateful-button.json +18 -0
  108. package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
  109. package/registry/text-animation/files/src/lib/kit.tsx +219 -0
  110. package/registry/text-animation.json +15 -0
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # @expokit/cli
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@expokit/cli.svg)](https://www.npmjs.com/package/@expokit/cli)
4
+ [![CI](https://github.com/Anshkaran7/expokit/actions/workflows/ci.yml/badge.svg)](https://github.com/Anshkaran7/expokit/actions/workflows/ci.yml)
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Anshkaran7/expokit/badge)](https://scorecard.dev/viewer/?uri=github.com/Anshkaran7/expokit)
6
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
4
7
  ![License](https://img.shields.io/badge/license-MIT-green)
5
8
 
6
9
  The CLI for **[ExpoKit](https://expokit.karandev.in)** — animated UI components for Expo and React Native. It copies
@@ -28,6 +31,17 @@ See every component: `npx @expokit/cli@latest add --list`
28
31
  Everything else — live previews, props, installation, theming, Pro, AI / MCP setup and help — is on
29
32
  **[expokit.karandev.in](https://expokit.karandev.in/docs)**.
30
33
 
34
+ ## Security
35
+
36
+ - **No dependencies, no install scripts, no telemetry.**
37
+ - **Writes only inside your app** — `components.json`, the `@/*` alias in `tsconfig.json` and the component files.
38
+ `--dry-run` shows every change first; `add` never overwrites a file you changed unless you pass `--force`.
39
+ - **Free components need no network** — they ship inside this package. Pro components are downloaded over HTTPS from
40
+ expokit.karandev.in; your token is stored readable only by you, sent only to that site and never through a redirect.
41
+ - **Runs one command:** `npx expo install` for the packages a component needs.
42
+
43
+ Report a vulnerability privately: [SECURITY.md](https://github.com/Anshkaran7/expokit/blob/main/SECURITY.md).
44
+
31
45
  ## License
32
46
 
33
47
  MIT
package/bin/expo-kit.js CHANGED
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import readline from 'node:readline';
16
+ import { Writable } from 'node:stream';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import {
18
19
  CliError,
@@ -36,6 +37,8 @@ const REGISTRY_DIR = path.join(PACKAGE_ROOT, 'registry');
36
37
  /** Website that serves Pro items (GET /api/registry/<name>) and issues CLI tokens (/account). */
37
38
  const SITE_URL = (process.env.EXPO_KIT_URL || DEFAULT_SITE_URL).replace(/\/+$/, '');
38
39
  const CREDENTIALS_DIR = path.join(os.homedir(), '.expo-kit');
40
+ /** How users run the CLI. Bare `expo-kit` only works once installed, and `npx expo-kit` would fetch an unrelated npm package. */
41
+ const RUN = 'npx @expokit/cli@latest';
39
42
  const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
40
43
 
41
44
  const DEFAULT_ALIASES = {
@@ -295,7 +298,7 @@ async function loadRegistryItem(name) {
295
298
  const resolved = resolveRegistryName(name);
296
299
  const meta = isValidItemName(resolved) ? readMeta(resolved) : null;
297
300
  if (!meta) {
298
- if (!isValidItemName(resolved)) fail(`Unknown item "${name}". Run: expo-kit add --list`);
301
+ if (!isValidItemName(resolved)) fail(`Unknown item "${name}". Run: ${RUN} add --list`);
299
302
  // Not in this package's registry — it may be newer than this CLI. Ask the site before giving up.
300
303
  const remoteMeta = { name: resolved, title: resolved, tier: 'pro', dependencies: [], unknownLocally: true };
301
304
  return stageRemoteItem(remoteMeta, resolved);
@@ -317,7 +320,7 @@ function listRegistry() {
317
320
  console.log(` • ${meta.name.padEnd(18)} ${tag}${meta.title}`);
318
321
  }
319
322
  console.log('');
320
- if (pro) log(`PRO items need an ExpoKit Pro plan — run \`expo-kit login\` (tokens: ${SITE_URL}/account).\n`);
323
+ if (pro) log(`PRO items need an ExpoKit Pro plan — run \`${RUN} login\` (tokens: ${SITE_URL}/account).\n`);
321
324
  }
322
325
 
323
326
  // ---------------------------------------------------------------------------
@@ -342,9 +345,9 @@ function assertTokenTarget(creds) {
342
345
  const target = new URL(SITE_URL).origin;
343
346
  throw new CliError(
344
347
  creds.fromEnv
345
- ? `Refusing to send EXPO_KIT_TOKEN to ${target} (only ${DEFAULT_SITE_URL} or localhost receive it). Unset EXPO_KIT_URL, or run \`expo-kit login\` against that site.`
348
+ ? `Refusing to send EXPO_KIT_TOKEN to ${target} (only ${DEFAULT_SITE_URL} or localhost receive it). Unset EXPO_KIT_URL, or run \`${RUN} login\` against that site.`
346
349
  : `Your saved token was created for ${creds.url ?? DEFAULT_SITE_URL}, so it won't be sent to ${target}. ` +
347
- `Unset EXPO_KIT_URL, or run \`expo-kit login\` with EXPO_KIT_URL=${target} to save a token for that site.`
350
+ `Unset EXPO_KIT_URL, or run \`${RUN} login\` with EXPO_KIT_URL=${target} to save a token for that site.`
348
351
  );
349
352
  }
350
353
 
@@ -361,6 +364,8 @@ async function apiGet(pathname, token) {
361
364
  try {
362
365
  res = await fetch(url, {
363
366
  headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
367
+ // A request that carries the token never follows a redirect: the token goes to SITE_URL's origin only.
368
+ redirect: token ? 'manual' : 'follow',
364
369
  signal: AbortSignal.timeout(20000),
365
370
  });
366
371
  } catch (e) {
@@ -369,6 +374,14 @@ async function apiGet(pathname, token) {
369
374
  'Check your connection (or EXPO_KIT_URL) and try again.'
370
375
  );
371
376
  }
377
+ if (token && res.status >= 300 && res.status < 400) {
378
+ const location = res.headers.get('location');
379
+ fail(
380
+ `${SITE_URL} answered with a redirect (HTTP ${res.status}${location ? ` to ${location}` : ''}). ` +
381
+ `Your token is only sent to ${new URL(SITE_URL).origin} directly, so nothing was sent on. ` +
382
+ 'Check EXPO_KIT_URL, or update the CLI: npx @expokit/cli@latest'
383
+ );
384
+ }
372
385
  let body = null;
373
386
  try {
374
387
  body = await res.json();
@@ -388,7 +401,7 @@ async function fetchRemoteItem(name, { unknownLocally = false } = {}) {
388
401
  assertTokenTarget(creds);
389
402
  const { status, ok, body } = await apiGet(`/api/registry/${encodeURIComponent(name)}`, creds?.token);
390
403
  if (ok && body && Array.isArray(body.files)) return body;
391
- if (status === 404 && unknownLocally) fail(`Unknown item "${name}". Run: expo-kit add --list`);
404
+ if (status === 404 && unknownLocally) fail(`Unknown item "${name}". Run: ${RUN} add --list`);
392
405
  if (status >= 500) failServerError(status, `downloading "${name}"`);
393
406
  if (status === 401 && !creds) {
394
407
  fail(
@@ -398,7 +411,7 @@ async function fetchRemoteItem(name, { unknownLocally = false } = {}) {
398
411
  );
399
412
  }
400
413
  if (status === 401) {
401
- fail(`Your token (${creds.source}) is invalid or was revoked. Create a new one at ${SITE_URL}/account and run \`expo-kit login\`.`);
414
+ fail(`Your token (${creds.source}) is invalid or was revoked. Create a new one at ${SITE_URL}/account and run \`${RUN} login\`.`);
402
415
  }
403
416
  if (status === 403) {
404
417
  fail(`"${name}" needs ExpoKit Pro, and your account doesn't have it yet. Upgrade at ${SITE_URL}/pricing.`);
@@ -484,11 +497,20 @@ async function stageRemoteItem(meta, resolved) {
484
497
  return { meta: { ...meta, title, dependencies, sharedFiles: [...shared] }, filesRoot: tmp, resolved };
485
498
  }
486
499
 
487
- function prompt(question) {
488
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
500
+ /** Reads one line from the terminal without echoing it (for tokens), so it stays off the screen and out of scrollback. */
501
+ function promptSecret(question) {
502
+ process.stdout.write(question);
503
+ const muted = new Writable({ write: (_chunk, _encoding, done) => done() });
504
+ const rl = readline.createInterface({ input: process.stdin, output: muted, terminal: true });
489
505
  return new Promise((resolve) => {
490
- rl.question(question, (answer) => {
506
+ rl.on('SIGINT', () => {
507
+ rl.close();
508
+ process.stdout.write('\n');
509
+ process.exit(130);
510
+ });
511
+ rl.question('', (answer) => {
491
512
  rl.close();
513
+ process.stdout.write('\n');
492
514
  resolve(answer.trim());
493
515
  });
494
516
  });
@@ -498,9 +520,9 @@ async function cmdLogin(args) {
498
520
  const i = args.findIndex((a) => a === '--token' || a.startsWith('--token='));
499
521
  let token = i === -1 ? '' : args[i].includes('=') ? args[i].slice('--token='.length) : args[i + 1] ?? '';
500
522
  if (!token) {
501
- log(`Create a CLI token at ${SITE_URL}/account (signed in), then paste it here.`);
502
- if (!process.stdin.isTTY) fail('No token given. Use: expo-kit login --token <token>');
503
- token = await prompt('Token: ');
523
+ log(`Create a CLI token at ${SITE_URL}/account (signed in), then paste it here (it won't be shown).`);
524
+ if (!process.stdin.isTTY) fail(`No token given. Use: ${RUN} login --token <token>`);
525
+ token = await promptSecret('Token: ');
504
526
  }
505
527
  token = token.trim();
506
528
  if (!token) fail('No token given.');
@@ -532,7 +554,7 @@ function cmdLogout() {
532
554
 
533
555
  async function cmdWhoami() {
534
556
  const creds = readCredentials();
535
- if (!creds) fail(`Not logged in. Run: expo-kit login (tokens: ${SITE_URL}/account)`);
557
+ if (!creds) fail(`Not logged in. Run: ${RUN} login (tokens: ${SITE_URL}/account)`);
536
558
  assertTokenTarget(creds);
537
559
  const { status, ok, body } = await apiGet('/api/cli/whoami', creds.token);
538
560
  if (status === 401) fail(`Your token (${creds.source}) is invalid or was revoked. Create a new one at ${SITE_URL}/account.`);
@@ -740,9 +762,43 @@ function runExpoInstall(projectRoot, packages, dryRun) {
740
762
  return true;
741
763
  }
742
764
 
743
- function cmdInit(cwd, flags) {
765
+ /** Folder names `init <dir>` will create: no paths, no spaces, nothing a shell could misread. */
766
+ const SAFE_APP_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
767
+
768
+ /** `init my-app`: a new Expo app (create-expo-app) in ./my-app, then the usual init inside it. */
769
+ function createExpoApp(cwd, dir, flags) {
770
+ if (!SAFE_APP_DIR.test(dir)) fail(`"${dir}" is not a valid folder name (letters, numbers, ".", "_" and "-").`);
771
+ const target = path.join(cwd, dir);
772
+ if (fs.existsSync(target) && fs.readdirSync(target).length) fail(`./${dir} already exists and is not empty.`);
773
+ if (flags.dryRun) {
774
+ log(`Would run: npx create-expo-app@latest ${dir} --yes`);
775
+ log(`Would then set up ExpoKit in ./${dir}`);
776
+ return null;
777
+ }
778
+ log(`Creating a new Expo app in ./${dir}…`);
779
+ const result = spawnSync('npx', ['create-expo-app@latest', dir, '--yes'], {
780
+ cwd,
781
+ stdio: 'inherit',
782
+ shell: process.platform === 'win32',
783
+ });
784
+ if (result.status !== 0 || !fs.existsSync(path.join(target, 'package.json'))) {
785
+ fail(`create-expo-app failed. Create the app yourself (npx create-expo-app@latest ${dir}), then run ${RUN} init inside it.`);
786
+ }
787
+ return target;
788
+ }
789
+
790
+ function cmdInit(cwd, flags, dir) {
791
+ if (dir) {
792
+ // Already inside an Expo app: `init <dir>` still means "create a new one there".
793
+ const target = createExpoApp(cwd, dir, flags);
794
+ if (!target) return;
795
+ cmdInit(target, flags);
796
+ log('');
797
+ log(`Next: cd ${dir} && npx expo start — then scan the QR with Expo Go.`);
798
+ return;
799
+ }
744
800
  const projectRoot = findProjectRoot(cwd);
745
- if (!projectRoot) fail('Not inside an Expo project (expo dependency not found).');
801
+ if (!projectRoot) fail(`Not inside an Expo project (expo dependency not found). Start a new app with: ${RUN} init my-app`);
746
802
 
747
803
  const configPath = path.join(projectRoot, 'components.json');
748
804
  if (fs.existsSync(configPath)) {
@@ -777,7 +833,7 @@ function cmdInit(cwd, flags) {
777
833
  log(`Created components.json (${layout} layout)`);
778
834
 
779
835
  ensureExpoRouterMain(projectRoot, false);
780
- log('Done. Add items with: npx @expokit/cli add <name>');
836
+ log(`Done. Add items with: ${RUN} add <name>`);
781
837
  }
782
838
 
783
839
  async function cmdAdd(args, cwd) {
@@ -891,7 +947,7 @@ async function cmdAdd(args, cwd) {
891
947
  if (resolved === 'liquidglass-tab') {
892
948
  log('Next steps:');
893
949
  console.log(' • Edit src/lib/tabs.ts — labels, icon presets, colors, screen text');
894
- console.log(' • Run: npx expo-kit doctor');
950
+ console.log(` • Run: ${RUN} doctor`);
895
951
  console.log(' • Build a dev client: npx expo run:ios (iOS 26 shows real Liquid Glass,');
896
952
  console.log(' iOS 18 and older, Android and web get the glass fallback bar)');
897
953
  console.log(' • After package updates: npx @expokit/cli@latest update');
@@ -977,7 +1033,7 @@ function cmdDoctor(cwd) {
977
1033
  log('Checking project…\n');
978
1034
 
979
1035
  if (!fs.existsSync(config.configPath)) {
980
- warn('Missing components.json — run: expo-kit init');
1036
+ warn(`Missing components.json — run: ${RUN} init`);
981
1037
  issues += 1;
982
1038
  } else {
983
1039
  log(' ✓ components.json');
@@ -996,7 +1052,7 @@ function cmdDoctor(cwd) {
996
1052
  const usesLiquidGlass = fs.existsSync(tabsPath) || config.installed?.['liquidglass-tab'];
997
1053
  if (usesLiquidGlass) {
998
1054
  if (!fs.existsSync(tabsPath)) {
999
- warn('No src/lib/tabs.ts — run: expo-kit add liquidglass-tab');
1055
+ warn(`No src/lib/tabs.ts — run: ${RUN} add liquidglass-tab`);
1000
1056
  issues += 1;
1001
1057
  } else {
1002
1058
  log(' ✓ src/lib/tabs.ts');
@@ -1018,7 +1074,7 @@ function cmdDoctor(cwd) {
1018
1074
  const routePath = path.join(tabsDir, fileName);
1019
1075
  if (!fs.existsSync(routePath)) {
1020
1076
  warn(`Missing route for tab "${tabName}": ${path.relative(projectRoot, routePath)}`);
1021
- console.log(` Fix: add the file or run expo-kit add liquidglass-tab --force`);
1077
+ console.log(` Fix: add the file or run ${RUN} add liquidglass-tab --force`);
1022
1078
  issues += 1;
1023
1079
  } else {
1024
1080
  log(` ✓ route ${path.relative(projectRoot, routePath)}`);
@@ -1073,7 +1129,7 @@ async function cmdMcp(args, cwd) {
1073
1129
  const i = args.findIndex((a) => a === '--client' || a.startsWith('--client='));
1074
1130
  const client = i < 0 ? undefined : args[i].includes('=') ? args[i].split('=')[1] : args[i + 1];
1075
1131
  const names = [...Object.keys(MCP_CLIENTS), 'codex'].join('|');
1076
- if (!client) fail(`Pick a client: expo-kit mcp init --client <${names}>`);
1132
+ if (!client) fail(`Pick a client: ${RUN} mcp init --client <${names}>`);
1077
1133
 
1078
1134
  if (client === 'codex') {
1079
1135
  log('Codex reads MCP servers from ~/.codex/config.toml (shared by every project). Add:\n');
@@ -1117,7 +1173,7 @@ function printHelp() {
1117
1173
  expo-kit — copy-paste Expo UI (shadcn-style)
1118
1174
 
1119
1175
  Usage:
1120
- npx @expokit/cli@latest init
1176
+ npx @expokit/cli@latest init [my-app] [--dry-run]
1121
1177
  npx @expokit/cli@latest add <name...> [--dry-run] [--force]
1122
1178
  npx @expokit/cli@latest update [name] [--dry-run] [--force]
1123
1179
  npx @expokit/cli@latest doctor
@@ -1129,6 +1185,7 @@ Usage:
1129
1185
  npx @expokit/cli@latest mcp init --client <claude|cursor|vscode|codex>
1130
1186
 
1131
1187
  Examples:
1188
+ npx @expokit/cli@latest init my-app new Expo app with ExpoKit set up
1132
1189
  npx @expokit/cli@latest add liquidglass-tab --dry-run
1133
1190
  npx @expokit/cli@latest add liquidglass-tab --force
1134
1191
  npx @expokit/cli@latest update
@@ -1159,10 +1216,15 @@ Config: components.json (created by init)
1159
1216
  async function main(command, rest) {
1160
1217
  switch (command) {
1161
1218
  case 'init': {
1162
- const { flags } = parseFlags(rest);
1163
- cmdInit(process.cwd(), flags);
1219
+ const { flags, positional } = parseFlags(rest);
1220
+ if (positional.length > 1) fail(`init takes one folder name: ${RUN} init my-app`);
1221
+ cmdInit(process.cwd(), flags, positional[0]);
1164
1222
  break;
1165
1223
  }
1224
+ case '--version':
1225
+ case '-v':
1226
+ console.log(getPackageVersion());
1227
+ break;
1166
1228
  case 'add':
1167
1229
  await cmdAdd(rest, process.cwd());
1168
1230
  break;
package/bin/mcp.mjs CHANGED
@@ -179,7 +179,7 @@ function searchItems(catalog, { query }) {
179
179
  function viewItem(catalog, registryDir, { name, include_source: includeSource = false }) {
180
180
  const it = requireItem(catalog, name);
181
181
  const doc = catalog.docs.get(it.name);
182
- const out = [`# ${it.title} (${it.name})`, '', it.description, ''];
182
+ const out = [`# ${it.title} (${it.name})`, '', ...(doc?.summary ? [doc.summary, ''] : []), it.description, ''];
183
183
  out.push(`Type: ${it.type} · Tier: ${it.tier === 'pro' ? 'PRO (needs an ExpoKit Pro plan)' : 'free'}${doc?.category ? ` · Category: ${doc.category}` : ''}`);
184
184
  if (doc?.aliases?.length) out.push(`Aliases: ${doc.aliases.join(', ')}`);
185
185
  out.push('', `Add: \`${addCommand([it.name])}\``);
@@ -195,6 +195,13 @@ function viewItem(catalog, registryDir, { name, include_source: includeSource =
195
195
  for (const [prop, type, def, desc] of doc.props) out.push(`| ${cell(prop)} | ${cell(type)} | ${cell(def)} | ${cell(desc)} |`);
196
196
  }
197
197
 
198
+ if (doc?.behavior?.length) {
199
+ out.push('', '## Behaviour & accessibility', '');
200
+ for (const [label, text] of doc.behavior) out.push(`- **${label}:** ${text}`);
201
+ }
202
+
203
+ if (doc?.usage) out.push('', '## Usage', '', '```tsx', doc.usage.trimEnd(), '```');
204
+
198
205
  if (doc?.examples?.length) {
199
206
  out.push('', '## Examples');
200
207
  for (const ex of doc.examples) out.push('', `### ${ex.title}`, '', '```tsx', ex.code.trimEnd(), '```');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expokit/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "shadcn-style CLI for Expo — add native UI blocks and components to your app",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "bin",
12
12
  "registry",
13
13
  "schema",
14
- "README.md"
14
+ "README.md",
15
+ "LICENSE"
15
16
  ],
16
17
  "keywords": [
17
18
  "expo",
@@ -35,7 +36,11 @@
35
36
  "bugs": {
36
37
  "url": "https://github.com/Anshkaran7/expokit/issues"
37
38
  },
38
- "author": "anshkaran7",
39
+ "funding": {
40
+ "type": "github",
41
+ "url": "https://github.com/sponsors/Anshkaran7"
42
+ },
43
+ "author": "Karan (anshkaran7) <work@karandev.in>",
39
44
  "publishConfig": {
40
45
  "access": "public"
41
46
  },
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "src/components/ui/accordion.tsx"
8
8
  ],
9
+ "summary": "Sections spring open to their measured height while the chevron turns.",
9
10
  "props": [
10
11
  [
11
12
  "type",
@@ -74,6 +75,21 @@
74
75
  "Style for the Accordion's root view (the card in the bordered variant); AccordionItem takes its own."
75
76
  ]
76
77
  ],
78
+ "behavior": [
79
+ [
80
+ "Touch",
81
+ "Tap a header to open or close its section. In single mode, opening one closes the other; disabled items don't respond."
82
+ ],
83
+ [
84
+ "Motion",
85
+ "Content height springs to its measured size, so any content animates smoothly. The chevron turns 180° on the native driver."
86
+ ],
87
+ [
88
+ "Screen readers",
89
+ "Each header is a button with expanded and disabled state. Content of closed sections is hidden from assistive tech."
90
+ ]
91
+ ],
92
+ "usage": "import { Accordion, AccordionItem } from '@/components/ui/accordion';\n\n<Accordion defaultValue={['shipping']}>\n <AccordionItem value=\"shipping\" title=\"How long does shipping take?\">\n Orders leave our warehouse within 24 hours.\n </AccordionItem>\n <AccordionItem value=\"returns\" title=\"Can I return an item?\">\n You have 30 days to send anything back for a full refund.\n </AccordionItem>\n</Accordion>",
77
93
  "examples": [
78
94
  {
79
95
  "name": "Default",
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "src/components/ui/action-sheet.tsx"
8
8
  ],
9
+ "summary": "An iOS-style action sheet that follows your finger and flings away to dismiss.",
9
10
  "props": [
10
11
  [
11
12
  "open / defaultOpen",
@@ -80,6 +81,33 @@
80
81
  "Sheet container style."
81
82
  ]
82
83
  ],
84
+ "behavior": [
85
+ [
86
+ "Touch",
87
+ "Drag anywhere on the sheet. Pull past a third of its height or flick down to dismiss; pulling up rubber-bands."
88
+ ],
89
+ [
90
+ "Motion",
91
+ "Springs up over a fading scrim. The exit is quicker than the entry and keeps a fling's momentum."
92
+ ],
93
+ [
94
+ "Reduced motion",
95
+ "The sheet fades in and out in place instead of sliding up from the bottom."
96
+ ],
97
+ [
98
+ "Haptics",
99
+ "A light impact when it opens and a selection tick when you pick an action. Turn off with haptics={false}."
100
+ ],
101
+ [
102
+ "Screen readers",
103
+ "The sheet is a modal view, the title is a header and each row is a button with its label and disabled state."
104
+ ],
105
+ [
106
+ "Keyboard",
107
+ "Escape closes it on web; the Android back button closes it too."
108
+ ]
109
+ ],
110
+ "usage": "import { useState } from 'react';\nimport { ActionSheet } from '@/components/ui/action-sheet';\n\nconst [open, setOpen] = useState(false);\n<ActionSheet\n open={open}\n onOpenChange={setOpen}\n title=\"Profile photo\"\n actions={[{ label: 'Take photo' }, { label: 'Remove photo', destructive: true }]}\n/>",
83
111
  "examples": [
84
112
  {
85
113
  "name": "Photo",
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "src/components/ui/alert.tsx"
8
8
  ],
9
+ "summary": "An inline banner whose icon draws itself inside a halo, with an optional draining timer.",
9
10
  "props": [
10
11
  [
11
12
  "variant",
@@ -92,6 +93,33 @@
92
93
  "Style for the outer animated wrapper (its height collapses on dismiss)."
93
94
  ]
94
95
  ],
96
+ "behavior": [
97
+ [
98
+ "Motion",
99
+ "On appear the icon outline draws, the glyph springs in and the halo ripples once. Dismissing collapses the height so content below glides up."
100
+ ],
101
+ [
102
+ "Touch",
103
+ "With duration set, a hairline drains along the bottom to show the time left. Pressing the alert pauses it."
104
+ ],
105
+ [
106
+ "Reduced motion",
107
+ "The icon draw, glyph pop and halo ripple jump straight to their finished state."
108
+ ],
109
+ [
110
+ "Haptics",
111
+ "Success, warning and danger alerts play the matching notification haptic when they appear; info stays silent."
112
+ ],
113
+ [
114
+ "Screen readers",
115
+ "The card has the alert role in a polite live region. The close button is labelled Dismiss and actions are buttons named by their label."
116
+ ],
117
+ [
118
+ "Web",
119
+ "Hovering also pauses the auto-dismiss timer."
120
+ ]
121
+ ],
122
+ "usage": "import { Alert } from '@/components/ui/alert';\n\n<Alert\n variant=\"warning\"\n title=\"Storage almost full\"\n description=\"You've used 9.4 GB of your 10 GB plan.\"\n dismissible\n/>",
95
123
  "examples": [
96
124
  {
97
125
  "name": "Soft",
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "src/components/ui/avatar.tsx"
8
8
  ],
9
+ "summary": "A photo over hashed-colour initials, with presence dots and badges cut into the edge.",
9
10
  "props": [
10
11
  [
11
12
  "src",
@@ -158,6 +159,29 @@
158
159
  "Style for the avatar root; AvatarGroup applies it to its row."
159
160
  ]
160
161
  ],
162
+ "behavior": [
163
+ [
164
+ "Motion",
165
+ "The photo fades in over the initials, a story ring cross-fades to a hairline once seen, and AvatarGroup springs its faces in with a stagger."
166
+ ],
167
+ [
168
+ "Touch",
169
+ "With onPress it becomes a button that springs down while pressed."
170
+ ],
171
+ [
172
+ "Reduced motion",
173
+ "The status pulse, ring spin, press squish and group stagger are skipped, and the seen cross-fade is instant."
174
+ ],
175
+ [
176
+ "Haptics",
177
+ "A light impact on press when onPress is set. Turn off with haptics={false}."
178
+ ],
179
+ [
180
+ "Screen readers",
181
+ "Read as an image (or a button when pressable) labelled with the name plus verified, status, notification and story details. A group reads its first names and \"and N more\"."
182
+ ]
183
+ ],
184
+ "usage": "import { Avatar, AvatarGroup } from '@/components/ui/avatar';\n\n<Avatar name=\"Ava Rossi\" src=\"https://i.pravatar.cc/240?img=47\" size=\"lg\" status=\"online\" />\n<AvatarGroup max={3} total={12}>\n <Avatar name=\"Noah Kim\" />\n <Avatar name=\"Priya Patel\" />\n <Avatar name=\"Diego Alvarez\" />\n</AvatarGroup>",
161
185
  "examples": [
162
186
  {
163
187
  "name": "ProfileHeader",
@@ -6,6 +6,7 @@
6
6
  "files": [
7
7
  "src/components/ui/badge.tsx"
8
8
  ],
9
+ "summary": "Status pills in four styles that roll, pop and re-tint when their status changes.",
9
10
  "props": [
10
11
  [
11
12
  "variant",
@@ -49,6 +50,12 @@
49
50
  "false",
50
51
  "Pulsing dot for live states (dot variant)."
51
52
  ],
53
+ [
54
+ "animateChanges",
55
+ "boolean",
56
+ "true",
57
+ "Roll the label, pop the icon, cross-fade colours and spring the width when they change. Never on mount."
58
+ ],
52
59
  [
53
60
  "NotificationBadge.count",
54
61
  "number",
@@ -92,26 +99,50 @@
92
99
  "Style for the badge pill; NotificationBadge applies it to the anchor wrapper or bare bubble wrapper."
93
100
  ]
94
101
  ],
102
+ "behavior": [
103
+ [
104
+ "Motion",
105
+ "On a change the old label rolls up and out as the new one rolls in (per letter for short Latin text), the icon swaps with a scale-and-rotate pop and the pill width springs to fit. Rolls and pops use the native driver; colour and width run on the JS driver on separate views. Nothing animates on mount; animateChanges={false} swaps instantly."
106
+ ],
107
+ [
108
+ "Reduced motion",
109
+ "Changes swap instantly: no roll, pop, colour fade or width spring. The dot variant's pulse still loops."
110
+ ],
111
+ [
112
+ "Screen readers",
113
+ "Text labels are one accessible element whose label is always the current text; on Android it is a polite live region so status changes are announced."
114
+ ],
115
+ [
116
+ "Platforms",
117
+ "React Native Animated and react-native-svg only — iOS, Android, Expo Go and web."
118
+ ]
119
+ ],
120
+ "usage": "import { Badge } from '@/components/ui/badge';\n\nexport function BuildStatus({ done }: { done: boolean }) {\n return (\n <Badge tone={done ? 'success' : 'warning'}>{done ? 'Deployed' : 'Building'}</Badge>\n );\n}",
95
121
  "examples": [
96
122
  {
97
123
  "name": "Variants",
98
124
  "title": "Variants",
99
- "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge } from '@/components/ui/badge';\n\nconst TONES = ['neutral', 'accent', 'success', 'warning', 'danger'] as const;\n\nexport default function Variants() {\n return (\n <View style={{ gap: 10 }}>\n {(['solid', 'soft', 'outline', 'dot'] as const).map((variant) => (\n <View key={variant} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>\n {TONES.map((tone) => (\n <Badge key={tone} variant={variant} tone={tone}>\n {tone[0].toUpperCase() + tone.slice(1)}\n </Badge>\n ))}\n </View>\n ))}\n </View>\n );\n}\n"
125
+ "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge, type BadgeIconName, type BadgeTone } from '@/components/ui/badge';\n\nconst TONES = ['neutral', 'accent', 'success', 'warning', 'danger'] as const;\n\nexport default function Variants() {\n return (\n <View style={{ gap: 10 }}>\n {(['solid', 'soft', 'outline', 'dot'] as const).map((variant) => (\n <View key={variant} style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>\n {TONES.map((tone) => (\n <Badge key={tone} variant={variant} tone={tone}>\n {tone[0].toUpperCase() + tone.slice(1)}\n </Badge>\n ))}\n </View>\n ))}\n </View>\n );\n}\n"
100
126
  },
101
127
  {
102
128
  "name": "Statuses",
103
129
  "title": "Statuses",
104
- "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge } from '@/components/ui/badge';\nimport { alpha, useKit } from '@/lib/kit';\n\nexport default function Statuses() {\n const { colors } = useKit();\n return (\n <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>\n <Badge variant=\"dot\" tone=\"success\" pulse>Live</Badge>\n <Badge variant=\"soft\" tone=\"success\" icon={<BadgeIcon name=\"package\" color={colors.success} size={14} />}>Paid</Badge>\n <Badge variant=\"soft\" tone=\"warning\">Pending review</Badge>\n <Badge variant=\"outline\" tone=\"danger\">Overdue</Badge>\n <Badge variant=\"solid\" tone=\"accent\" shape=\"rounded\">PRO</Badge>\n <Badge variant=\"soft\" tone=\"accent\" icon={<BadgeIcon name=\"heart\" color={colors.accent} size={14} />}>Saved</Badge>\n </View>\n );\n}\n"
130
+ "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge, type BadgeIconName, type BadgeTone } from '@/components/ui/badge';\nimport { alpha, useKit } from '@/lib/kit';\n\nexport default function Statuses() {\n const { colors } = useKit();\n return (\n <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>\n <Badge variant=\"dot\" tone=\"success\" pulse>Live</Badge>\n <Badge variant=\"soft\" tone=\"success\" icon={<BadgeIcon name=\"package\" color={colors.success} size={14} />}>Paid</Badge>\n <Badge variant=\"soft\" tone=\"warning\">Pending review</Badge>\n <Badge variant=\"outline\" tone=\"danger\">Overdue</Badge>\n <Badge variant=\"solid\" tone=\"accent\" shape=\"rounded\">PRO</Badge>\n <Badge variant=\"soft\" tone=\"accent\" icon={<BadgeIcon name=\"heart\" color={colors.accent} size={14} />}>Saved</Badge>\n </View>\n );\n}\n"
131
+ },
132
+ {
133
+ "name": "LiveStatus",
134
+ "title": "Live Status",
135
+ "code": "import React, { useEffect, useState } from 'react';\nimport { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge, type BadgeIconName, type BadgeTone } from '@/components/ui/badge';\nimport { alpha, useKit } from '@/lib/kit';\n\nconst DEPLOY: { label: string; tone: BadgeTone; icon: BadgeIconName }[] = [\n { label: 'Queued', tone: 'neutral', icon: 'clock' },\n { label: 'Building', tone: 'warning', icon: 'refresh' },\n { label: 'Deployed', tone: 'success', icon: 'check' },\n { label: 'Failed', tone: 'danger', icon: 'alert' },\n];\n\nexport default function LiveStatus() {\n const { colors } = useKit();\n const [step, setStep] = useState(0);\n useEffect(() => {\n const id = setInterval(() => setStep((i) => (i + 1) % DEPLOY.length), 1800);\n return () => clearInterval(id);\n }, []);\n const { label, tone, icon } = DEPLOY[step];\n const tint = { neutral: colors.mutedForeground, accent: colors.accent, success: colors.success, warning: colors.warning, danger: colors.danger }[tone];\n return (\n <View style={{ gap: 12 }}>\n <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>\n <Text style={{ fontSize: 15, color: colors.foreground }}>web-checkout #482</Text>\n <Badge tone={tone} icon={<BadgeIcon name={icon} color={tint} size={14} />}>\n {label}\n </Badge>\n </View>\n <View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>\n <Text style={{ fontSize: 15, color: colors.foreground }}>api-gateway #117</Text>\n <Badge variant=\"solid\" tone={tone} size=\"lg\">\n {label}\n </Badge>\n <Badge variant=\"dot\" tone={tone} pulse={tone === 'warning'}>\n {label}\n </Badge>\n </View>\n </View>\n );\n}\n"
105
136
  },
106
137
  {
107
138
  "name": "Sizes",
108
139
  "title": "Sizes",
109
- "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge } from '@/components/ui/badge';\n\nexport default function Sizes() {\n return (\n <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>\n <Badge size=\"sm\" tone=\"accent\">Small</Badge>\n <Badge size=\"md\" tone=\"accent\">Medium</Badge>\n <Badge size=\"lg\" tone=\"accent\">Large</Badge>\n </View>\n );\n}\n"
140
+ "code": "import { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge, type BadgeIconName, type BadgeTone } from '@/components/ui/badge';\n\nexport default function Sizes() {\n return (\n <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>\n <Badge size=\"sm\" tone=\"accent\">Small</Badge>\n <Badge size=\"md\" tone=\"accent\">Medium</Badge>\n <Badge size=\"lg\" tone=\"accent\">Large</Badge>\n </View>\n );\n}\n"
110
141
  },
111
142
  {
112
143
  "name": "Notifications",
113
144
  "title": "Notifications",
114
- "code": "import React, { useState } from 'react';\nimport { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge } from '@/components/ui/badge';\nimport { alpha, useKit } from '@/lib/kit';\n\nexport default function Notifications() {\n const { colors, radius } = useKit();\n const [count, setCount] = useState(3);\n return (\n <View style={{ gap: 18 }}>\n <View style={{ flexDirection: 'row', gap: 26 }}>\n <NotificationBadge count={count}>\n <BadgeIconTile name=\"bell\" backgroundColor={alpha(colors.accent, 0.12)} color={colors.accent} />\n </NotificationBadge>\n <NotificationBadge count={count * 37} tone=\"accent\">\n <BadgeIconTile name=\"message\" backgroundColor={alpha(colors.accent, 0.1)} color={colors.accent} />\n </NotificationBadge>\n <NotificationBadge count={count} dot tone=\"success\">\n <BadgeIconTile name=\"package\" backgroundColor={alpha(colors.success, 0.12)} color={colors.success} />\n </NotificationBadge>\n </View>\n <View style={{ flexDirection: 'row', gap: 10 }}>\n {[\n ['+ New', () => setCount((c) => c + 1)],\n ['Mark all read', () => setCount(0)],\n ].map(([label, onPress]) => (\n <Pressable\n key={label as string}\n onPress={onPress as () => void}\n style={{ paddingHorizontal: 14, height: 36, borderRadius: radius.capsule(36), justifyContent: 'center', backgroundColor: colors.muted }}\n >\n <Text style={{ fontWeight: '400', color: colors.foreground }}>{label as string}</Text>\n </Pressable>\n ))}\n </View>\n </View>\n );\n}\n"
145
+ "code": "import React, { useEffect, useState } from 'react';\nimport { Pressable, Text, View } from 'react-native';\nimport { Badge, BadgeIcon, BadgeIconTile, NotificationBadge, type BadgeIconName, type BadgeTone } from '@/components/ui/badge';\nimport { alpha, useKit } from '@/lib/kit';\n\nexport default function Notifications() {\n const { colors, radius } = useKit();\n const [count, setCount] = useState(3);\n return (\n <View style={{ gap: 18 }}>\n <View style={{ flexDirection: 'row', gap: 26 }}>\n <NotificationBadge count={count}>\n <BadgeIconTile name=\"bell\" backgroundColor={alpha(colors.accent, 0.12)} color={colors.accent} />\n </NotificationBadge>\n <NotificationBadge count={count * 37} tone=\"accent\">\n <BadgeIconTile name=\"message\" backgroundColor={alpha(colors.accent, 0.1)} color={colors.accent} />\n </NotificationBadge>\n <NotificationBadge count={count} dot tone=\"success\">\n <BadgeIconTile name=\"package\" backgroundColor={alpha(colors.success, 0.12)} color={colors.success} />\n </NotificationBadge>\n </View>\n <View style={{ flexDirection: 'row', gap: 10 }}>\n {[\n ['+ New', () => setCount((c) => c + 1)],\n ['Mark all read', () => setCount(0)],\n ].map(([label, onPress]) => (\n <Pressable\n key={label as string}\n onPress={onPress as () => void}\n style={{ paddingHorizontal: 14, height: 36, borderRadius: radius.capsule(36), justifyContent: 'center', backgroundColor: colors.muted }}\n >\n <Text style={{ fontWeight: '400', color: colors.foreground }}>{label as string}</Text>\n </Pressable>\n ))}\n </View>\n </View>\n );\n}\n"
115
146
  }
116
147
  ]
117
148
  }
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "src/components/ui/bottom-nav.tsx"
12
12
  ],
13
+ "summary": "A tab bar whose indicator stretches between tabs, leading edge first.",
13
14
  "props": [
14
15
  [
15
16
  "items",
@@ -84,6 +85,29 @@
84
85
  "Style of the bar (the outer shadow view for `floating`)."
85
86
  ]
86
87
  ],
88
+ "behavior": [
89
+ [
90
+ "Touch",
91
+ "Tap a tab to select it. Items marked action call onAction instead and never become the active tab."
92
+ ],
93
+ [
94
+ "Motion",
95
+ "The indicator springs its leading edge first and the trailing edge catches up. Icons morph from outline to filled and badges pop in."
96
+ ],
97
+ [
98
+ "Reduced motion",
99
+ "The indicator glides with a short timing and no stretch, and icon changes use quick timings instead of springs."
100
+ ],
101
+ [
102
+ "Haptics",
103
+ "A selection tick on tab change and a light impact on the action button. Turn off with haptics={false}."
104
+ ],
105
+ [
106
+ "Screen readers",
107
+ "The bar is a tablist and each tab carries selected state, with the badge count in its label. Action items are buttons."
108
+ ]
109
+ ],
110
+ "usage": "import { useState } from 'react';\nimport { BottomNav } from '@/components/ui/bottom-nav';\n\nconst [tab, setTab] = useState('home');\n<BottomNav\n items={[{ key: 'home', label: 'Home', icon: 'home' }, { key: 'inbox', label: 'Inbox', icon: 'inbox', badge: 3 }]}\n value={tab}\n onValueChange={setTab}\n/>",
87
111
  "examples": [
88
112
  {
89
113
  "name": "MinimalClean",