@expokit/cli 0.4.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -3
- package/bin/expo-kit.js +749 -288
- package/bin/lib.mjs +310 -3
- package/package.json +1 -1
- package/registry/_docs/action-sheet.json +1 -1
- package/registry/_docs/alert.json +6 -0
- package/registry/_docs/bottom-sheet.json +6 -0
- package/registry/_docs/bullet.json +6 -0
- package/registry/_docs/dialog.json +1 -1
- package/registry/_docs/drawer.json +3 -3
- package/registry/_docs/glass-search-bar.json +1 -1
- package/registry/_docs/grain.json +6 -1
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +1 -1
- package/registry/_docs/password-input.json +1 -1
- package/registry/_docs/pixel-bar.json +6 -0
- package/registry/_docs/polar-area.json +6 -0
- package/registry/_docs/qr-code-button.json +9 -3
- package/registry/_docs/scratch-card.json +7 -1
- package/registry/_docs/search-bar.json +1 -1
- package/registry/_docs/select.json +2 -2
- package/registry/_docs/slide-to-confirm.json +2 -2
- package/registry/_docs/spinner.json +1 -1
- package/registry/_docs/stateful-button.json +2 -2
- package/registry/_docs/swipe-deck.json +1 -1
- package/registry/_docs/time-picker.json +2 -2
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/toast.json +15 -3
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
- package/registry/accordion/files/src/lib/kit.tsx +363 -11
- package/registry/accordion.json +1 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +43 -48
- package/registry/action-sheet/files/src/lib/kit.tsx +363 -11
- package/registry/action-sheet.json +1 -0
- package/registry/alert/files/src/components/ui/alert.tsx +19 -12
- package/registry/alert/files/src/lib/kit.tsx +363 -11
- package/registry/alert.json +3 -4
- package/registry/assistant-app.json +1 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +84 -66
- package/registry/avatar/files/src/lib/kit.tsx +363 -11
- package/registry/avatar.json +1 -0
- package/registry/badge/files/src/components/ui/badge.tsx +34 -34
- package/registry/badge/files/src/lib/kit.tsx +363 -11
- package/registry/badge.json +1 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +28 -27
- package/registry/bottom-nav/files/src/lib/kit.tsx +363 -11
- package/registry/bottom-nav.json +5 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +353 -289
- package/registry/bottom-sheet/files/src/lib/kit.tsx +363 -11
- package/registry/bottom-sheet.json +5 -1
- package/registry/bullet.json +1 -0
- package/registry/button/files/src/components/ui/button.tsx +18 -16
- package/registry/button/files/src/lib/kit.tsx +363 -11
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +2 -2
- package/registry/button-3d/files/src/lib/kit.tsx +363 -11
- package/registry/button-3d.json +1 -0
- package/registry/button.json +1 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +74 -76
- package/registry/calendar/files/src/lib/kit.tsx +363 -11
- package/registry/calendar.json +1 -0
- package/registry/card/files/src/components/ui/card.tsx +3 -3
- package/registry/card/files/src/lib/kit.tsx +363 -11
- package/registry/card-stack.json +1 -0
- package/registry/card.json +1 -0
- package/registry/carousel.json +1 -0
- package/registry/chat.json +7 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
- package/registry/checkbox/files/src/lib/kit.tsx +363 -11
- package/registry/checkbox.json +1 -0
- package/registry/checkout.json +1 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +22 -22
- package/registry/chip-group/files/src/lib/kit.tsx +363 -11
- package/registry/chip-group.json +1 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
- package/registry/confetti.json +3 -6
- package/registry/coupon/files/src/components/ui/coupon.tsx +42 -42
- package/registry/coupon/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/coupon/files/src/lib/kit.tsx +363 -11
- package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/coupon.json +1 -0
- package/registry/cube-pager.json +1 -0
- package/registry/dashboard.json +1 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +74 -76
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +55 -56
- package/registry/date-picker/files/src/lib/kit.tsx +363 -11
- package/registry/date-picker.json +1 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +60 -56
- package/registry/dialog/files/src/lib/kit.tsx +363 -11
- package/registry/dialog.json +1 -0
- package/registry/donut-shop.json +1 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +73 -69
- package/registry/drawer/files/src/lib/kit.tsx +363 -11
- package/registry/drawer.json +1 -0
- package/registry/fab.json +1 -0
- package/registry/fintech-app.json +1 -0
- package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +92 -86
- package/registry/flip-calendar/files/src/lib/kit.tsx +363 -11
- package/registry/flip-calendar.json +7 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/flip-card/files/src/lib/kit.tsx +363 -11
- package/registry/flip-card.json +1 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/floating-input/files/src/lib/kit.tsx +363 -11
- package/registry/floating-input.json +1 -0
- package/registry/food-app.json +1 -0
- package/registry/gift-card.json +1 -0
- package/registry/glass-button.json +5 -0
- package/registry/glass-nav-bar.json +1 -0
- package/registry/glass-search-bar.json +1 -0
- package/registry/glass-segmented.json +1 -0
- package/registry/glass-sheet.json +1 -0
- package/registry/glass-tab-bar.json +5 -0
- package/registry/glass.json +1 -0
- package/registry/grain/files/src/components/ui/grain.tsx +16 -14
- package/registry/grain.json +8 -6
- package/registry/inbox.json +1 -0
- package/registry/isometric-bar.json +1 -0
- package/registry/kit/files/src/lib/kit.tsx +363 -11
- package/registry/kit.json +1 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +23 -9
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +2 -2
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
- package/registry/liquidglass-tab.json +1 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +42 -42
- package/registry/list-item/files/src/lib/kit.tsx +363 -11
- package/registry/list-item.json +1 -0
- package/registry/loyalty-card.json +1 -0
- package/registry/morphing-button-group.json +1 -0
- package/registry/number-ticker.json +1 -0
- package/registry/offers.json +1 -0
- package/registry/onboarding.json +1 -0
- package/registry/orb-reveal.json +1 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +48 -29
- package/registry/otp-input/files/src/lib/kit.tsx +363 -11
- package/registry/otp-input.json +1 -0
- package/registry/parallax-layers.json +1 -0
- package/registry/parallax-scroll.json +1 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
- package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
- package/registry/password-input/files/src/lib/kit.tsx +363 -11
- package/registry/password-input.json +1 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +6 -5
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +17 -17
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/payment-card/files/src/lib/kit.tsx +363 -11
- package/registry/payment-card.json +1 -0
- package/registry/paywall.json +1 -0
- package/registry/pixel-bar.json +1 -0
- package/registry/polar-area.json +1 -0
- package/registry/profile.json +1 -0
- package/registry/progress/files/src/components/ui/progress.tsx +29 -29
- package/registry/progress/files/src/lib/kit.tsx +363 -11
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +20 -20
- package/registry/progress-steps/files/src/lib/kit.tsx +363 -11
- package/registry/progress-steps.json +1 -0
- package/registry/progress.json +1 -0
- package/registry/qr-code-button.json +1 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
- package/registry/radio-group/files/src/lib/kit.tsx +363 -11
- package/registry/radio-group.json +1 -0
- package/registry/rating.json +1 -0
- package/registry/ring-carousel.json +1 -0
- package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +98 -82
- package/registry/ruler-time-picker/files/src/lib/kit.tsx +363 -11
- package/registry/ruler-time-picker.json +7 -0
- package/registry/scratch-card.json +1 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +25 -21
- package/registry/search-bar/files/src/lib/kit.tsx +363 -11
- package/registry/search-bar.json +1 -0
- package/registry/search-results.json +4 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +8 -7
- package/registry/segmented-control/files/src/lib/kit.tsx +363 -11
- package/registry/segmented-control.json +1 -0
- package/registry/select/files/src/components/ui/select.tsx +93 -88
- package/registry/select/files/src/lib/kit.tsx +363 -11
- package/registry/select.json +6 -0
- package/registry/settings.json +1 -0
- package/registry/sign-in.json +1 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
- package/registry/skeleton/files/src/lib/kit.tsx +363 -11
- package/registry/skeleton.json +1 -0
- package/registry/slide-to-confirm.json +1 -0
- package/registry/slider/files/src/components/ui/slider.tsx +103 -96
- package/registry/slider/files/src/lib/kit.tsx +363 -11
- package/registry/slider.json +5 -0
- package/registry/spin-wheel.json +1 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
- package/registry/spinner/files/src/lib/kit.tsx +363 -11
- package/registry/spinner.json +6 -0
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
- package/registry/stateful-button/files/src/lib/kit.tsx +363 -11
- package/registry/stateful-button.json +5 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +38 -36
- package/registry/stepper/files/src/lib/kit.tsx +363 -11
- package/registry/stepper.json +1 -0
- package/registry/swipe-deck.json +1 -0
- package/registry/swipeable-row.json +4 -0
- package/registry/switch/files/src/components/ui/switch.tsx +39 -42
- package/registry/switch/files/src/lib/kit.tsx +363 -11
- package/registry/switch.json +1 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +59 -62
- package/registry/tabs/files/src/lib/kit.tsx +363 -11
- package/registry/tabs.json +1 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
- package/registry/text-animation/files/src/lib/kit.tsx +363 -11
- package/registry/text-animation.json +7 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
- package/registry/textarea/files/src/lib/kit.tsx +363 -11
- package/registry/textarea.json +1 -0
- package/registry/ticket-card.json +1 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +363 -11
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
- package/registry/ticket-shape.json +1 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +76 -91
- package/registry/tilt-card/files/src/lib/kit.tsx +363 -11
- package/registry/tilt-card.json +1 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +122 -119
- package/registry/time-picker/files/src/lib/kit.tsx +363 -11
- package/registry/time-picker.json +1 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +84 -66
- package/registry/timeline/files/src/components/ui/timeline.tsx +44 -41
- package/registry/timeline/files/src/lib/kit.tsx +363 -11
- package/registry/timeline-chart.json +1 -0
- package/registry/timeline.json +1 -0
- package/registry/toast/files/src/components/ui/toast.tsx +541 -463
- package/registry/toast/files/src/lib/kit.tsx +363 -11
- package/registry/toast.json +7 -5
- package/registry/treemap.json +1 -0
- package/registry/waterfall.json +1 -0
- package/registry/wheel-list.json +1 -0
- package/schema/components.schema.json +1 -0
- package/registry/alert/files/src/components/ui/button.tsx +0 -290
- package/registry/confetti/files/src/lib/kit.tsx +0 -320
- package/registry/grain/files/src/lib/kit.tsx +0 -320
- package/registry/toast/files/src/components/ui/button.tsx +0 -290
package/bin/lib.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Pure helpers for the expo-kit CLI (no I/O, no dependencies) — kept separate so they can be unit-tested.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
|
|
7
8
|
/** Thrown for user-facing errors; the CLI entry point prints `message` and exits 1. */
|
|
@@ -346,13 +347,122 @@ export function needsDevBuild(deps) {
|
|
|
346
347
|
|
|
347
348
|
/**
|
|
348
349
|
* The component a file is named for: the PascalCase export matching one of `names` ("Grain Gradient" or
|
|
349
|
-
* "grain-gradient" → GrainGradient),
|
|
350
|
+
* "grain-gradient" → GrainGradient), then one that starts with it (Toast → Toaster, not ToastProvider: the shortest),
|
|
351
|
+
* else the first PascalCase export (never a CONSTANT).
|
|
350
352
|
*/
|
|
351
353
|
export function pickMainExport(source, names = []) {
|
|
352
354
|
const exports = [...source.matchAll(/export (?:function|const) ([A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*)\b/g)].map((m) => m[1]);
|
|
353
355
|
const pascal = (s) => String(s ?? '').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
354
|
-
const wanted = names.map(pascal).map((n) => n.toLowerCase());
|
|
355
|
-
|
|
356
|
+
const wanted = names.map(pascal).map((n) => n.toLowerCase()).filter(Boolean);
|
|
357
|
+
const exact = exports.find((e) => wanted.includes(e.toLowerCase()));
|
|
358
|
+
if (exact) return exact;
|
|
359
|
+
const prefixed = exports.filter((e) => wanted.some((w) => e.toLowerCase().startsWith(w))).sort((a, b) => a.length - b.length);
|
|
360
|
+
return prefixed[0] ?? exports[0] ?? null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// add: clashes, kit files, packages, suggestions, --dir
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The entry in a folder listing that differs from `name` only by case (`Skeleton.tsx` for `skeleton.tsx`), or null.
|
|
369
|
+
* macOS and Windows treat the two as one file, so writing ours would replace theirs; on Linux both would live side
|
|
370
|
+
* by side and break the repo for anyone who clones it on a Mac.
|
|
371
|
+
*/
|
|
372
|
+
export function caseClash(entries, name) {
|
|
373
|
+
const lower = name.toLowerCase();
|
|
374
|
+
return entries.find((e) => e !== name && e.toLowerCase() === lower) ?? null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether a file came from ExpoKit (kit.tsx itself, or a component built on it) rather than being the user's own. */
|
|
378
|
+
export function looksLikeKitFile(text) {
|
|
379
|
+
return /\bfrom\s+['"][^'"]*\/lib\/kit['"]/.test(text) || kitVersion(text) !== null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const bareName = (spec) => spec.replace(/^(@?[^@]+)@.*$/, '$1');
|
|
383
|
+
|
|
384
|
+
/** `deps` (npm specs, `pkg@1.2` allowed) that package.json doesn't list yet, deduped by package name. */
|
|
385
|
+
export function missingPackages(deps, pkg) {
|
|
386
|
+
const has = (name) =>
|
|
387
|
+
[pkg?.dependencies, pkg?.devDependencies].some((d) => d && typeof d === 'object' && Object.hasOwn(d, name));
|
|
388
|
+
const seen = new Set();
|
|
389
|
+
return (deps ?? []).filter((spec) => {
|
|
390
|
+
const name = bareName(spec);
|
|
391
|
+
if (seen.has(name) || has(name)) return false;
|
|
392
|
+
seen.add(name);
|
|
393
|
+
return true;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function editDistance(a, b) {
|
|
398
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
399
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
400
|
+
const row = [i];
|
|
401
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
402
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
403
|
+
}
|
|
404
|
+
prev = row;
|
|
405
|
+
}
|
|
406
|
+
return prev[b.length];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Up to 3 item names close to a mistyped one ("buton" → button, "sheet-bottom" → bottom-sheet), closest first. */
|
|
410
|
+
export function suggestItemNames(input, names) {
|
|
411
|
+
const want = String(input).toLowerCase();
|
|
412
|
+
const sorted = (s) => s.split('-').filter(Boolean).sort().join('-');
|
|
413
|
+
const flat = (s) => s.replace(/-/g, '');
|
|
414
|
+
const scored = [];
|
|
415
|
+
for (const name of new Set(names)) {
|
|
416
|
+
const d = editDistance(want, name);
|
|
417
|
+
let score = null;
|
|
418
|
+
if (sorted(want) === sorted(name) || flat(want) === flat(name)) score = 0;
|
|
419
|
+
else if (Math.min(want.length, name.length) >= 3 && (want.includes(name) || name.includes(want))) score = 1;
|
|
420
|
+
else if (d <= Math.max(1, Math.floor(want.length / 3))) score = 1 + d;
|
|
421
|
+
if (score !== null) scored.push({ name, score, d });
|
|
422
|
+
}
|
|
423
|
+
return scored
|
|
424
|
+
.sort((a, b) => a.score - b.score || a.d - b.d || a.name.localeCompare(b.name))
|
|
425
|
+
.slice(0, 3)
|
|
426
|
+
.map((s) => s.name);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Registry files import each other as `@/components/…` and `@/lib/…`. When components.json maps those to other
|
|
431
|
+
* aliases (or `add --dir` moved the components), point the imports there. Leaves everything else untouched.
|
|
432
|
+
*/
|
|
433
|
+
export function rewriteKitImports(text, aliases) {
|
|
434
|
+
let out = text;
|
|
435
|
+
for (const key of ['components', 'lib', 'hooks']) {
|
|
436
|
+
const to = aliases?.[key];
|
|
437
|
+
if (typeof to !== 'string' || to === `@/${key}`) continue;
|
|
438
|
+
out = out.replace(new RegExp(`(['"])@/${key}/`, 'g'), (_, q) => `${q}${to.replace(/\/+$/, '')}/`);
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The folder `@/*` points at in a parsed tsconfig ("src" for `./src/*`, "" for `./*`), or null when there is no
|
|
445
|
+
* such alias. Used to tell whether an `add --dir` folder can be imported as `@/…`.
|
|
446
|
+
*/
|
|
447
|
+
export function aliasRootFromTsconfig(tsconfig) {
|
|
448
|
+
const target = tsconfig?.compilerOptions?.paths?.['@/*']?.[0];
|
|
449
|
+
if (typeof target !== 'string' || !target.endsWith('*')) return null;
|
|
450
|
+
const base = typeof tsconfig.compilerOptions.baseUrl === 'string' ? tsconfig.compilerOptions.baseUrl : '.';
|
|
451
|
+
const joined = path.posix.normalize(path.posix.join(base, target.slice(0, -1)));
|
|
452
|
+
const clean = joined.replace(/\/+$/, '');
|
|
453
|
+
return clean === '.' ? '' : clean;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* `@/…` alias for a project-relative folder under the alias root (`src/components/kit` → `@/components/kit` when
|
|
458
|
+
* `@/*` is `./src/*`), or null when the folder is outside it, is the root itself, or isn't a plain folder path.
|
|
459
|
+
*/
|
|
460
|
+
export function aliasTargetForDir(dir, aliasRoot) {
|
|
461
|
+
const rel = String(dir).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
462
|
+
if (!rel || rel.startsWith('/') || /^[A-Za-z]:/.test(rel) || !/^[A-Za-z0-9._()/-]+$/.test(rel)) return null;
|
|
463
|
+
if (rel.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null;
|
|
464
|
+
if (!aliasRoot) return `@/${rel}`;
|
|
465
|
+
return rel.startsWith(`${aliasRoot}/`) ? `@/${rel.slice(aliasRoot.length + 1)}` : null;
|
|
356
466
|
}
|
|
357
467
|
|
|
358
468
|
/** Union of dependency lists, order preserved. */
|
|
@@ -364,6 +474,203 @@ export function mergeDependencies(...lists) {
|
|
|
364
474
|
return out;
|
|
365
475
|
}
|
|
366
476
|
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Install records (components.json `files` / `installed`), diffs, init theme
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/** sha256 (hex) of a file's text, line endings normalised so a git autocrlf checkout hashes the same. */
|
|
482
|
+
export function contentHash(text) {
|
|
483
|
+
return createHash('sha256').update(String(text).replace(/\r\n/g, '\n'), 'utf8').digest('hex');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The hash components.json recorded for a project file, or null. The file comes from the repo, so it is untrusted:
|
|
490
|
+
* only own, well-formed entries count, and its keys are only ever looked up — never used as paths to read or write.
|
|
491
|
+
*/
|
|
492
|
+
export function recordedHash(files, rel) {
|
|
493
|
+
if (!files || typeof files !== 'object' || Array.isArray(files) || !Object.hasOwn(files, rel)) return null;
|
|
494
|
+
const value = files[rel];
|
|
495
|
+
return typeof value === 'string' && HASH.test(value) ? value : null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* A project file against the registry's copy:
|
|
500
|
+
* current (same as upstream) · pristine (as the CLI wrote it, upstream moved on) ·
|
|
501
|
+
* modified (changed since it was written) · untracked (differs, and there is no record to tell why)
|
|
502
|
+
*/
|
|
503
|
+
export function fileStatus(recorded, have, upstream) {
|
|
504
|
+
const norm = (t) => String(t).replace(/\r\n/g, '\n');
|
|
505
|
+
if (norm(have) === norm(upstream)) return 'current';
|
|
506
|
+
if (!recorded) return 'untracked';
|
|
507
|
+
return contentHash(have) === recorded ? 'pristine' : 'modified';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* components.json `installed`, cleaned: `{ name: { version?, kitVersion?, dir? } }`. Older CLIs stored just the
|
|
512
|
+
* version string. Unknown names and odd values are dropped (the file is untrusted); `dir` is checked where it's used.
|
|
513
|
+
*/
|
|
514
|
+
export function normalizeInstalled(raw) {
|
|
515
|
+
const out = {};
|
|
516
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
517
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
518
|
+
if (!isValidItemName(name)) continue;
|
|
519
|
+
if (typeof value === 'string') {
|
|
520
|
+
out[name] = value.length <= 40 ? { version: value } : {};
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
524
|
+
const entry = {};
|
|
525
|
+
if (typeof value.version === 'string' && value.version.length <= 40) entry.version = value.version;
|
|
526
|
+
if (Number.isInteger(value.kitVersion) && value.kitVersion > 0) entry.kitVersion = value.kitVersion;
|
|
527
|
+
if (typeof value.dir === 'string' && value.dir.length <= 200) entry.dir = value.dir;
|
|
528
|
+
out[name] = entry;
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const splitLines = (text) => {
|
|
534
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
535
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
536
|
+
return lines;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/** Edit script (' ', '-', '+' per line) by LCS; past `maxCells` the changed middle is one remove + add block. */
|
|
540
|
+
function diffOps(a, b, maxCells) {
|
|
541
|
+
let pre = 0;
|
|
542
|
+
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre += 1;
|
|
543
|
+
let suf = 0;
|
|
544
|
+
while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf += 1;
|
|
545
|
+
const x = a.slice(pre, a.length - suf);
|
|
546
|
+
const y = b.slice(pre, b.length - suf);
|
|
547
|
+
const ops = a.slice(0, pre).map((line) => [' ', line]);
|
|
548
|
+
if (x.length * y.length > maxCells) {
|
|
549
|
+
ops.push(...x.map((line) => ['-', line]), ...y.map((line) => ['+', line]));
|
|
550
|
+
} else {
|
|
551
|
+
const w = y.length + 1;
|
|
552
|
+
const lcs = new Uint32Array((x.length + 1) * w);
|
|
553
|
+
for (let i = x.length - 1; i >= 0; i -= 1) {
|
|
554
|
+
for (let j = y.length - 1; j >= 0; j -= 1) {
|
|
555
|
+
lcs[i * w + j] = x[i] === y[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
let i = 0;
|
|
559
|
+
let j = 0;
|
|
560
|
+
while (i < x.length || j < y.length) {
|
|
561
|
+
if (i < x.length && j < y.length && x[i] === y[j]) {
|
|
562
|
+
ops.push([' ', x[i]]);
|
|
563
|
+
i += 1;
|
|
564
|
+
j += 1;
|
|
565
|
+
} else if (i < x.length && (j === y.length || lcs[(i + 1) * w + j] >= lcs[i * w + j + 1])) {
|
|
566
|
+
ops.push(['-', x[i]]);
|
|
567
|
+
i += 1;
|
|
568
|
+
} else {
|
|
569
|
+
ops.push(['+', y[j]]);
|
|
570
|
+
j += 1;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
ops.push(...a.slice(a.length - suf).map((line) => [' ', line]));
|
|
575
|
+
return ops;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** A unified diff (`--- from` / `+++ to` / `@@` hunks) of two texts, or '' when they match (CRLF vs LF is no change). */
|
|
579
|
+
export function unifiedDiff(a, b, { from = 'a', to = 'b', context = 3, maxCells = 4_000_000 } = {}) {
|
|
580
|
+
const na = String(a).replace(/\r\n/g, '\n');
|
|
581
|
+
const nb = String(b).replace(/\r\n/g, '\n');
|
|
582
|
+
if (na === nb) return '';
|
|
583
|
+
const head = `--- ${from}\n+++ ${to}\n`;
|
|
584
|
+
const ops = diffOps(splitLines(na), splitLines(nb), maxCells);
|
|
585
|
+
const changed = ops.flatMap((op, i) => (op[0] === ' ' ? [] : [i]));
|
|
586
|
+
if (!changed.length) return `${head}(only the newline at the end of the file differs)\n`;
|
|
587
|
+
|
|
588
|
+
// Line numbers before each op, for the @@ headers.
|
|
589
|
+
const at = [];
|
|
590
|
+
let la = 1;
|
|
591
|
+
let lb = 1;
|
|
592
|
+
for (const [kind] of ops) {
|
|
593
|
+
at.push([la, lb]);
|
|
594
|
+
if (kind !== '+') la += 1;
|
|
595
|
+
if (kind !== '-') lb += 1;
|
|
596
|
+
}
|
|
597
|
+
let out = head;
|
|
598
|
+
let k = 0;
|
|
599
|
+
while (k < changed.length) {
|
|
600
|
+
const start = Math.max(0, changed[k] - context);
|
|
601
|
+
let end = changed[k];
|
|
602
|
+
while (k + 1 < changed.length && changed[k + 1] - end <= context * 2 + 1) end = changed[(k += 1)];
|
|
603
|
+
k += 1;
|
|
604
|
+
end = Math.min(ops.length - 1, end + context);
|
|
605
|
+
const slice = ops.slice(start, end + 1);
|
|
606
|
+
const countA = slice.filter(([kind]) => kind !== '+').length;
|
|
607
|
+
const countB = slice.filter(([kind]) => kind !== '-').length;
|
|
608
|
+
const [sa, sb] = at[start];
|
|
609
|
+
out += `@@ -${countA ? sa : sa - 1},${countA} +${countB ? sb : sb - 1},${countB} @@\n`;
|
|
610
|
+
out += slice.map(([kind, line]) => `${kind}${line}\n`).join('');
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** `init` theme answers, checked: they are written into a .tsx file, so only plain values get through. */
|
|
616
|
+
export function themeOptions({ brand, font, radius, scheme } = {}) {
|
|
617
|
+
const out = { brand: null, font: null, radius: null, scheme: 'system' };
|
|
618
|
+
if (brand !== undefined && brand !== null && brand !== '') {
|
|
619
|
+
const hex = String(brand).trim().replace(/^#?/, '#').toLowerCase();
|
|
620
|
+
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(hex)) throw new CliError(`--brand must be a hex colour like #6366f1 (got ${JSON.stringify(brand)}).`);
|
|
621
|
+
out.brand = hex.length === 4 ? `#${[...hex.slice(1)].map((c) => c + c).join('')}` : hex;
|
|
622
|
+
}
|
|
623
|
+
if (font !== undefined && font !== null && font !== '') {
|
|
624
|
+
const name = String(font).trim();
|
|
625
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$/.test(name)) throw new CliError(`--font must be a font family name like Inter (got ${JSON.stringify(font)}).`);
|
|
626
|
+
out.font = name;
|
|
627
|
+
}
|
|
628
|
+
if (radius !== undefined && radius !== null && radius !== '') {
|
|
629
|
+
const text = String(radius).trim();
|
|
630
|
+
if (!/^\d{1,2}$/.test(text) || Number(text) > 48) throw new CliError(`--radius must be a whole number of points from 0 to 48 (got ${JSON.stringify(radius)}).`);
|
|
631
|
+
out.radius = Number(text);
|
|
632
|
+
}
|
|
633
|
+
if (scheme !== undefined && scheme !== null && scheme !== '') {
|
|
634
|
+
const s = String(scheme).trim().toLowerCase();
|
|
635
|
+
if (!['system', 'light', 'dark'].includes(s)) throw new CliError(`--scheme must be light, dark or system (got ${JSON.stringify(scheme)}).`);
|
|
636
|
+
out.scheme = s;
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The `AppTheme` wrapper `init` writes next to kit.tsx: KitProvider with the chosen theme, and a place for your tokens. */
|
|
642
|
+
export function kitThemeSource({ brand, font, radius, scheme }, libAlias = '@/lib') {
|
|
643
|
+
const props = [`scheme="${scheme}"`];
|
|
644
|
+
if (brand) props.push(`accent="${brand}"`);
|
|
645
|
+
if (radius !== null && radius !== undefined) props.push(`radius={${radius}}`);
|
|
646
|
+
if (font) props.push(`fontFamily="${font}"`);
|
|
647
|
+
return `import type { ReactNode } from 'react';
|
|
648
|
+
|
|
649
|
+
import { KitProvider } from '${String(libAlias).replace(/\/+$/, '')}/kit';
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Your app's ExpoKit theme, written by \`expo-kit init\`. Wrap your root layout in it:
|
|
653
|
+
* <AppTheme><Stack /></AppTheme>
|
|
654
|
+
* Already have a design system? Point the kit tokens at it in \`colors\`:
|
|
655
|
+
* https://expokit.in/docs/design-tokens
|
|
656
|
+
*/
|
|
657
|
+
export function AppTheme({ children }: { children: ReactNode }) {
|
|
658
|
+
return (
|
|
659
|
+
<KitProvider
|
|
660
|
+
${props.join('\n ')}
|
|
661
|
+
colors={{
|
|
662
|
+
// light: { background: tokens.surface, foreground: tokens.text, muted: tokens.subtle, border: tokens.line },
|
|
663
|
+
light: {},
|
|
664
|
+
dark: {},
|
|
665
|
+
}}
|
|
666
|
+
>
|
|
667
|
+
{children}
|
|
668
|
+
</KitProvider>
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
`;
|
|
672
|
+
}
|
|
673
|
+
|
|
367
674
|
// ---------------------------------------------------------------------------
|
|
368
675
|
// Site URL + token guard
|
|
369
676
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expokit/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.Anshkaran7/expokit",
|
|
5
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",
|
|
6
6
|
"license": "MIT",
|
|
@@ -86,6 +86,12 @@
|
|
|
86
86
|
"tone colour",
|
|
87
87
|
"Override the tone colour."
|
|
88
88
|
],
|
|
89
|
+
[
|
|
90
|
+
"dismissLabel",
|
|
91
|
+
"string",
|
|
92
|
+
"labels.dismiss",
|
|
93
|
+
"Screen-reader name of the dismiss button. Set it app-wide with KitProvider's labels."
|
|
94
|
+
],
|
|
89
95
|
[
|
|
90
96
|
"style",
|
|
91
97
|
"StyleProp<ViewStyle>",
|
|
@@ -68,6 +68,12 @@
|
|
|
68
68
|
"with a title",
|
|
69
69
|
"Round ✕ in the header (shown when dismissible, there's a title and no headerRight)."
|
|
70
70
|
],
|
|
71
|
+
[
|
|
72
|
+
"closeLabel",
|
|
73
|
+
"string",
|
|
74
|
+
"labels.close",
|
|
75
|
+
"Screen-reader name of the close button (default: the kit's labels.close)."
|
|
76
|
+
],
|
|
71
77
|
[
|
|
72
78
|
"handle",
|
|
73
79
|
"boolean",
|
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
"string",
|
|
26
26
|
"auto summary",
|
|
27
27
|
"What screen readers announce; defaults to a summary of the data."
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"accessibilityHint",
|
|
31
|
+
"string",
|
|
32
|
+
"\"Swipe up or down to read each …\"",
|
|
33
|
+
"Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
|
|
28
34
|
]
|
|
29
35
|
],
|
|
30
36
|
"behavior": [],
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
[
|
|
96
96
|
"accessibilityLabel",
|
|
97
97
|
"string",
|
|
98
|
-
"
|
|
98
|
+
"labels.navigationDrawer",
|
|
99
99
|
"Panel label."
|
|
100
100
|
],
|
|
101
101
|
[
|
|
@@ -112,9 +112,9 @@
|
|
|
112
112
|
],
|
|
113
113
|
[
|
|
114
114
|
"DrawerHeader",
|
|
115
|
-
"{ title?, subtitle?, leading?, showClose?, onClose? }",
|
|
115
|
+
"{ title?, subtitle?, leading?, showClose?, onClose?, closeLabel? }",
|
|
116
116
|
"—",
|
|
117
|
-
"Title chrome for the header slot."
|
|
117
|
+
"Title chrome for the header slot. closeLabel names the close button (default labels.closeMenu)."
|
|
118
118
|
],
|
|
119
119
|
[
|
|
120
120
|
"DrawerItem",
|
|
@@ -73,6 +73,12 @@
|
|
|
73
73
|
"string",
|
|
74
74
|
"auto summary",
|
|
75
75
|
"What screen readers announce; defaults to a summary of the data."
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"accessibilityHint",
|
|
79
|
+
"string",
|
|
80
|
+
"\"Swipe up or down to read each …\"",
|
|
81
|
+
"Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
|
|
76
82
|
]
|
|
77
83
|
],
|
|
78
84
|
"behavior": [],
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
{
|
|
26
26
|
"name": "Preview",
|
|
27
27
|
"title": "Preview",
|
|
28
|
-
"code": "import React, { useEffect, useRef, useState } from 'react';\nimport { Animated, Image, Platform, Pressable, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';\nimport { BlurView } from 'expo-blur';\nimport Svg, { Circle, Path } from 'react-native-svg';\nimport { useKit } from '@/lib/kit';\n\n/** Control text stops at iOS's largest standard size. */\nconst FONT_CAP = 1.35;\n\ntype TabKey = 'index' | 'explore' | 'alerts' | 'profile';\n\nconst TABS: { key: TabKey; title: string; badge?: string; icon: (c: string) => React.ReactNode }[] = [\n { key: 'index', title: 'Home', icon: (c) => <Path d=\"M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'explore',\n title: 'Explore',\n icon: (c) => (\n <>\n <Circle cx={12} cy={12} r={9} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"m15.5 8.5-2 5-5 2 2-5z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" />\n </>\n ),\n },\n { key: 'alerts', title: 'Alerts', badge: '3', icon: (c) => <Path d=\"M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21h4\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'profile',\n title: 'Profile',\n icon: (c) => (\n <>\n <Circle cx={12} cy={8} r={4} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" fill=\"none\" />\n </>\n ),\n },\n];\n\nconst PHOTOS = [\n 'photo-1501785888041-af3ef285b470',\n 'photo-1469474968028-56623f02e42e',\n 'photo-1500530855697-b586d89ba3ee',\n 'photo-1470071459604-3b5ec3a7fe05',\n 'photo-1441974231531-c6227db76b6e',\n].map((id) => `https://images.unsplash.com/${id}?w=700&auto=format&fit=crop`);\n\nconst COPY: Record<TabKey, { title: string; sub: string }> = {\n index: { title: 'For you', sub: 'Places picked for this weekend' },\n explore: { title: 'Explore', sub: 'Trending trails near you' },\n alerts: { title: 'Alerts', sub: '3 new since yesterday' },\n profile: { title: 'Profile', sub: 'Trips, saves and settings' },\n};\n\nconst BAR_H = 64;\n\nfunction GlassBar({\n value,\n onChange,\n dark,\n collapsed,\n onExpand,\n}: {\n value: TabKey;\n onChange: (k: TabKey) => void;\n dark: boolean;\n collapsed: boolean;\n onExpand: () => void;\n}) {\n const [w, setW] = useState(0);\n // 0 = full bar, 1 = folded into a circle (JS driver: it animates the bar's width).\n const fold = useRef(new Animated.Value(0)).current;\n useEffect(() => {\n Animated.spring(fold, { toValue: collapsed ? 1 : 0, useNativeDriver: false, damping: 29, stiffness: 220, mass: 0.9 }).start();\n }, [collapsed, fold]);\n const width = fold.interpolate({ inputRange: [0, 1], outputRange: [w || 1, BAR_H] });\n const rowOpacity = fold.interpolate({ inputRange: [0, 0.45], outputRange: [1, 0], extrapolate: 'clamp' });\n const dotOpacity = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0, 1], extrapolate: 'clamp' });\n const dotScale = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0.6, 1], extrapolate: 'clamp' });\n const active = TABS.find((t) => t.key === value) ?? TABS[0];\n const x = useRef(new Animated.Value(0)).current;\n const stretch = useRef(new Animated.Value(1)).current;\n const index = TABS.findIndex((t) => t.key === value);\n const itemW = w ? (w - 8) / TABS.length : 0;\n const tint = dark ? '#0a84ff' : '#007aff';\n const inactive = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n\n useEffect(() => {\n if (!itemW) return;\n Animated.parallel([\n Animated.spring(x, { toValue: index * itemW, useNativeDriver: true, damping: 29, stiffness: 220, mass: 0.9 }),\n Animated.sequence([\n Animated.timing(stretch, { toValue: 1.22, duration: 110, useNativeDriver: true }),\n Animated.spring(stretch, { toValue: 1, useNativeDriver: true, damping: 33, stiffness: 260 }),\n ]),\n ]).start();\n }, [index, itemW, x, stretch]);\n\n return (\n <View pointerEvents=\"box-none\" style={styles.host} onLayout={(e: LayoutChangeEvent) => setW(Math.round(e.nativeEvent.layout.width))}>\n <Animated.View style={[styles.shadow, dark ? styles.shadowDark : styles.shadowLight, w ? { width } : null]}>\n <View style={[styles.glass, { borderColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.8)' }]}>\n <BlurView intensity={Platform.OS === 'ios' ? 70 : 40} tint={dark ? 'dark' : 'light'} style={[StyleSheet.absoluteFill, styles.round]} />\n <View style={[StyleSheet.absoluteFill, { backgroundColor: dark ? 'rgba(28,28,34,0.5)' : 'rgba(255,255,255,0.55)' }]} />\n <View style={[styles.rim, { backgroundColor: dark ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.95)' }]} />\n {itemW ? (\n <Animated.View\n style={[\n styles.bubble,\n {\n width: itemW,\n backgroundColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.06)',\n transform: [{ translateX: x }, { scaleX: stretch }, { scaleY: stretch.interpolate({ inputRange: [1, 1.22], outputRange: [1, 0.88] }) }],\n },\n ]}\n />\n ) : null}\n <Animated.View pointerEvents={collapsed ? 'none' : 'auto'} style={[styles.row, w ? { width: w } : null, { opacity: rowOpacity }]}>\n {TABS.map((t) => {\n const on = t.key === value;\n const c = on ? tint : inactive;\n return (\n <Pressable\n key={t.key}\n onPress={() => onChange(t.key)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: on }}\n accessibilityLabel={t.title}\n style={styles.item}\n >\n <View>\n <Svg width={24} height={24} viewBox=\"0 0 24 24\">\n {t.icon(c)}\n </Svg>\n {t.badge ? (\n <View style={styles.badge}>\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.badgeText}>{t.badge}</Text>\n </View>\n ) : null}\n </View>\n <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.label, { color: c }]}>{t.title}</Text>\n </Pressable>\n );\n })}\n </Animated.View>\n <Animated.View pointerEvents={collapsed ? 'auto' : 'none'} style={[styles.dot, { opacity: dotOpacity, transform: [{ scale: dotScale }] }]}>\n <Pressable onPress={onExpand} accessibilityRole=\"button\" accessibilityLabel={`${active.title}. Show tab bar`} style={styles.dotPress}>\n <Svg width={26} height={26} viewBox=\"0 0 24 24\">\n {active.icon(tint)}\n </Svg>\n </Pressable>\n </Animated.View>\n </View>\n </Animated.View>\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n root: { flex: 1 },\n feed: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 120, gap: 12 },\n large: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8, fontWeight: '400' },\n sub: { fontSize: 15, marginTop: -6, marginBottom: 6 },\n card: { height: 190, borderRadius: 22, overflow: 'hidden', justifyContent: 'flex-end', padding: 16 },\n cardShade: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.18)' },\n cardTitle: { color: '#ffffff', fontSize: 20, fontWeight: '400', letterSpacing: -0.3 },\n host: { position: 'absolute', left: 20, right: 20, bottom: 24 },\n shadow: { height: BAR_H, borderRadius: BAR_H / 2 },\n shadowLight: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.22)' },\n shadowDark: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.7)' },\n glass: { flex: 1, borderRadius: 32, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },\n round: { borderRadius: 32 },\n rim: { position: 'absolute', top: 0, left: 24, right: 24, height: 1 },\n bubble: { position: 'absolute', top: 4, bottom: 4, left: 4, borderRadius: 28 },\n row: { flex: 1, flexDirection: 'row', paddingHorizontal: 4 },\n dot: { position: 'absolute', top: 0, left: 0, width: BAR_H, height: BAR_H },\n dotPress: { flex: 1, alignItems: 'center', justifyContent: 'center' },\n item: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: 10.5, fontWeight: '400' },\n badge: { position: 'absolute', top: -4, right: -10, minWidth: 17, minHeight: 17, borderRadius: 9, paddingHorizontal: 4, backgroundColor: '#ff3b30', alignItems: 'center', justifyContent: 'center' },\n badgeText: { color: '#ffffff', fontSize: 10.5, fontWeight: '400' },\n});\n\nexport default function Preview() {\n const { dark } = useKit();\n const [tab, setTab] = useState<TabKey>('index');\n const [collapsed, setCollapsed] = useState(false);\n const lastY = useRef(0);\n const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n const y = e.nativeEvent.contentOffset.y;\n const dy = y - lastY.current;\n lastY.current = y;\n if (y < 24) setCollapsed(false);\n else if (dy > 6) setCollapsed(true);\n else if (dy < -6) setCollapsed(false);\n };\n const fg = dark ? '#ffffff' : '#1c1c1e';\n const sub = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n const offset = TABS.findIndex((t) => t.key === tab);\n const copy = COPY[tab];\n\n return (\n <View style={[styles.root, { backgroundColor: dark ? '#000000' : '#f2f2f7' }]}>\n <ScrollView contentContainerStyle={styles.feed} showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={16}>\n <Text style={[styles.large, { color: fg }]}>{copy.title}</Text>\n <Text style={[styles.sub, { color: sub }]}>{copy.sub}</Text>\n {[0, 1, 2, 3, 4].map((i) => {\n const p = PHOTOS[(i + offset) % PHOTOS.length];\n return (\n <View key={`${tab}-${i}`} style={styles.card}>\n <Image source={{ uri: p }} alt=\"\" style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n <View style={styles.cardShade} />\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.cardTitle}>{['Lake Braies', 'Dolomites', 'Valley trail', 'Blue ridge', 'Old forest'][(i + offset) % 5]}</Text>\n </View>\n );\n })}\n </ScrollView>\n <GlassBar value={tab} onChange={setTab} dark={dark} collapsed={collapsed} onExpand={() => setCollapsed(false)} />\n </View>\n );\n}\n"
|
|
28
|
+
"code": "import React, { useEffect, useRef, useState } from 'react';\nimport { Animated, Image, Platform, Pressable, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';\nimport { BlurView } from 'expo-blur';\nimport Svg, { Circle, Path } from 'react-native-svg';\nimport { useKit } from '@/lib/kit';\n\n/** Control text stops at iOS's largest standard size. */\nconst FONT_CAP = 1.35;\n\ntype TabKey = 'index' | 'explore' | 'alerts' | 'profile';\n\ntype DemoTab = { key: TabKey; title: string; badge?: string; icon: (c: string) => React.ReactNode };\n\nconst TABS: [DemoTab, ...DemoTab[]] = [\n { key: 'index', title: 'Home', icon: (c) => <Path d=\"M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'explore',\n title: 'Explore',\n icon: (c) => (\n <>\n <Circle cx={12} cy={12} r={9} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"m15.5 8.5-2 5-5 2 2-5z\" stroke={c} strokeWidth={2} strokeLinejoin=\"round\" fill=\"none\" />\n </>\n ),\n },\n { key: 'alerts', title: 'Alerts', badge: '3', icon: (c) => <Path d=\"M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21h4\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" fill=\"none\" /> },\n {\n key: 'profile',\n title: 'Profile',\n icon: (c) => (\n <>\n <Circle cx={12} cy={8} r={4} stroke={c} strokeWidth={2} fill=\"none\" />\n <Path d=\"M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6\" stroke={c} strokeWidth={2} strokeLinecap=\"round\" fill=\"none\" />\n </>\n ),\n },\n];\n\nconst PHOTOS = [\n 'photo-1501785888041-af3ef285b470',\n 'photo-1469474968028-56623f02e42e',\n 'photo-1500530855697-b586d89ba3ee',\n 'photo-1470071459604-3b5ec3a7fe05',\n 'photo-1441974231531-c6227db76b6e',\n].map((id) => `https://images.unsplash.com/${id}?w=700&auto=format&fit=crop`);\n\nconst COPY: Record<TabKey, { title: string; sub: string }> = {\n index: { title: 'For you', sub: 'Places picked for this weekend' },\n explore: { title: 'Explore', sub: 'Trending trails near you' },\n alerts: { title: 'Alerts', sub: '3 new since yesterday' },\n profile: { title: 'Profile', sub: 'Trips, saves and settings' },\n};\n\nconst BAR_H = 64;\n\nfunction GlassBar({\n value,\n onChange,\n dark,\n collapsed,\n onExpand,\n}: {\n value: TabKey;\n onChange: (k: TabKey) => void;\n dark: boolean;\n collapsed: boolean;\n onExpand: () => void;\n}) {\n const [w, setW] = useState(0);\n // 0 = full bar, 1 = folded into a circle (JS driver: it animates the bar's width).\n const fold = useRef(new Animated.Value(0)).current;\n useEffect(() => {\n Animated.spring(fold, { toValue: collapsed ? 1 : 0, useNativeDriver: false, damping: 29, stiffness: 220, mass: 0.9 }).start();\n }, [collapsed, fold]);\n const width = fold.interpolate({ inputRange: [0, 1], outputRange: [w || 1, BAR_H] });\n const rowOpacity = fold.interpolate({ inputRange: [0, 0.45], outputRange: [1, 0], extrapolate: 'clamp' });\n const dotOpacity = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0, 1], extrapolate: 'clamp' });\n const dotScale = fold.interpolate({ inputRange: [0.55, 1], outputRange: [0.6, 1], extrapolate: 'clamp' });\n const active = TABS.find((t) => t.key === value) ?? TABS[0];\n const x = useRef(new Animated.Value(0)).current;\n const stretch = useRef(new Animated.Value(1)).current;\n const index = TABS.findIndex((t) => t.key === value);\n const itemW = w ? (w - 8) / TABS.length : 0;\n const tint = dark ? '#0a84ff' : '#007aff';\n const inactive = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n\n useEffect(() => {\n if (!itemW) return;\n Animated.parallel([\n Animated.spring(x, { toValue: index * itemW, useNativeDriver: true, damping: 29, stiffness: 220, mass: 0.9 }),\n Animated.sequence([\n Animated.timing(stretch, { toValue: 1.22, duration: 110, useNativeDriver: true }),\n Animated.spring(stretch, { toValue: 1, useNativeDriver: true, damping: 33, stiffness: 260 }),\n ]),\n ]).start();\n }, [index, itemW, x, stretch]);\n\n return (\n <View pointerEvents=\"box-none\" style={styles.host} onLayout={(e: LayoutChangeEvent) => setW(Math.round(e.nativeEvent.layout.width))}>\n <Animated.View style={[styles.shadow, dark ? styles.shadowDark : styles.shadowLight, w ? { width } : null]}>\n <View style={[styles.glass, { borderColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.8)' }]}>\n <BlurView intensity={Platform.OS === 'ios' ? 70 : 40} tint={dark ? 'dark' : 'light'} style={[StyleSheet.absoluteFill, styles.round]} />\n <View style={[StyleSheet.absoluteFill, { backgroundColor: dark ? 'rgba(28,28,34,0.5)' : 'rgba(255,255,255,0.55)' }]} />\n <View style={[styles.rim, { backgroundColor: dark ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.95)' }]} />\n {itemW ? (\n <Animated.View\n style={[\n styles.bubble,\n {\n width: itemW,\n backgroundColor: dark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.06)',\n transform: [{ translateX: x }, { scaleX: stretch }, { scaleY: stretch.interpolate({ inputRange: [1, 1.22], outputRange: [1, 0.88] }) }],\n },\n ]}\n />\n ) : null}\n <Animated.View pointerEvents={collapsed ? 'none' : 'auto'} style={[styles.row, w ? { width: w } : null, { opacity: rowOpacity }]}>\n {TABS.map((t) => {\n const on = t.key === value;\n const c = on ? tint : inactive;\n return (\n <Pressable\n key={t.key}\n onPress={() => onChange(t.key)}\n accessibilityRole=\"tab\"\n accessibilityState={{ selected: on }}\n accessibilityLabel={t.title}\n style={styles.item}\n >\n <View>\n <Svg width={24} height={24} viewBox=\"0 0 24 24\">\n {t.icon(c)}\n </Svg>\n {t.badge ? (\n <View style={styles.badge}>\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.badgeText}>{t.badge}</Text>\n </View>\n ) : null}\n </View>\n <Text maxFontSizeMultiplier={FONT_CAP} style={[styles.label, { color: c }]}>{t.title}</Text>\n </Pressable>\n );\n })}\n </Animated.View>\n <Animated.View pointerEvents={collapsed ? 'auto' : 'none'} style={[styles.dot, { opacity: dotOpacity, transform: [{ scale: dotScale }] }]}>\n <Pressable onPress={onExpand} accessibilityRole=\"button\" accessibilityLabel={`${active.title}. Show tab bar`} style={styles.dotPress}>\n <Svg width={26} height={26} viewBox=\"0 0 24 24\">\n {active.icon(tint)}\n </Svg>\n </Pressable>\n </Animated.View>\n </View>\n </Animated.View>\n </View>\n );\n}\n\nconst styles = StyleSheet.create({\n root: { flex: 1 },\n feed: { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 120, gap: 12 },\n large: { fontSize: 32, lineHeight: 38, letterSpacing: -0.8, fontWeight: '400' },\n sub: { fontSize: 15, marginTop: -6, marginBottom: 6 },\n card: { height: 190, borderRadius: 22, overflow: 'hidden', justifyContent: 'flex-end', padding: 16 },\n cardShade: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.18)' },\n cardTitle: { color: '#ffffff', fontSize: 20, fontWeight: '400', letterSpacing: -0.3 },\n host: { position: 'absolute', left: 20, right: 20, bottom: 24 },\n shadow: { height: BAR_H, borderRadius: BAR_H / 2 },\n shadowLight: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.22)' },\n shadowDark: { boxShadow: '0 10px 28px -8px rgba(0,0,0,0.7)' },\n glass: { flex: 1, borderRadius: 32, overflow: 'hidden', borderWidth: StyleSheet.hairlineWidth * 2 },\n round: { borderRadius: 32 },\n rim: { position: 'absolute', top: 0, left: 24, right: 24, height: 1 },\n bubble: { position: 'absolute', top: 4, bottom: 4, left: 4, borderRadius: 28 },\n row: { flex: 1, flexDirection: 'row', paddingHorizontal: 4 },\n dot: { position: 'absolute', top: 0, left: 0, width: BAR_H, height: BAR_H },\n dotPress: { flex: 1, alignItems: 'center', justifyContent: 'center' },\n item: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 2 },\n label: { fontSize: 10.5, fontWeight: '400' },\n badge: { position: 'absolute', top: -4, right: -10, minWidth: 17, minHeight: 17, borderRadius: 9, paddingHorizontal: 4, backgroundColor: '#ff3b30', alignItems: 'center', justifyContent: 'center' },\n badgeText: { color: '#ffffff', fontSize: 10.5, fontWeight: '400' },\n});\n\nexport default function Preview() {\n const { dark } = useKit();\n const [tab, setTab] = useState<TabKey>('index');\n const [collapsed, setCollapsed] = useState(false);\n const lastY = useRef(0);\n const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {\n const y = e.nativeEvent.contentOffset.y;\n const dy = y - lastY.current;\n lastY.current = y;\n if (y < 24) setCollapsed(false);\n else if (dy > 6) setCollapsed(true);\n else if (dy < -6) setCollapsed(false);\n };\n const fg = dark ? '#ffffff' : '#1c1c1e';\n const sub = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';\n const offset = TABS.findIndex((t) => t.key === tab);\n const copy = COPY[tab];\n\n return (\n <View style={[styles.root, { backgroundColor: dark ? '#000000' : '#f2f2f7' }]}>\n <ScrollView contentContainerStyle={styles.feed} showsVerticalScrollIndicator={false} onScroll={onScroll} scrollEventThrottle={16}>\n <Text style={[styles.large, { color: fg }]}>{copy.title}</Text>\n <Text style={[styles.sub, { color: sub }]}>{copy.sub}</Text>\n {[0, 1, 2, 3, 4].map((i) => {\n const p = PHOTOS[(i + offset) % PHOTOS.length];\n return (\n <View key={`${tab}-${i}`} style={styles.card}>\n <Image source={{ uri: p }} alt=\"\" style={StyleSheet.absoluteFill} resizeMode=\"cover\" />\n <View style={styles.cardShade} />\n <Text maxFontSizeMultiplier={FONT_CAP} style={styles.cardTitle}>{['Lake Braies', 'Dolomites', 'Valley trail', 'Blue ridge', 'Old forest'][(i + offset) % 5]}</Text>\n </View>\n );\n })}\n </ScrollView>\n <GlassBar value={tab} onChange={setTab} dark={dark} collapsed={collapsed} onExpand={() => setCollapsed(false)} />\n </View>\n );\n}\n"
|
|
29
29
|
}
|
|
30
30
|
]
|
|
31
31
|
}
|
|
@@ -67,6 +67,12 @@
|
|
|
67
67
|
"string",
|
|
68
68
|
"auto summary",
|
|
69
69
|
"What screen readers announce; defaults to a summary of the data."
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
"accessibilityHint",
|
|
73
|
+
"string",
|
|
74
|
+
"\"Swipe up or down to read each …\"",
|
|
75
|
+
"Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
|
|
70
76
|
]
|
|
71
77
|
],
|
|
72
78
|
"behavior": [],
|
|
@@ -79,6 +79,12 @@
|
|
|
79
79
|
"string",
|
|
80
80
|
"auto summary",
|
|
81
81
|
"What screen readers announce; defaults to a summary of the data."
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"accessibilityHint",
|
|
85
|
+
"string",
|
|
86
|
+
"\"Swipe up or down to read each …\"",
|
|
87
|
+
"Screen-reader hint for the swipe-to-read gesture; pass your own to translate it."
|
|
82
88
|
]
|
|
83
89
|
],
|
|
84
90
|
"behavior": [],
|
|
@@ -17,21 +17,27 @@
|
|
|
17
17
|
[
|
|
18
18
|
"label",
|
|
19
19
|
"string",
|
|
20
|
-
"
|
|
20
|
+
"labels.showQrCode",
|
|
21
21
|
"Text on the pill."
|
|
22
22
|
],
|
|
23
23
|
[
|
|
24
24
|
"copyLabel",
|
|
25
25
|
"string",
|
|
26
|
-
"
|
|
26
|
+
"labels.copy",
|
|
27
27
|
"Copy button text."
|
|
28
28
|
],
|
|
29
29
|
[
|
|
30
30
|
"copiedLabel",
|
|
31
31
|
"string",
|
|
32
|
-
"
|
|
32
|
+
"labels.copied",
|
|
33
33
|
"Shown after Copy is pressed."
|
|
34
34
|
],
|
|
35
|
+
[
|
|
36
|
+
"closeLabel",
|
|
37
|
+
"string",
|
|
38
|
+
"labels.close",
|
|
39
|
+
"Screen-reader name of the card's close button. Set it app-wide with KitProvider's labels."
|
|
40
|
+
],
|
|
35
41
|
[
|
|
36
42
|
"onCopy",
|
|
37
43
|
"(value: string) => void",
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
"\"Scratch to reveal\" / —",
|
|
27
27
|
"Printed on the foil."
|
|
28
28
|
],
|
|
29
|
+
[
|
|
30
|
+
"revealHint",
|
|
31
|
+
"string",
|
|
32
|
+
"\"Double-tap to reveal\"",
|
|
33
|
+
"Screen-reader hint on the foil; pass your own to translate it."
|
|
34
|
+
],
|
|
29
35
|
[
|
|
30
36
|
"threshold",
|
|
31
37
|
"number",
|
|
@@ -102,7 +108,7 @@
|
|
|
102
108
|
],
|
|
103
109
|
[
|
|
104
110
|
"Screen readers",
|
|
105
|
-
"The foil is a button labelled with label and sublabel, with the hint 'Double-tap to reveal'."
|
|
111
|
+
"The foil is a button labelled with label and sublabel, with the hint revealHint (default 'Double-tap to reveal')."
|
|
106
112
|
]
|
|
107
113
|
],
|
|
108
114
|
"usage": null,
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
[
|
|
77
77
|
"placeholder",
|
|
78
78
|
"string",
|
|
79
|
-
"
|
|
79
|
+
"labels.selectPlaceholder",
|
|
80
80
|
"Shown in the field while open with nothing selected."
|
|
81
81
|
],
|
|
82
82
|
[
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
[
|
|
107
107
|
"searchPlaceholder",
|
|
108
108
|
"string",
|
|
109
|
-
"
|
|
109
|
+
"labels.search",
|
|
110
110
|
"Placeholder of the filter field."
|
|
111
111
|
],
|
|
112
112
|
[
|