diff --git a/.github/scripts/configure-deps.ts b/.github/scripts/configure-deps.ts deleted file mode 100755 index ea4fde83dd..0000000000 --- a/.github/scripts/configure-deps.ts +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env bun - -/** - * Verify that GitHub Packages auth is available for `bun install`. - * - * IMPORTANT: Bun reads bunfig.toml at process startup, BEFORE this preinstall - * hook runs. That means we cannot inject PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN - * into the parent process — the variable must already be exported in the - * shell that invokes `bun install`. This script's job is to detect a missing - * token early and print the exact command to fix it. - * - * Expected flow: - * - Local dev: `export PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN=$(gh auth token)` then `bun install` - * - CI/CD: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN set from repo secrets - */ - -import { $ } from 'bun'; - -const TOKEN_VAR = 'PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN'; - -function isCI(): boolean { - return ( - process.env.CI === '1' || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true' - ); -} - -function isCFPages(): boolean { - return process.env.CF_PAGES === '1'; -} - -function printLocalFix(): void { - console.error(`\n❌ ${TOKEN_VAR} is not exported in your shell.`); - console.error('\nBun reads bunfig.toml before the preinstall hook runs, so the token'); - console.error('must be present in the parent shell. Run one of:\n'); - console.error(' # Inline'); - console.error(` export ${TOKEN_VAR}=$(gh auth token)`); - console.error(' bun install\n'); - console.error(' # One-liner'); - console.error(` ${TOKEN_VAR}=$(gh auth token) bun install\n`); - console.error(' # Persist — add to ~/.zshrc or ~/.bashrc'); - console.error(` export ${TOKEN_VAR}=$(gh auth token 2>/dev/null)\n`); - console.error('If gh is not set up yet:'); - console.error(' gh auth login'); - console.error(' gh auth refresh -h github.com -s read:packages'); -} - -async function configureDeps() { - if (process.env[TOKEN_VAR]) { - console.log(`✓ ${TOKEN_VAR} is set — bun install will authenticate to GitHub Packages`); - return; - } - - if (isCI()) { - if (isCFPages()) { - console.error(`❌ ${TOKEN_VAR} not found in Cloudflare Pages build environment.`); - console.error('Add it as an environment variable in the CF Pages project settings:'); - console.error( - ' dash.cloudflare.com → Pages → packrat-guides → Settings → Environment variables', - ); - console.error(` Variable name: ${TOKEN_VAR}`); - console.error(' Value: a GitHub PAT with read:packages scope'); - } else { - console.error(`❌ ${TOKEN_VAR} not found in CI environment`); - console.error(`Set ${TOKEN_VAR} in your CI secrets and expose it to this job.`); - } - process.exit(1); - } - - const ghStatus = await $`gh auth status`.quiet().nothrow(); - if (ghStatus.exitCode !== 0) { - console.error('❌ GitHub CLI not found or not authenticated.\n'); - console.error('1. Install GitHub CLI: https://cli.github.com'); - console.error('2. Authenticate: gh auth login'); - console.error('3. Add packages scope: gh auth refresh -h github.com -s read:packages'); - console.error(`4. Then export ${TOKEN_VAR}=$(gh auth token) and re-run bun install.`); - process.exit(1); - } - - printLocalFix(); - process.exit(1); -} - -if (import.meta.main) { - configureDeps(); -} diff --git a/.gitignore b/.gitignore index dbb208ff0e..ec75f6f0a5 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,10 @@ apps/swift/Generated/ # portless (local-dev proxy; state lives in ~/.portless, this is defensive) .portless/ + +# Defensive: `bun expo ` (e.g. `bun expo prebuild`) resolves to the expo binary +# directly instead of the root "expo" script when extra args are passed, running from repo +# root instead of apps/expo — generates a stray Xcode project here. Always cd into apps/expo +# (or use `bun ios`/`bun android`) instead of `bun expo `. +/ios/ +/android/ diff --git a/CLAUDE.md b/CLAUDE.md index 63ba81d638..1ba9941d8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Bun workspace monorepo with three apps and two packages: | `apps/guides` | Next.js 15 / React 19 / Radix UI / Shadcn | Content/guides site | | `apps/landing` | Next.js 15 / React 19 / Framer Motion | Marketing site | | `packages/api` | Elysia on Cloudflare Workers / Drizzle ORM / Neon PostgreSQL | Backend API | -| `packages/ui` | Re-exports from `@packrat-ai/nativewindui` | Shared UI components | +| `packages/ui` | `@expo/ui` wrappers + plain RN components | Shared UI components | ### Infrastructure @@ -213,55 +213,6 @@ export const apiClient = createApiClient({ - Call via Treaty path syntax: `apiClient.auth.login.post(...)`, `apiClient.trails.search.get({ query: { q } })` - Responses are `{ data, error, status }` — check `if (error || !data)` before using `data` -## Private Package Auth - -`@packrat-ai/nativewindui` is hosted on GitHub Packages. `bunfig.toml` resolves the scope using `$PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN`. Bun auto-loads `.env.local` before running `install`, so the simplest setup is to put the token there alongside your other secrets. - -### One-time GitHub CLI setup - -```bash -gh auth login -gh auth refresh -h github.com -s read:packages # write:packages also works -``` - -### Preferred: add the token to `.env.local` - -Append to the repo-root `.env.local` (gitignored): - -```bash -PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN= -``` - -Then `bun install` just works — Bun picks it up automatically. - -### Alternative: export in shell - -Useful in ephemeral shells or when you don't keep a `.env.local`: - -```bash -# Inline -export PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN=$(gh auth token) -bun install - -# One-liner -PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN=$(gh auth token) bun install -``` - -The `preinstall` hook cannot inject env vars into the parent `bun install` process (Bun has already parsed `bunfig.toml`), so if neither `.env.local` nor a shell export has the token, install will 401. - -### CI / environments without `gh` - -Set the env var directly from secrets: - -```bash -PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN= -``` - -### Troubleshooting - -- **401 on `@packrat-ai/nativewindui`**: Token is missing from both `.env.local` and your shell, or lacks `read:packages`. Check `.env.local` first. -- The `preinstall` hook (`bun run configure:deps`) only *validates* that the token is visible to the install process — it doesn't inject it. - ## Path Aliases Defined in root `tsconfig.json`: @@ -305,9 +256,7 @@ If you find a migration in the repo that was hand-written (no `drizzle-kit` prov ## Common Issues -- **401 on `bun install`**: Missing `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` — see Private Package Auth above - **Next.js build failures**: `apps/guides` and `apps/landing` may fail without internet (fetches remote data) -- **Type errors after NativeWindUI update**: Check for renamed refs — v2.0.0 renamed `AlertRef` → `AlertMethods`, `LargeTitleSearchBarRef` → `LargeTitleSearchBarMethods` - **Bun install hangs**: Normal — takes 120+ seconds. Never cancel mid-install. ## Documented Solutions diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index 7d602b5d99..5da53e6736 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -187,7 +187,6 @@ export default (): ExpoConfig => 'react-native-maps', { iosGoogleMapsApiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY }, ], - '@react-native-community/datetimepicker', '@sentry/react-native', 'expo-status-bar', ['expo-splash-screen', { image: './assets/splash.png' }], diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx index 257810c7e6..6fd908471e 100644 --- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx +++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx @@ -1,10 +1,10 @@ 'use client'; -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; import { arrayIncludes, assertIsString, objectKeys } from '@packrat/guards'; -import type { ListDataItem } from '@packrat/ui/nativewindui'; -import { List, type ListRenderItemInfo, ListSectionHeader } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import type { ListDataItem } from '@packrat/ui/src/list'; +import { List, type ListRenderItemInfo, ListSectionHeader } from '@packrat/ui/src/list'; import { SearchOverlay } from '@packrat/ui/src/search-overlay'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 7ca6c456e4..469cac0182 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -1,17 +1,11 @@ import { clientEnvs } from '@packrat/env/expo-client'; import { isRemoteUrl, isString } from '@packrat/guards'; -import { - ActivityIndicator, - Avatar, - AvatarFallback, - Button, - List, - ListItem, - type ListRenderItemInfo, - ListSectionHeader, - Text, -} from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Avatar, AvatarFallback } from '@packrat/ui/src/avatar'; +import { Button } from '@packrat/ui/src/button'; +import { List, ListItem, type ListRenderItemInfo, ListSectionHeader } from '@packrat/ui/src/list'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import * as Sentry from '@sentry/react-native'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; @@ -49,7 +43,7 @@ function SettingsIcon() { const { colors } = useColorScheme(); return ( - + {({ pressed }) => ( diff --git a/apps/expo/app/(app)/(tabs)/profile/name.tsx b/apps/expo/app/(app)/(tabs)/profile/name.tsx index 37e5fd467c..b20715025f 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -1,5 +1,8 @@ -import { Button, Form, FormItem, FormSection, Text, TextField } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; import { IosTransparentHeaderOverlapFix } from '@packrat/ui/src/ios-transparent-header-overlap-fix'; +import { Text } from '@packrat/ui/src/text'; +import { TextField } from '@packrat/ui/src/text-field'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; import { cn } from 'expo-app/lib/cn'; diff --git a/apps/expo/app/(app)/(tabs)/profile/notifications.tsx b/apps/expo/app/(app)/(tabs)/profile/notifications.tsx index 6b2aad3e2c..5b4393a1db 100644 --- a/apps/expo/app/(app)/(tabs)/profile/notifications.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/notifications.tsx @@ -1,4 +1,7 @@ -import { Button, Form, FormItem, FormSection, Text, Toggle } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { Text } from '@packrat/ui/src/text'; +import { Toggle } from '@packrat/ui/src/toggle'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -79,7 +82,7 @@ export default function NotificationsScreen() { {t('profile.weatherAlerts')} - + {t('profile.weatherAlertsNotif')} diff --git a/apps/expo/app/(app)/(tabs)/profile/username.tsx b/apps/expo/app/(app)/(tabs)/profile/username.tsx index c620c03040..a452c0cb6e 100644 --- a/apps/expo/app/(app)/(tabs)/profile/username.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/username.tsx @@ -1,4 +1,7 @@ -import { Button, Form, FormItem, FormSection, Text, TextField } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { Text } from '@packrat/ui/src/text'; +import { TextField } from '@packrat/ui/src/text-field'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router, Stack } from 'expo-router'; diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 5509ea8fe1..46103af624 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -1,5 +1,5 @@ import { use$ } from '@legendapp/state/react'; -import { ActivityIndicator } from '@packrat/ui/nativewindui'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; import { ThemeToggle } from 'expo-app/components/ThemeToggle'; import { isLoadingAtom, diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index f7da54bfb8..696f74be5b 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -1,6 +1,8 @@ import { type UIMessage, useChat } from '@ai-sdk/react'; import { clientEnvs } from '@packrat/env/expo-client'; -import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, diff --git a/apps/expo/app/(app)/current-pack/[id].tsx b/apps/expo/app/(app)/current-pack/[id].tsx index b87ce7c684..b6fcf46ab0 100644 --- a/apps/expo/app/(app)/current-pack/[id].tsx +++ b/apps/expo/app/(app)/current-pack/[id].tsx @@ -1,5 +1,6 @@ -import { Avatar, AvatarFallback, AvatarImage, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Avatar, AvatarFallback, AvatarImage } from '@packrat/ui/src/avatar'; +import { Text } from '@packrat/ui/src/text'; import { parseWeightUnit } from '@packrat/units'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; @@ -76,7 +77,7 @@ function CategoryItem({ category, index }: { category: CategorySummary; index: n className="h-6 w-6 items-center justify-center rounded-full" style={{ backgroundColor: colors.grey4 }} > - + {category.items} diff --git a/apps/expo/app/(app)/demo/index.tsx b/apps/expo/app/(app)/demo/index.tsx index e05b7816e6..4fbd3b98b8 100644 --- a/apps/expo/app/(app)/demo/index.tsx +++ b/apps/expo/app/(app)/demo/index.tsx @@ -1,5 +1,7 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Button } from '@packrat/ui/src/button'; +import { SelectableText } from '@packrat/ui/src/selectable-text'; +import { Text } from '@packrat/ui/src/text'; import { FlashList } from '@shopify/flash-list'; import { Card } from 'expo-app/components/Card'; import { Icon } from 'expo-app/components/Icon'; @@ -11,7 +13,7 @@ import { useHeaderHeight } from 'expo-router/react-navigation'; import { cssInterop } from 'nativewind'; import type * as React from 'react'; import { useState } from 'react'; -import { Linking, useWindowDimensions, View } from 'react-native'; +import { Linking, Pressable, useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; cssInterop(FlashList, { @@ -74,17 +76,14 @@ function ListEmptyComponent() { No Components Installed - - You can install any of the free components from the{' '} - Linking.openURL('https://nativewindui.com')} - variant="subhead" - className="text-primary" - > - NativeWindUI - - {' website.'} + + You can install any of the free components from the NativeWindUI website. + Linking.openURL('https://nativewindui.com')}> + + nativewindui.com + + ); } @@ -213,9 +212,9 @@ const COMPONENTS: ComponentItem[] = [ name: 'Selectable Text', component: function SelectableTextExample() { return ( - + Long press or double press this text - + ); }, }, diff --git a/apps/expo/app/(app)/dev/paywall-state.tsx b/apps/expo/app/(app)/dev/paywall-state.tsx index 20190b4699..56df89e45f 100644 --- a/apps/expo/app/(app)/dev/paywall-state.tsx +++ b/apps/expo/app/(app)/dev/paywall-state.tsx @@ -1,5 +1,5 @@ import { isInEarlyAccess, PACKRAT_PRO_ENTITLEMENT } from '@packrat/config'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { useConnectivity } from 'expo-app/features/purchases/hooks/useConnectivity'; import { useCustomerInfo } from 'expo-app/features/purchases/hooks/useCustomerInfo'; import { useFeatureAccessConfig } from 'expo-app/features/purchases/hooks/useFeatureAccess'; diff --git a/apps/expo/app/(app)/feed/[id].tsx b/apps/expo/app/(app)/feed/[id].tsx index 59fcfdee8c..fb65086059 100644 --- a/apps/expo/app/(app)/feed/[id].tsx +++ b/apps/expo/app/(app)/feed/[id].tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { useQuery } from '@tanstack/react-query'; import { userStore } from 'expo-app/features/auth/store'; import { PostDetailScreen } from 'expo-app/features/feed'; diff --git a/apps/expo/app/(app)/gear-inventory.tsx b/apps/expo/app/(app)/gear-inventory.tsx index 5f9e1bfafb..544caae825 100644 --- a/apps/expo/app/(app)/gear-inventory.tsx +++ b/apps/expo/app/(app)/gear-inventory.tsx @@ -1,6 +1,6 @@ import { assertDefined } from '@packrat/guards'; -import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Text } from '@packrat/ui/src/text'; import { PackItemCard } from 'expo-app/features/packs/components/PackItemCard'; import { useUserPackItems } from 'expo-app/features/packs/hooks/useUserPackItems'; import type { PackItem } from 'expo-app/features/packs/types'; diff --git a/apps/expo/app/(app)/messages/chat.android.tsx b/apps/expo/app/(app)/messages/chat.android.tsx index 34ebcd27dc..9ddcebddf4 100644 --- a/apps/expo/app/(app)/messages/chat.android.tsx +++ b/apps/expo/app/(app)/messages/chat.android.tsx @@ -1,13 +1,9 @@ import { isString } from '@packrat/guards'; -import { - Avatar, - AvatarFallback, - Button, - ContextMenu, - createDropdownItem, - DropdownMenu, - Text, -} from '@packrat/ui/nativewindui'; +import { Avatar, AvatarFallback } from '@packrat/ui/src/avatar'; +import { Button } from '@packrat/ui/src/button'; +import { ContextMenu } from '@packrat/ui/src/context-menu'; +import { createDropdownItem, DropdownMenu } from '@packrat/ui/src/dropdown-menu'; +import { Text } from '@packrat/ui/src/text'; import { Portal } from '@rn-primitives/portal'; import { FlashList } from '@shopify/flash-list'; import { Icon } from 'expo-app/components/Icon'; diff --git a/apps/expo/app/(app)/messages/chat.tsx b/apps/expo/app/(app)/messages/chat.tsx index f6c94459ad..62d620dc5c 100644 --- a/apps/expo/app/(app)/messages/chat.tsx +++ b/apps/expo/app/(app)/messages/chat.tsx @@ -1,13 +1,9 @@ import { assertDefined, isString } from '@packrat/guards'; -import type { ContextMenuMethods } from '@packrat/ui/nativewindui'; -import { - Avatar, - AvatarFallback, - Button, - ContextMenu, - createContextItem, - Text, -} from '@packrat/ui/nativewindui'; +import { Avatar, AvatarFallback } from '@packrat/ui/src/avatar'; +import { Button } from '@packrat/ui/src/button'; +import type { ContextMenuMethods } from '@packrat/ui/src/context-menu'; +import { ContextMenu, createContextItem } from '@packrat/ui/src/context-menu'; +import { Text } from '@packrat/ui/src/text'; import { FlashList } from '@shopify/flash-list'; import { Icon } from 'expo-app/components/Icon'; import { TextInput } from 'expo-app/components/TextInput'; diff --git a/apps/expo/app/(app)/messages/conversations.android.tsx b/apps/expo/app/(app)/messages/conversations.android.tsx index 45508379e1..71bc35be70 100644 --- a/apps/expo/app/(app)/messages/conversations.android.tsx +++ b/apps/expo/app/(app)/messages/conversations.android.tsx @@ -1,19 +1,11 @@ import { assertDefined } from '@packrat/guards'; -import { - Avatar, - AvatarFallback, - Button, - ContextMenu, - createContextItem, - createDropdownItem, - DropdownMenu, - List, - ListItem, - type ListRenderItemInfo, - Text, - Toolbar, - ToolbarCTA, -} from '@packrat/ui/nativewindui'; +import { Avatar, AvatarFallback } from '@packrat/ui/src/avatar'; +import { Button } from '@packrat/ui/src/button'; +import { ContextMenu, createContextItem } from '@packrat/ui/src/context-menu'; +import { createDropdownItem, DropdownMenu } from '@packrat/ui/src/dropdown-menu'; +import { List, ListItem, type ListRenderItemInfo } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; +import { Toolbar, ToolbarCTA } from '@packrat/ui/src/toolbar'; import { Portal } from '@rn-primitives/portal'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; @@ -36,7 +28,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function ConversationsAndroidScreen() { - const { colors, isDarkColorScheme } = useColorScheme(); + const { isDarkColorScheme } = useColorScheme(); const [selectedMessages, setSelectedMessages] = React.useState([]); const renderItem = React.useCallback( diff --git a/apps/expo/app/(app)/messages/conversations.tsx b/apps/expo/app/(app)/messages/conversations.tsx index 3ba8e60484..a7ba37e191 100644 --- a/apps/expo/app/(app)/messages/conversations.tsx +++ b/apps/expo/app/(app)/messages/conversations.tsx @@ -1,23 +1,17 @@ import { assertDefined } from '@packrat/guards'; -import { - Avatar, - AvatarFallback, - Button, - Checkbox, - ContextMenu, - createContextItem, - createDropdownItem, - DropdownMenu, - List, - ListItem, - type ListRenderItemInfo, - Text, - Toolbar, -} from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Avatar, AvatarFallback } from '@packrat/ui/src/avatar'; +import { Button } from '@packrat/ui/src/button'; +import { Checkbox } from '@packrat/ui/src/checkbox'; +import { ContextMenu, createContextItem } from '@packrat/ui/src/context-menu'; +import { createDropdownItem, DropdownMenu } from '@packrat/ui/src/dropdown-menu'; +import { List, ListItem, type ListRenderItemInfo } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; +import { Toolbar } from '@packrat/ui/src/toolbar'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { testIds } from 'expo-app/lib/testIds'; import * as Haptics from 'expo-haptics'; import { router, Stack } from 'expo-router'; import * as React from 'react'; @@ -239,6 +233,8 @@ const CONTEXT_MENU_ITEMS = [ const TIME_STAMP_WIDTH = 96; +// Reserves room for the absolutely-positioned timestamp overlay. TextStyle now that +// ListItem's titleStyle reaches the Text itself rather than a bridging Host box. const TEXT_STYLE: TextStyle = { paddingRight: TIME_STAMP_WIDTH, }; @@ -309,6 +305,7 @@ function MessageRow({ )} diff --git a/apps/expo/app/(app)/pack-categories/[id].tsx b/apps/expo/app/(app)/pack-categories/[id].tsx index faa93d8f32..dd8cfdd761 100644 --- a/apps/expo/app/(app)/pack-categories/[id].tsx +++ b/apps/expo/app/(app)/pack-categories/[id].tsx @@ -1,5 +1,5 @@ -import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Text } from '@packrat/ui/src/text'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; @@ -77,7 +77,7 @@ export default function PackCategoriesScreen() { {categories.length ? ( - + {t('packs.organizeGear')} @@ -90,7 +90,9 @@ export default function PackCategoriesScreen() { ) : ( - {t('packs.noCategorizedItems')} + + {t('packs.noCategorizedItems')} + )} diff --git a/apps/expo/app/(app)/pack-stats/[id].tsx b/apps/expo/app/(app)/pack-stats/[id].tsx index 757db63cb4..933a87ba62 100644 --- a/apps/expo/app/(app)/pack-stats/[id].tsx +++ b/apps/expo/app/(app)/pack-stats/[id].tsx @@ -1,5 +1,6 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { usePackDetailsFromStore } from 'expo-app/features/packs/hooks/usePackDetailsFromStore'; import { usePackWeightHistory } from 'expo-app/features/packs/hooks/usePackWeightHistory'; @@ -71,7 +72,7 @@ export default function PackStatsScreen() { ); })} - + {t('packs.packWeightOverMonths')} @@ -80,7 +81,7 @@ export default function PackStatsScreen() { No weight history yet - + Add gear to your pack — your pack weight over time will appear here. diff --git a/apps/expo/features/catalog/components/CatalogBrowserModal.tsx b/apps/expo/features/catalog/components/CatalogBrowserModal.tsx index bab152c1bd..e9e49e8692 100644 --- a/apps/expo/features/catalog/components/CatalogBrowserModal.tsx +++ b/apps/expo/features/catalog/components/CatalogBrowserModal.tsx @@ -1,4 +1,5 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { searchValueAtom } from 'expo-app/atoms/itemListAtoms'; import { CategoriesFilter } from 'expo-app/components/CategoriesFilter'; import { Icon } from 'expo-app/components/Icon'; diff --git a/apps/expo/features/catalog/components/CatalogCategoriesFilter.tsx b/apps/expo/features/catalog/components/CatalogCategoriesFilter.tsx index 34b4218d00..11619955f8 100644 --- a/apps/expo/features/catalog/components/CatalogCategoriesFilter.tsx +++ b/apps/expo/features/catalog/components/CatalogCategoriesFilter.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { decodeHtmlEntities } from 'expo-app/lib/utils/decodeHtmlEntities'; import { ScrollView, TouchableOpacity, View } from 'react-native'; import { useCatalogItemsCategories } from '../hooks/useCatalogItemsCategories'; diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index 9e8fcfd178..b220638036 100644 --- a/apps/expo/features/catalog/components/CatalogItemCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemCard.tsx @@ -5,8 +5,8 @@ import { CardFooter, CardSubtitle, CardTitle, - Text, -} from '@packrat/ui/nativewindui'; +} from '@packrat/ui/src/card'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx index 7202afa6a0..5dbd74d66b 100644 --- a/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemSelectCard.tsx @@ -5,8 +5,8 @@ import { CardFooter, CardSubtitle, CardTitle, - Text, -} from '@packrat/ui/nativewindui'; +} from '@packrat/ui/src/card'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { cn } from 'expo-app/lib/cn'; diff --git a/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx b/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx index c9929a0171..883c8255d7 100644 --- a/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx +++ b/apps/expo/features/catalog/components/CatalogItemsAuthWall.tsx @@ -1,8 +1,9 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, usePathname, useRouter } from 'expo-router'; -import { View } from 'react-native'; +import { ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; export function CatalogItemsAuthWall() { @@ -14,7 +15,13 @@ export function CatalogItemsAuthWall() { - + {/* Scrollable for the same reason as ProfileAuthWall: on a short screen the feature rows plus + the sign-in button exceed the viewport once the tab bar is accounted for, which left the + button clipped and unreachable. `flex-grow` keeps taller screens looking identical. */} + @@ -22,7 +29,7 @@ export function CatalogItemsAuthWall() { {t('catalog.createYourPerfectPack')} - + {t('catalog.signInMessage')} @@ -36,11 +43,11 @@ export function CatalogItemsAuthWall() { } size="lg" variant="primary" - className="mb-4 w-full" + className="mb-4 mt-auto w-full" > {t('catalog.signIn')} - + ); } diff --git a/apps/expo/features/catalog/components/ItemLinks.tsx b/apps/expo/features/catalog/components/ItemLinks.tsx index 8172e2426d..9a68d79352 100644 --- a/apps/expo/features/catalog/components/ItemLinks.tsx +++ b/apps/expo/features/catalog/components/ItemLinks.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/catalog/components/ItemReviews.tsx b/apps/expo/features/catalog/components/ItemReviews.tsx index aa8fb4d5a4..919f207fee 100644 --- a/apps/expo/features/catalog/components/ItemReviews.tsx +++ b/apps/expo/features/catalog/components/ItemReviews.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -99,6 +99,7 @@ export function ItemReviews({ reviews }: ItemReviewsProps) { {review.text} diff --git a/apps/expo/features/catalog/components/SimilarItems.tsx b/apps/expo/features/catalog/components/SimilarItems.tsx index a65c14a1f0..91d0c5a475 100644 --- a/apps/expo/features/catalog/components/SimilarItems.tsx +++ b/apps/expo/features/catalog/components/SimilarItems.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { type SimilarItem, useSimilarCatalogItems } from 'expo-app/features/catalog/hooks'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx index 4156cd97f6..c0fa71b964 100644 --- a/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx +++ b/apps/expo/features/catalog/screens/AddCatalogItemDetailsScreen.tsx @@ -1,5 +1,6 @@ import { assertDefined } from '@packrat/guards'; -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { displayWeight, normalize, parseWeightUnit } from '@packrat/units'; import { useQueryClient } from '@tanstack/react-query'; import * as Burnt from 'burnt'; @@ -263,7 +264,7 @@ export function AddCatalogItemDetailsScreen() { {t('catalog.consumable')} - + {t('catalog.consumableDescription')} @@ -273,7 +274,7 @@ export function AddCatalogItemDetailsScreen() { {t('catalog.worn')} - + {t('catalog.wornDescription')} diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index 174971aafc..22b876b79e 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; import { Icon } from 'expo-app/components/Icon'; import { Chip } from 'expo-app/components/initial/Chip'; diff --git a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx index 8885c8bed0..51f53a5765 100644 --- a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx @@ -1,7 +1,7 @@ -import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { IosTransparentHeaderOverlapFix } from '@packrat/ui/src/ios-transparent-header-overlap-fix'; import { SearchOverlay } from '@packrat/ui/src/search-overlay'; +import { Text } from '@packrat/ui/src/text'; import { catalogGroupVariantsAtom } from 'expo-app/atoms/catalogGroupAtom'; import { searchValueAtom } from 'expo-app/atoms/itemListAtoms'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; @@ -168,7 +168,7 @@ function CatalogItemsScreen() { {t('catalog.searchError')} - + {t('catalog.unableToSearch')} @@ -180,7 +180,7 @@ function CatalogItemsScreen() { {t('catalog.noResults')} - + {t('catalog.tryAdjustingFilters')} @@ -216,7 +216,7 @@ function CatalogItemsScreen() { {t('catalog.scrollToLoadMore')} ) : paginatedItems.length > 0 ? ( - + {t('catalog.endOfCatalog')} ) : null} @@ -255,7 +255,7 @@ function CatalogItemsScreen() { {t('catalog.noItemsFound')} - + {t('catalog.tryDifferentCategory')} diff --git a/apps/expo/features/catalog/screens/PackSelectionScreen.tsx b/apps/expo/features/catalog/screens/PackSelectionScreen.tsx index 84102225bd..cfd58da6bb 100644 --- a/apps/expo/features/catalog/screens/PackSelectionScreen.tsx +++ b/apps/expo/features/catalog/screens/PackSelectionScreen.tsx @@ -1,4 +1,5 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { SearchInput } from 'expo-app/components/SearchInput'; import { useDetailedPacks } from 'expo-app/features/packs'; @@ -51,7 +52,7 @@ export function PackSelectionScreen() { {t('catalog.noPacksAvailable')} - + {t('catalog.createPackMessage')} diff --git a/apps/expo/features/feed/screens/PostDetailScreen.tsx b/apps/expo/features/feed/screens/PostDetailScreen.tsx index 5d74b81ed4..50b233412c 100644 --- a/apps/expo/features/feed/screens/PostDetailScreen.tsx +++ b/apps/expo/features/feed/screens/PostDetailScreen.tsx @@ -1,4 +1,5 @@ -import { ActivityIndicator, Text } from '@packrat/ui/nativewindui'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { TextInput } from 'expo-app/components/TextInput'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -121,7 +122,11 @@ export const PostDetailScreen = ({ post, currentUserId }: PostDetailScreenProps) {/* Post info */} - {post.caption && {post.caption}} + {post.caption && ( + + {post.caption} + + )} {/* Like row */} diff --git a/apps/expo/features/guides/components/GuideCard.tsx b/apps/expo/features/guides/components/GuideCard.tsx index 49c012caff..fdca1fc469 100644 --- a/apps/expo/features/guides/components/GuideCard.tsx +++ b/apps/expo/features/guides/components/GuideCard.tsx @@ -1,4 +1,5 @@ -import { Card, CardContent, CardTitle, Text } from '@packrat/ui/nativewindui'; +import { Card, CardContent, CardTitle } from '@packrat/ui/src/card'; +import { Text } from '@packrat/ui/src/text'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; import { TouchableOpacity, View } from 'react-native'; @@ -26,7 +27,11 @@ export const GuideCard: React.FC = ({ guide, onPress }) => { {guide.difficulty && {guide.difficulty}} {guide.title} {guide.description && ( - + {guide.description} )} diff --git a/apps/expo/features/guides/components/GuidesTile.tsx b/apps/expo/features/guides/components/GuidesTile.tsx index 7bb23bab64..f40917820e 100644 --- a/apps/expo/features/guides/components/GuidesTile.tsx +++ b/apps/expo/features/guides/components/GuidesTile.tsx @@ -1,4 +1,5 @@ -import { ListItem, Text } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/guides/screens/GuideDetailScreen.tsx b/apps/expo/features/guides/screens/GuideDetailScreen.tsx index 2cd50eabe2..87447308a5 100644 --- a/apps/expo/features/guides/screens/GuideDetailScreen.tsx +++ b/apps/expo/features/guides/screens/GuideDetailScreen.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Chip } from 'expo-app/components/initial/Chip'; import { Markdown } from 'expo-app/components/Markdown'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -88,7 +88,9 @@ export const GuideDetailScreen = () => { {guide.description && ( - {guide.description} + + {guide.description} + )} {guide.content || ''} diff --git a/apps/expo/features/guides/screens/GuidesListScreen.tsx b/apps/expo/features/guides/screens/GuidesListScreen.tsx index f015d7defd..bb61859536 100644 --- a/apps/expo/features/guides/screens/GuidesListScreen.tsx +++ b/apps/expo/features/guides/screens/GuidesListScreen.tsx @@ -1,7 +1,7 @@ -import { Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { IosTransparentHeaderOverlapFix } from '@packrat/ui/src/ios-transparent-header-overlap-fix'; import { SearchOverlay } from '@packrat/ui/src/search-overlay'; +import { Text } from '@packrat/ui/src/text'; import { CategoriesFilter } from 'expo-app/components/CategoriesFilter'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/pack-templates/components/AddPackTemplateItemActions.tsx b/apps/expo/features/pack-templates/components/AddPackTemplateItemActions.tsx index dab4845123..0a37db9b87 100644 --- a/apps/expo/features/pack-templates/components/AddPackTemplateItemActions.tsx +++ b/apps/expo/features/pack-templates/components/AddPackTemplateItemActions.tsx @@ -1,8 +1,8 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; import { isFunction } from '@packrat/guards'; -import { Sheet, Text } from '@packrat/ui/nativewindui'; +import { Sheet, SheetView } from '@packrat/ui/src/bottom-sheet'; +import { Text } from '@packrat/ui/src/text'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; import { Icon } from 'expo-app/components/Icon'; @@ -137,10 +137,8 @@ export default React.forwardRef - + - + - + {t('packTemplates.onlineContentImportDescription')} diff --git a/apps/expo/features/pack-templates/components/PackTemplateCard.tsx b/apps/expo/features/pack-templates/components/PackTemplateCard.tsx index 17ad86439d..6b020c632e 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateCard.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateCard.tsx @@ -1,6 +1,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { isArray } from '@packrat/guards'; -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { appAlert } from 'expo-app/app/_layout'; import { Icon } from 'expo-app/components/Icon'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; @@ -117,7 +118,7 @@ export function PackTemplateCard({ templateId, onPress }: PackTemplateCard) { {template.description && ( - + {template.description} )} diff --git a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx index 7d4a6a1168..1bb0ba20de 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateForm.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateForm.tsx @@ -1,14 +1,9 @@ import { fromZod } from '@packrat/guards'; import { PackCategorySchema } from '@packrat/schemas/constants'; -import { - Button, - createDropdownItem, - DropdownMenu, - Form, - FormItem, - FormSection, - TextField, -} from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { createDropdownItem, DropdownMenu } from '@packrat/ui/src/dropdown-menu'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { TextField } from '@packrat/ui/src/text-field'; import { useForm } from '@tanstack/react-form'; import { Icon } from 'expo-app/components/Icon'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; diff --git a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx index 539f44802a..cb1ae0a563 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx @@ -1,5 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { appAlert } from 'expo-app/app/_layout'; import { Icon } from 'expo-app/components/Icon'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; diff --git a/apps/expo/features/pack-templates/components/PackTemplatesTile.tsx b/apps/expo/features/pack-templates/components/PackTemplatesTile.tsx index c001689e94..c45472ddc4 100644 --- a/apps/expo/features/pack-templates/components/PackTemplatesTile.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplatesTile.tsx @@ -1,4 +1,5 @@ -import { ListItem, Text } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/pack-templates/components/TemplateCreationOptions.tsx b/apps/expo/features/pack-templates/components/TemplateCreationOptions.tsx index 514b3bf0ce..4f6b0c94d4 100644 --- a/apps/expo/features/pack-templates/components/TemplateCreationOptions.tsx +++ b/apps/expo/features/pack-templates/components/TemplateCreationOptions.tsx @@ -1,6 +1,6 @@ -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; -import { Sheet, Text } from '@packrat/ui/nativewindui'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; +import { Sheet, SheetView } from '@packrat/ui/src/bottom-sheet'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; @@ -10,7 +10,6 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRouter } from 'expo-router'; import React, { useState } from 'react'; import { TouchableOpacity, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { OnlineContentImportModal } from './OnlineContentImportModal'; type TemplateCreationOptionsProps = object; @@ -24,7 +23,6 @@ export default React.forwardRef( const user = useUser(); const isAdmin = user?.role === 'ADMIN'; const [showOnlineContentModal, setShowOnlineContentModal] = useState(false); - const insets = useSafeAreaInsets(); const { run, handleDismiss } = useBottomSheetAction(ref as React.RefObject); @@ -46,17 +44,15 @@ export default React.forwardRef( ref={ref} enableDynamicSizing={true} enablePanDownToClose - bottomInset={insets.bottom} backgroundStyle={{ backgroundColor: colors.card }} - handleIndicatorStyle={{ backgroundColor: colors.grey2 }} onDismiss={handleDismiss} > - + {t('packTemplates.createTemplate')} - + {t('packTemplates.chooseCreationMethod')} @@ -73,7 +69,7 @@ export default React.forwardRef( {t('packTemplates.createFromScratch')} - + {t('packTemplates.createFromScratchDescription')} @@ -93,14 +89,14 @@ export default React.forwardRef( {t('packTemplates.importFromOnlineContent')} - + {t('packTemplates.importFromOnlineContentDescription')} )} - + {t('packTemplates.noDetections')} - + {t('packTemplates.tryDifferentImage')} diff --git a/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx index 51ae6febbd..3fe71ca82c 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx @@ -1,4 +1,6 @@ -import { Button, Text, useSheetRef } from '@packrat/ui/nativewindui'; +import { useSheetRef } from '@packrat/ui/src/bottom-sheet'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; @@ -95,7 +97,9 @@ export function PackTemplateDetailScreen() { {packTemplate.description && ( - {packTemplate.description} + + {packTemplate.description} + )} diff --git a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx index e30a103b80..9d67f2b8bc 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx @@ -1,5 +1,5 @@ import { assertDefined } from '@packrat/guards'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -51,7 +51,9 @@ export function PackTemplateItemDetailScreen() { {item.category} {item.description && ( - {item.description} + + {item.description} + )} @@ -104,7 +106,9 @@ export function PackTemplateItemDetailScreen() { {itemHasNotes && ( {t('packTemplates.notes')} - {itemNotes} + + {itemNotes} + )} diff --git a/apps/expo/features/pack-templates/screens/PackTemplateListScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateListScreen.tsx index 581e1a5f67..ae7efca027 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateListScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateListScreen.tsx @@ -1,7 +1,7 @@ -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { SegmentedControl } from '@packrat/ui/nativewindui'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; import { SearchOverlay } from '@packrat/ui/src/search-overlay'; +import { SegmentedControl } from '@packrat/ui/src/segmented-control'; import { Icon } from 'expo-app/components/Icon'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; diff --git a/apps/expo/features/pack-templates/utils/getPackTemplateDetailOptions.tsx b/apps/expo/features/pack-templates/utils/getPackTemplateDetailOptions.tsx index 31e12b8acf..3f1715f1a5 100644 --- a/apps/expo/features/pack-templates/utils/getPackTemplateDetailOptions.tsx +++ b/apps/expo/features/pack-templates/utils/getPackTemplateDetailOptions.tsx @@ -1,4 +1,6 @@ -import { Alert, Button, useSheetRef } from '@packrat/ui/nativewindui'; +import { Alert } from '@packrat/ui/src/alert'; +import { useSheetRef } from '@packrat/ui/src/bottom-sheet'; +import { Button } from '@packrat/ui/src/button'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx b/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx index 28ddb17a48..4ce82be11b 100644 --- a/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx +++ b/apps/expo/features/pack-templates/utils/getPackTemplateItemDetailOptions.tsx @@ -1,5 +1,6 @@ import { assertDefined } from '@packrat/guards'; -import { Alert, Button } from '@packrat/ui/nativewindui'; +import { Alert } from '@packrat/ui/src/alert'; +import { Button } from '@packrat/ui/src/button'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/ActivityPicker.tsx b/apps/expo/features/packs/components/ActivityPicker.tsx index 19dc749355..244a6b6aff 100644 --- a/apps/expo/features/packs/components/ActivityPicker.tsx +++ b/apps/expo/features/packs/components/ActivityPicker.tsx @@ -1,4 +1,5 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/AddPackItemActions.tsx b/apps/expo/features/packs/components/AddPackItemActions.tsx index f58782ac44..121f0b9373 100644 --- a/apps/expo/features/packs/components/AddPackItemActions.tsx +++ b/apps/expo/features/packs/components/AddPackItemActions.tsx @@ -1,8 +1,8 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; import { isFunction } from '@packrat/guards'; -import { Sheet, Text } from '@packrat/ui/nativewindui'; +import { Sheet, SheetView } from '@packrat/ui/src/bottom-sheet'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { isAuthed } from 'expo-app/features/auth/store'; import { CatalogBrowserModal } from 'expo-app/features/catalog/components'; @@ -127,10 +127,8 @@ export default React.forwardRef( enableDynamicSizing={true} enablePanDownToClose backgroundStyle={{ backgroundColor: colors.card }} - handleIndicatorStyle={{ backgroundColor: colors.grey2 }} - bottomInset={insets.bottom} > - + ( - + - No gear found for this suggestion. + + No gear found for this suggestion. + )} @@ -335,7 +339,7 @@ function DevGapPanel({ {chip.label} @@ -344,7 +348,7 @@ function DevGapPanel({ })} - + Skip auto-analyze (saves API credits) {skipAutoAnalyze ? 'ON' : 'OFF'} @@ -498,7 +502,7 @@ export function GapAnalysisModal({ {/* Header */} - + {t('packs.gapAnalysis')} {pack.name} @@ -554,7 +558,7 @@ export function GapAnalysisModal({ setActiveControlIndex(null)}> {analysis.summary && ( - + {analysis.summary} )} @@ -588,7 +592,7 @@ export function GapAnalysisModal({ {t('packs.packLooksComplete')} - + {t('packs.noSignificantGaps')} diff --git a/apps/expo/features/packs/components/GapSuggestionRow.tsx b/apps/expo/features/packs/components/GapSuggestionRow.tsx index 6c8d5f5d01..b79131eb3c 100644 --- a/apps/expo/features/packs/components/GapSuggestionRow.tsx +++ b/apps/expo/features/packs/components/GapSuggestionRow.tsx @@ -1,5 +1,5 @@ -import { Text } from '@packrat/ui/nativewindui'; -import MaskedView from '@react-native-masked-view/masked-view'; +import MaskedView from '@expo/ui/community/masked-view'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { CatalogItemImage } from 'expo-app/features/catalog/components/CatalogItemImage'; import type { CatalogItem } from 'expo-app/features/catalog/types'; @@ -43,12 +43,14 @@ function ShimmerFindingText({ suggestion }: { suggestion: string }) { + // @expo/ui's Universal Text has no fontStyle/italic support — dropped, matches the + // shimmer-loading state's minor visual polish, not a functional requirement. + {text} } > - + {text} {priorityConfig && ( - + {priorityConfig.label} )} @@ -296,7 +298,7 @@ export function GapSuggestionRow({ onPress={onSwapPress} hitSlop={{ top: 8, bottom: 8, left: 8, right: 6 }} > - + Swap @@ -322,7 +324,7 @@ export function GapSuggestionRow({ }} > displayItem && handleAdd(displayItem)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 10 }} > - + Add @@ -399,11 +403,11 @@ export function GapSuggestionRow({ hitSlop={{ top: 20, bottom: 20, left: 12, right: 12 }} > ) : ( - + No gear found for this suggestion )} diff --git a/apps/expo/features/packs/components/GearInventoryTile.tsx b/apps/expo/features/packs/components/GearInventoryTile.tsx index 2efede9639..3d874aa11b 100644 --- a/apps/expo/features/packs/components/GearInventoryTile.tsx +++ b/apps/expo/features/packs/components/GearInventoryTile.tsx @@ -1,5 +1,7 @@ -import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; +import type { AlertMethods } from '@packrat/ui/src/alert'; +import { Alert } from '@packrat/ui/src/alert'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index 9c63c94849..2f3cf99955 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { parseWeightUnit } from '@packrat/units'; import { Icon } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; diff --git a/apps/expo/features/packs/components/LocationSearchSheet.tsx b/apps/expo/features/packs/components/LocationSearchSheet.tsx index 81cce19095..d53543d692 100644 --- a/apps/expo/features/packs/components/LocationSearchSheet.tsx +++ b/apps/expo/features/packs/components/LocationSearchSheet.tsx @@ -1,8 +1,9 @@ -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; +import { BottomSheetScrollView, BottomSheetTextInput } from '@expo/ui/community/bottom-sheet'; import { clientEnvs } from '@packrat/env/expo-client'; import { isString, toRecordArray } from '@packrat/guards'; -import { Sheet, Text } from '@packrat/ui/nativewindui'; +import { Sheet } from '@packrat/ui/src/bottom-sheet'; +import { Text } from '@packrat/ui/src/text'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -105,10 +106,8 @@ export const LocationSearchSheet = React.forwardRef {/* Fixed header */} diff --git a/apps/expo/features/packs/components/LocationSourceSheet.tsx b/apps/expo/features/packs/components/LocationSourceSheet.tsx index 06cb17e8f6..d6ef5e0951 100644 --- a/apps/expo/features/packs/components/LocationSourceSheet.tsx +++ b/apps/expo/features/packs/components/LocationSourceSheet.tsx @@ -1,6 +1,6 @@ -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; -import { Sheet, Text } from '@packrat/ui/nativewindui'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; +import { Sheet, SheetView } from '@packrat/ui/src/bottom-sheet'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; @@ -25,13 +25,12 @@ export const LocationSourceSheet = React.forwardRef - + {t('seasons.chooseLocation')} - + {t('seasons.chooseLocationDescription')} @@ -76,7 +75,7 @@ export const LocationSourceSheet = React.forwardRef - + ); }, diff --git a/apps/expo/features/packs/components/PackCard.tsx b/apps/expo/features/packs/components/PackCard.tsx index 0638a613e6..5cab7c336e 100644 --- a/apps/expo/features/packs/components/PackCard.tsx +++ b/apps/expo/features/packs/components/PackCard.tsx @@ -1,6 +1,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { isArray } from '@packrat/guards'; -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/PackCategoriesTile.tsx b/apps/expo/features/packs/components/PackCategoriesTile.tsx index e91096206c..83e550856e 100644 --- a/apps/expo/features/packs/components/PackCategoriesTile.tsx +++ b/apps/expo/features/packs/components/PackCategoriesTile.tsx @@ -1,5 +1,7 @@ -import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; +import type { AlertMethods } from '@packrat/ui/src/alert'; +import { Alert } from '@packrat/ui/src/alert'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/PackForm.tsx b/apps/expo/features/packs/components/PackForm.tsx index cc32776acc..3268b089a0 100644 --- a/apps/expo/features/packs/components/PackForm.tsx +++ b/apps/expo/features/packs/components/PackForm.tsx @@ -1,14 +1,9 @@ import { fromZod } from '@packrat/guards'; import { PackCategorySchema } from '@packrat/schemas/constants'; -import { - Button, - createDropdownItem, - DropdownMenu, - Form, - FormItem, - FormSection, - TextField, -} from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { createDropdownItem, DropdownMenu } from '@packrat/ui/src/dropdown-menu'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { TextField } from '@packrat/ui/src/text-field'; import { useForm } from '@tanstack/react-form'; import { Icon } from 'expo-app/components/Icon'; import { useCreatePackFromTemplate } from 'expo-app/features/pack-templates'; diff --git a/apps/expo/features/packs/components/PackItemCard.tsx b/apps/expo/features/packs/components/PackItemCard.tsx index 0d6f44ca22..d5edfa2fad 100644 --- a/apps/expo/features/packs/components/PackItemCard.tsx +++ b/apps/expo/features/packs/components/PackItemCard.tsx @@ -1,6 +1,6 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { assertDefined } from '@packrat/guards'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/PackStatsTile.tsx b/apps/expo/features/packs/components/PackStatsTile.tsx index 701e66e3bc..4214f0f098 100644 --- a/apps/expo/features/packs/components/PackStatsTile.tsx +++ b/apps/expo/features/packs/components/PackStatsTile.tsx @@ -1,5 +1,7 @@ -import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; +import type { AlertMethods } from '@packrat/ui/src/alert'; +import { Alert } from '@packrat/ui/src/alert'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { SearchInput } from 'expo-app/components/SearchInput'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/RecentPacksTile.tsx b/apps/expo/features/packs/components/RecentPacksTile.tsx index 8958356406..28f920f3fc 100644 --- a/apps/expo/features/packs/components/RecentPacksTile.tsx +++ b/apps/expo/features/packs/components/RecentPacksTile.tsx @@ -1,4 +1,6 @@ -import { Avatar, AvatarFallback, AvatarImage, ListItem, Text } from '@packrat/ui/nativewindui'; +import { Avatar, AvatarFallback, AvatarImage } from '@packrat/ui/src/avatar'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/SeasonSuggestionsTile.tsx b/apps/expo/features/packs/components/SeasonSuggestionsTile.tsx index 2356dcd290..6ff54ff4ef 100644 --- a/apps/expo/features/packs/components/SeasonSuggestionsTile.tsx +++ b/apps/expo/features/packs/components/SeasonSuggestionsTile.tsx @@ -1,4 +1,4 @@ -import { ListItem } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; import { Icon } from 'expo-app/components/Icon'; import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/components/SeasonSuggestionsUnlockSheet.tsx b/apps/expo/features/packs/components/SeasonSuggestionsUnlockSheet.tsx index 2f92e0c6c1..bc98505f26 100644 --- a/apps/expo/features/packs/components/SeasonSuggestionsUnlockSheet.tsx +++ b/apps/expo/features/packs/components/SeasonSuggestionsUnlockSheet.tsx @@ -1,6 +1,7 @@ -import type { BottomSheetModal } from '@gorhom/bottom-sheet'; -import { BottomSheetView } from '@gorhom/bottom-sheet'; -import { Button, Sheet, Text } from '@packrat/ui/nativewindui'; +import type { BottomSheetModal } from '@expo/ui/community/bottom-sheet'; +import { Sheet, SheetView } from '@packrat/ui/src/bottom-sheet'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useSeasonSuggestionsPrefs } from 'expo-app/features/packs/atoms/seasonSuggestionsAtoms'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; @@ -38,9 +39,8 @@ export const SeasonSuggestionsUnlockSheet = React.forwardRef< enableDynamicSizing enablePanDownToClose backgroundStyle={{ backgroundColor: colors.card }} - handleIndicatorStyle={{ backgroundColor: colors.grey2 }} > - + - + {t('seasons.unlockDescription')} @@ -70,7 +70,7 @@ export const SeasonSuggestionsUnlockSheet = React.forwardRef< {t('seasons.maybeLater')} - + ); }); diff --git a/apps/expo/features/packs/components/SharedPacksTile.tsx b/apps/expo/features/packs/components/SharedPacksTile.tsx index 4151c10e36..0927701747 100644 --- a/apps/expo/features/packs/components/SharedPacksTile.tsx +++ b/apps/expo/features/packs/components/SharedPacksTile.tsx @@ -1,4 +1,4 @@ -import { ListItem } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/ShoppingListTile.tsx b/apps/expo/features/packs/components/ShoppingListTile.tsx index c875b2059e..7cd183e127 100644 --- a/apps/expo/features/packs/components/ShoppingListTile.tsx +++ b/apps/expo/features/packs/components/ShoppingListTile.tsx @@ -1,4 +1,5 @@ -import { ListItem, Text } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/SimilarItemsForPackItem.tsx b/apps/expo/features/packs/components/SimilarItemsForPackItem.tsx index afb39e3822..8419e192f3 100644 --- a/apps/expo/features/packs/components/SimilarItemsForPackItem.tsx +++ b/apps/expo/features/packs/components/SimilarItemsForPackItem.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { CatalogItemImage } from 'expo-app/features/catalog/components/CatalogItemImage'; import { type SimilarItem, useSimilarPackItems } from 'expo-app/features/catalog/hooks'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/packs/components/WeightAnalysisTile.tsx b/apps/expo/features/packs/components/WeightAnalysisTile.tsx index 27fb1965d9..263510ea9b 100644 --- a/apps/expo/features/packs/components/WeightAnalysisTile.tsx +++ b/apps/expo/features/packs/components/WeightAnalysisTile.tsx @@ -1,5 +1,7 @@ -import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem, Text } from '@packrat/ui/nativewindui'; +import type { AlertMethods } from '@packrat/ui/src/alert'; +import { Alert } from '@packrat/ui/src/alert'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useWeightUnit } from 'expo-app/features/auth/hooks/useWeightUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/screens/CreatePackItemForm.tsx b/apps/expo/features/packs/screens/CreatePackItemForm.tsx index ca6246dd1b..d074f28944 100644 --- a/apps/expo/features/packs/screens/CreatePackItemForm.tsx +++ b/apps/expo/features/packs/screens/CreatePackItemForm.tsx @@ -1,7 +1,9 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import type { WeightUnit } from '@packrat/constants'; import { safeIndexOf } from '@packrat/guards'; -import { Form, FormItem, FormSection, SegmentedControl, TextField } from '@packrat/ui/nativewindui'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { SegmentedControl } from '@packrat/ui/src/segmented-control'; +import { TextField } from '@packrat/ui/src/text-field'; import * as Sentry from '@sentry/react-native'; import { useForm } from '@tanstack/react-form'; import { Icon } from 'expo-app/components/Icon'; diff --git a/apps/expo/features/packs/screens/ItemsScanScreen.tsx b/apps/expo/features/packs/screens/ItemsScanScreen.tsx index 2edf22ced8..9ae379c890 100644 --- a/apps/expo/features/packs/screens/ItemsScanScreen.tsx +++ b/apps/expo/features/packs/screens/ItemsScanScreen.tsx @@ -1,6 +1,8 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; import { assertNonNull } from '@packrat/guards'; -import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; import { ErrorState } from 'expo-app/components/ErrorState'; @@ -208,7 +210,7 @@ export function ItemsScanScreen() { {t('packs.noItemsFound')} - + {t('packs.weCouldntIdentify')} @@ -221,7 +223,7 @@ export function ItemsScanScreen() { - + {t('packs.spreadItemsContrast')} @@ -234,7 +236,7 @@ export function ItemsScanScreen() { color={colors.primary} /> - + {t('packs.useGoodLighting')} @@ -242,7 +244,7 @@ export function ItemsScanScreen() { - + {t('packs.ensureItemsVisible')} diff --git a/apps/expo/features/packs/screens/PackDetailScreen.tsx b/apps/expo/features/packs/screens/PackDetailScreen.tsx index e054014f45..d52746a684 100644 --- a/apps/expo/features/packs/screens/PackDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackDetailScreen.tsx @@ -1,6 +1,8 @@ -import { BottomSheetView } from '@gorhom/bottom-sheet'; import { isDefined } from '@packrat/guards'; -import { ActivityIndicator, Button, Sheet, Text, useSheetRef } from '@packrat/ui/nativewindui'; +import { Sheet, SheetView, useSheetRef } from '@packrat/ui/src/bottom-sheet'; +import { Button } from '@packrat/ui/src/button'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import * as Burnt from 'burnt'; import { appAlert } from 'expo-app/app/_layout'; import { devSkipAutoAnalyzeAtom } from 'expo-app/atoms/devAtoms'; @@ -23,7 +25,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { useMemo, useState } from 'react'; import { Image, Platform, ScrollView, Share, TouchableOpacity, View } from 'react-native'; -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SafeAreaView } from 'react-native-safe-area-context'; import AddPackItemActions from '../components/AddPackItemActions'; import { usePackDetailsFromApi, usePackDetailsFromStore, usePackGapAnalysis } from '../hooks'; import { usePackOwnershipCheck } from '../hooks/usePackOwnershipCheck'; @@ -79,7 +81,6 @@ export function PackDetailScreen() { const pack = (isOwnedByUser ? packFromStore : packFromApi) as Pack; const { colors } = useColorScheme(); - const insets = useSafeAreaInsets(); const bottomSheetRef = useSheetRef(); const addItemActionsRef = useSheetRef(); @@ -493,7 +494,9 @@ export function PackDetailScreen() { {pack.description && ( - {pack.description} + + {pack.description} + )} @@ -704,11 +707,9 @@ export function PackDetailScreen() { enableDynamicSizing enablePanDownToClose backgroundStyle={{ backgroundColor: colors.card }} - handleIndicatorStyle={{ backgroundColor: colors.grey2 }} - bottomInset={insets.bottom} onDismiss={handleBottomSheetDismiss} > - + {/* Revamped consistent 2-column action layout */} {normalizedActions.map((action) => ( @@ -729,7 +730,7 @@ export function PackDetailScreen() { ))} - + {/* Add Item Options Sheet */} diff --git a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx index 02b8c94505..864d03ec5c 100644 --- a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx @@ -1,5 +1,7 @@ import { isDefined } from '@packrat/guards'; -import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { Chip } from 'expo-app/components/initial/Chip'; import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; @@ -132,7 +134,9 @@ export function ItemDetailScreen() { {item.category} {item.description && ( - {item.description} + + {item.description} + )} @@ -185,7 +189,9 @@ export function ItemDetailScreen() { {itemHasNotes && itemNotes && ( {t('packs.notes')} - {itemNotes} + + {itemNotes} + )} @@ -198,7 +204,7 @@ export function ItemDetailScreen() { className="flex-row items-center justify-center rounded-full px-4 py-3" > - {t('packs.askAIAboutItem')} + {t('packs.askAIAboutItem')} )} diff --git a/apps/expo/features/packs/screens/PackListScreen.tsx b/apps/expo/features/packs/screens/PackListScreen.tsx index 9d08ec0a26..b115920728 100644 --- a/apps/expo/features/packs/screens/PackListScreen.tsx +++ b/apps/expo/features/packs/screens/PackListScreen.tsx @@ -1,7 +1,9 @@ -import { ActivityIndicator, Button, SegmentedControl } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Button } from '@packrat/ui/src/button'; import { IosTransparentHeaderOverlapFix } from '@packrat/ui/src/ios-transparent-header-overlap-fix'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; import { SearchOverlay } from '@packrat/ui/src/search-overlay'; +import { SegmentedControl } from '@packrat/ui/src/segmented-control'; import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix'; import { Icon } from 'expo-app/components/Icon'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; diff --git a/apps/expo/features/packs/utils/getPackDetailOptions.tsx b/apps/expo/features/packs/utils/getPackDetailOptions.tsx index a115c4c409..329a10acd8 100644 --- a/apps/expo/features/packs/utils/getPackDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackDetailOptions.tsx @@ -1,4 +1,5 @@ -import { Button, useSheetRef } from '@packrat/ui/nativewindui'; +import { useSheetRef } from '@packrat/ui/src/bottom-sheet'; +import { Button } from '@packrat/ui/src/button'; import { appAlert } from 'expo-app/app/_layout'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx index 2023216e9e..a817aa5dbb 100644 --- a/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx +++ b/apps/expo/features/packs/utils/getPackItemDetailOptions.tsx @@ -1,5 +1,6 @@ import { assertDefined } from '@packrat/guards'; -import { Alert, Button } from '@packrat/ui/nativewindui'; +import { Alert } from '@packrat/ui/src/alert'; +import { Button } from '@packrat/ui/src/button'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { t } from 'expo-app/lib/i18n'; diff --git a/apps/expo/features/profile/components/ProfileAuthWall.tsx b/apps/expo/features/profile/components/ProfileAuthWall.tsx index 3c5f03d103..aace3f9ba1 100644 --- a/apps/expo/features/profile/components/ProfileAuthWall.tsx +++ b/apps/expo/features/profile/components/ProfileAuthWall.tsx @@ -1,9 +1,10 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Link, Stack, usePathname, useRouter } from 'expo-router'; -import { Pressable, View } from 'react-native'; +import { Pressable, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; export function ProfileAuthWall() { @@ -29,7 +30,14 @@ export function ProfileAuthWall() { - + {/* Scrollable: the four feature rows plus the sign-in button exceed a 720x1600 screen once the + tab bar and the "sync paused" banner are accounted for, which left the Sign In button + clipped below the fold and unreachable — a guest could not sign in from this screen at all. + `flex-grow` on the content container keeps the layout identical on taller screens. */} + @@ -37,7 +45,9 @@ export function ProfileAuthWall() { {t('profile.createYourAccount')} - {t('profile.joinPackRat')} + + {t('profile.joinPackRat')} + @@ -72,11 +82,14 @@ export function ProfileAuthWall() { } size="lg" variant="primary" - className="mb-4 w-full" + // `mt-auto` keeps the button pinned to the bottom on tall screens (the old flex-1 layout's + // behaviour) while `flex-grow` on the content container lets it be pushed into scrollable + // overflow on short ones instead of being clipped. + className="mb-4 mt-auto w-full" > {t('auth.signIn')} - + ); } @@ -99,7 +112,9 @@ function FeatureItem({ {title} - {description} + + {description} + ); diff --git a/apps/expo/features/purchases/components/CustomerCenter.tsx b/apps/expo/features/purchases/components/CustomerCenter.tsx index df21765ba6..be9170cf73 100644 --- a/apps/expo/features/purchases/components/CustomerCenter.tsx +++ b/apps/expo/features/purchases/components/CustomerCenter.tsx @@ -1,4 +1,5 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Text } from '@packrat/ui/src/text'; import * as Sentry from '@sentry/react-native'; import RevenueCatUI from 'react-native-purchases-ui'; diff --git a/apps/expo/features/purchases/components/EarlyAccessGate.tsx b/apps/expo/features/purchases/components/EarlyAccessGate.tsx index eddab18975..552d714def 100644 --- a/apps/expo/features/purchases/components/EarlyAccessGate.tsx +++ b/apps/expo/features/purchases/components/EarlyAccessGate.tsx @@ -1,5 +1,7 @@ import { isInEarlyAccess } from '@packrat/config'; -import { ActivityIndicator, Button, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import { Stack, useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; import { View } from 'react-native'; diff --git a/apps/expo/features/trail-conditions/components/ConditionBadge.tsx b/apps/expo/features/trail-conditions/components/ConditionBadge.tsx index 63a2621cb7..968e403e71 100644 --- a/apps/expo/features/trail-conditions/components/ConditionBadge.tsx +++ b/apps/expo/features/trail-conditions/components/ConditionBadge.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { cn } from 'expo-app/lib/cn'; import { View } from 'react-native'; import type { OverallCondition } from '../types'; diff --git a/apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx b/apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx index fb7b6a6cdb..8834c6216e 100644 --- a/apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx +++ b/apps/expo/features/trail-conditions/components/SubmitConditionReportForm.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import * as Sentry from '@sentry/react-native'; import { TextInput } from 'expo-app/components/TextInput'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx b/apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx index 0bb13c7d60..63e0497851 100644 --- a/apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx +++ b/apps/expo/features/trail-conditions/components/TrailConditionReportCard.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { View } from 'react-native'; import type { TrailConditionReport, TrailSurface, WaterCrossingDifficulty } from '../types'; @@ -110,7 +110,7 @@ export function TrailConditionReportCard({ report }: TrailConditionReportCardPro )} {report.notes ? ( - + {report.notes} ) : null} diff --git a/apps/expo/features/trips/components/TrailConditionsTile.tsx b/apps/expo/features/trips/components/TrailConditionsTile.tsx index 6a0b36ff87..7948b7bee8 100644 --- a/apps/expo/features/trips/components/TrailConditionsTile.tsx +++ b/apps/expo/features/trips/components/TrailConditionsTile.tsx @@ -1,5 +1,6 @@ -import type { AlertMethods } from '@packrat/ui/nativewindui'; -import { Alert, ListItem } from '@packrat/ui/nativewindui'; +import type { AlertMethods } from '@packrat/ui/src/alert'; +import { Alert } from '@packrat/ui/src/alert'; +import { ListItem } from '@packrat/ui/src/list'; import { Icon } from 'expo-app/components/Icon'; import { useFeatureFlag } from 'expo-app/hooks/useFeatureFlags'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/trips/components/TripCard.tsx b/apps/expo/features/trips/components/TripCard.tsx index aef934b570..1d5c874091 100644 --- a/apps/expo/features/trips/components/TripCard.tsx +++ b/apps/expo/features/trips/components/TripCard.tsx @@ -1,5 +1,6 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; -import { Alert, type AlertMethods, Button } from '@packrat/ui/nativewindui'; +import { Alert, type AlertMethods } from '@packrat/ui/src/alert'; +import { Button } from '@packrat/ui/src/button'; import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; diff --git a/apps/expo/features/trips/components/TripForm.tsx b/apps/expo/features/trips/components/TripForm.tsx index a5bd8f3707..c4d6856318 100644 --- a/apps/expo/features/trips/components/TripForm.tsx +++ b/apps/expo/features/trips/components/TripForm.tsx @@ -1,6 +1,7 @@ +import DateTimePicker from '@expo/ui/community/datetime-picker'; import { assertDefined, isString } from '@packrat/guards'; -import { Form, FormItem, FormSection, TextField } from '@packrat/ui/nativewindui'; -import DateTimePicker from '@react-native-community/datetimepicker'; +import { Form, FormItem, FormSection } from '@packrat/ui/src/form'; +import { TextField } from '@packrat/ui/src/text-field'; import * as Sentry from '@sentry/react-native'; import { useForm } from '@tanstack/react-form'; import * as Burnt from 'burnt'; diff --git a/apps/expo/features/trips/components/UpcomingTripsTile.tsx b/apps/expo/features/trips/components/UpcomingTripsTile.tsx index dbea3b6fa8..c4f492c00a 100644 --- a/apps/expo/features/trips/components/UpcomingTripsTile.tsx +++ b/apps/expo/features/trips/components/UpcomingTripsTile.tsx @@ -1,4 +1,5 @@ -import { ListItem, Text } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useTrips } from 'expo-app/features/trips/hooks'; import { useFeatureFlag } from 'expo-app/hooks/useFeatureFlags'; diff --git a/apps/expo/features/trips/screens/EditTripScreen.tsx b/apps/expo/features/trips/screens/EditTripScreen.tsx index bc53d83aa6..102661481a 100644 --- a/apps/expo/features/trips/screens/EditTripScreen.tsx +++ b/apps/expo/features/trips/screens/EditTripScreen.tsx @@ -1,5 +1,5 @@ import { assertDefined } from '@packrat/guards'; -import { ActivityIndicator } from '@packrat/ui/nativewindui'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; import { useTripDetailsFromStore } from 'expo-app/features/trips/hooks/useTripDetailsFromStore'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useLocalSearchParams, useRouter } from 'expo-router'; diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx index 3ebb1a2fb0..f53943f10e 100644 --- a/apps/expo/features/trips/screens/TripDetailScreen.tsx +++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx @@ -1,5 +1,8 @@ import { assertDefined } from '@packrat/guards'; -import { ActivityIndicator, Button, Card, Text } from '@packrat/ui/nativewindui'; +import { Button } from '@packrat/ui/src/button'; +import { Card } from '@packrat/ui/src/card'; +import { ActivityIndicator } from '@packrat/ui/src/loading-indicator'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm'; import { useFeatureFlag } from 'expo-app/hooks/useFeatureFlags'; @@ -100,7 +103,9 @@ export function TripDetailScreen() { {t('trips.details')} {trip.description ? ( - {trip.description} + + {trip.description} + ) : ( {t('trips.noDetailsAvailable')} @@ -197,7 +202,7 @@ export function TripDetailScreen() { {t('trailConditions.reportConditionsTitle')} - + {t('trailConditions.reportConditionsPrompt')} - + ); } @@ -84,7 +91,9 @@ function FeatureItem({ {title} - {description} + + {description} + ); diff --git a/apps/expo/features/weather/components/WeatherForecast.tsx b/apps/expo/features/weather/components/WeatherForecast.tsx index 0b798c689a..c7a5227d78 100644 --- a/apps/expo/features/weather/components/WeatherForecast.tsx +++ b/apps/expo/features/weather/components/WeatherForecast.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit'; import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; @@ -66,7 +66,9 @@ export function WeatherForecast({ )) ) : ( - {t('weather.hourlyForecastNotAvailable')} + + {t('weather.hourlyForecastNotAvailable')} + )} @@ -110,7 +112,9 @@ export function WeatherForecast({ )) ) : ( - {t('weather.dailyForecastNotAvailable')} + + {t('weather.dailyForecastNotAvailable')} + )} diff --git a/apps/expo/features/weather/components/WeatherTile.tsx b/apps/expo/features/weather/components/WeatherTile.tsx index f0badbd6b2..0915740b99 100644 --- a/apps/expo/features/weather/components/WeatherTile.tsx +++ b/apps/expo/features/weather/components/WeatherTile.tsx @@ -1,4 +1,5 @@ -import { ListItem, Text } from '@packrat/ui/nativewindui'; +import { ListItem } from '@packrat/ui/src/list'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; diff --git a/apps/expo/features/weather/screens/LocationDetailScreen.tsx b/apps/expo/features/weather/screens/LocationDetailScreen.tsx index c23e5947b2..a197a919af 100644 --- a/apps/expo/features/weather/screens/LocationDetailScreen.tsx +++ b/apps/expo/features/weather/screens/LocationDetailScreen.tsx @@ -1,5 +1,5 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; import { getWeatherBackgroundColors } from 'expo-app/features/weather/lib/weatherService'; diff --git a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx index 6a4e393c2b..94415dbd08 100644 --- a/apps/expo/features/weather/screens/LocationPreviewScreen.tsx +++ b/apps/expo/features/weather/screens/LocationPreviewScreen.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { useSpeedUnit } from 'expo-app/features/auth/hooks/useSpeedUnit'; import { useTemperatureUnit } from 'expo-app/features/auth/hooks/useTemperatureUnit'; @@ -219,7 +219,7 @@ export default function LocationPreviewScreen() { )) ) : ( - + {t('weather.hourlyForecastNotAvailable')} @@ -269,7 +269,7 @@ export default function LocationPreviewScreen() { )) ) : ( - + {t('weather.dailyForecastNotAvailable')} diff --git a/apps/expo/features/weather/screens/LocationSearchScreen.tsx b/apps/expo/features/weather/screens/LocationSearchScreen.tsx index d6b70a85dc..fc322a20ff 100644 --- a/apps/expo/features/weather/screens/LocationSearchScreen.tsx +++ b/apps/expo/features/weather/screens/LocationSearchScreen.tsx @@ -1,4 +1,4 @@ -import { Text } from '@packrat/ui/nativewindui'; +import { Text } from '@packrat/ui/src/text'; import { safeJsonParse, safeJsonStringify } from '@packrat/utils'; import * as Sentry from '@sentry/react-native'; import { Icon } from 'expo-app/components/Icon'; @@ -320,10 +320,10 @@ export default function LocationSearchScreen() { return ( - + {t('weather.noLocationsFound', { query })} - + {t('weather.tryDifferentSearch')} @@ -349,7 +349,7 @@ export default function LocationSearchScreen() { ) : locationPermissionDenied ? ( <> - + {t('weather.locationPermissionRequired')} diff --git a/apps/expo/features/weather/screens/LocationsScreen.tsx b/apps/expo/features/weather/screens/LocationsScreen.tsx index a730938c82..e87897a66d 100644 --- a/apps/expo/features/weather/screens/LocationsScreen.tsx +++ b/apps/expo/features/weather/screens/LocationsScreen.tsx @@ -1,6 +1,7 @@ -import { Button, Text } from '@packrat/ui/nativewindui'; import { getAppBarOptions } from '@packrat/ui/src/app-bar'; +import { Button } from '@packrat/ui/src/button'; import { IosTransparentHeaderOverlapFix } from '@packrat/ui/src/ios-transparent-header-overlap-fix'; +import { Text } from '@packrat/ui/src/text'; import { Icon } from 'expo-app/components/Icon'; import { SearchInput } from 'expo-app/components/SearchInput'; import { withAuthWall } from 'expo-app/features/auth/hocs'; @@ -160,7 +161,7 @@ function LocationsScreen() { - + {t('weather.noLocationsMatch', { query: searchQuery })} @@ -217,7 +218,7 @@ function LocationsScreen() { )} - + {t('weather.longPressForOptions')} @@ -240,7 +241,7 @@ function LocationsScreen() { {t('weather.noSavedLocations')} - + {t('weather.noSavedLocationsDesc')} ` (the shape the codemod left everywhere, since `Button.label` was never used) collapsed the button to a near-zero-size blob with the label overflowing outside it. This affected most already-migrated Button call sites, not just ones with a specific size prop — the root cause is structural (nested Hosts), not a size/variant issue. + **Fix**: `Button` now auto-extracts a plain string when `children` is exactly one `Text` (or raw string) with only string content, and passes it via `@expo/ui`'s `label` prop instead of nesting — see `extractLabel` in `button.tsx`. This resolves the dominant case with zero call-site rewrites. + **Multi-child `Button` content — now verified on iOS.** icon+text `Button`s still nest a `Host`-bridged child, so the nested-Host risk is structural and unchanged. But it does not actually manifest on iOS: three separate icon+text call sites render correctly on the iPhone 17 Pro simulator (iOS 26.4) — `season-suggestions.tsx`'s "Generate Season Suggestions" (leading sparkle icon), and auth's "Continue with Google" and "Continue with Apple" (leading brand icons). Correct height, centred content, icon and label on one line, no collapse. The `filled`/`outlined`/`text` variants all appeared in the same sweep. Android is a different Host implementation and is not covered by this result, so verify there separately. + +## Resolved: TextField — no Host bridge, two platform files + +`TextField` never needed a native Host bridge — the old package's implementation was already plain RN (`TextInput`/`Pressable`/`View`/Reanimated), never `@expo/ui`. The original plan (replacement map, above) assumed wrapping `@expo/ui` Universal `TextInput`, but that component has no floating-label, no Material-variant styling, and no advantage over the existing RN composition — so it was ported directly instead. + +The old package had two genuinely different platform designs (not a shared component with platform tweaks): iOS used a simple non-animated layout, Android used a Material-style floating label with Reanimated. Rather than force one design onto both platforms, both were preserved as separate files — `packages/ui/src/text-field.tsx` (Android/default, Material) and `packages/ui/src/text-field.ios.tsx` (iOS, simple) — sharing one `TextFieldProps`/`TextFieldRef` type so call sites see one API regardless of platform. Metro resolves the `.ios.tsx` suffix at bundle time from the unsuffixed import path; a platform-suffix-free base file isn't needed here since `text-field.tsx` itself is a real implementation (the Android/default), not a re-export shim. + +One typecheck fix needed: the Material `MaterialLabel`'s filled-variant background referenced `colors.border`, a key that existed on the old package's own theme but not on this app's `apps/expo/theme/colors.ts`. Changed to `colors.card` (closest surface color used for filled-variant containers elsewhere) — verified via grep that no real call site passes `materialVariant="filled"`, so this path was already dead code, fixed for type correctness only. + +Verified on-device (iOS): `auth/(create-account)/credentials.tsx` (4 stacked fields, one with `leftView`) and `auth/(login)/index.tsx` (2 fields) both render correctly — fields sized correctly, dividers between fields visible, placeholder text and Submit/Continue button positioned correctly. Android Material design not yet re-verified on-device in this migration pass (previously only visually reviewed pre-port); low risk since it's a near-1:1 port of the original 334-line file. + +## Resolved: Sheet/useSheetRef — no Host bridge, direct port + +`Sheet`/`useSheetRef` never needed `@expo/ui` either — the old package's implementation was already a thin wrapper around `@gorhom/bottom-sheet`'s `BottomSheetModal` (an actively-maintained RN library, already a JS dependency of `apps/expo`, added to `packages/ui/package.json` too). Ported unchanged to `packages/ui/src/bottom-sheet.tsx`; same zero-native-bridge category as `List`/`Card`/`Toggle`/`Checkbox`/`Avatar`. + +17 call sites updated. A few files import `Sheet`/`useSheetRef` alongside still-unmigrated `Alert`/`Form` symbols from the same `@packrat/ui/nativewindui` line (`ChatBubble.tsx`, `getPackTemplateDetailOptions.tsx`) — these were split into two import lines rather than migrated wholesale, since `Alert`/`Form` aren't done yet. + +**On-device verification gap, accepted deliberately:** `Sheet` only opens via an in-app button press (e.g. the "+" FAB on `pack-templates` opening `TemplateCreationOptions`) — there's no deep-linkable "sheet open" state, and the user's mandated `xcrun simctl`-only workflow (deep-link navigation + screenshot, no coordinate taps/AppleScript/cliclick) has no way to trigger it. Typecheck and lint are clean, and the component is an unmodified 1:1 port of an already-shipped wrapper around an unchanged third-party library — the same risk profile as other zero-Host-bridge ports that were accepted on typecheck+lint alone. Flagging here rather than silently skipping the check. + +## Resolved: Form/FormSection/FormItem — no Host bridge, direct port + +Same story as `Sheet`/`TextField`: the old package's `Form` was already plain RN `View` composition — no `@expo/ui` involved. Ported directly to `packages/ui/src/form.tsx`. One real prop-shape difference handled: `FormSection`'s `materialIconProps` used the old package's own `Icon` component (`sfSymbol`/`materialCommunityIcon` object props); this app has its own `Icon` (`apps/expo/components/Icon`) with a unified `name` string prop. All 6 real call sites already passed `materialIconProps={{ name: '...' }}` (a plain MaterialCommunityIcons name string), so the new type (`{ name: MaterialIconName }`) is a drop-in match — no call-site changes needed beyond the import swap. + +15 call sites updated (2 of them, `PackForm.tsx`/`PackTemplateForm.tsx`, needed splitting a still-mixed `DropdownMenu`+`Form` import into two lines; 3 auth screens similarly split from `AlertAnchor`). + +Verified on-device (iOS): `auth/(login)/index.tsx` renders correctly — grouped card with divider between Email/Password fields, matching the pre-migration layout exactly. + +**Phase 3 is now fully complete** — all of Text, Button, List, Toggle, TextField, Sheet, and Form/FormSection/FormItem are migrated off `@packrat-ai/nativewindui`. Remaining work is Phase 4 (`Alert`, `ContextMenu`/`DropdownMenu`, `Toolbar` — all previously deprioritized as higher-risk) and Phase 2's `SearchInput`. + +## Resolved: Alert/AlertAnchor — no @expo/ui bridge needed on either platform + +Investigated the previously-paused `Alert` (blocked earlier in this migration on an unverified `ExpoAlert.Trigger` invisible-button mechanism and `@expo/ui` Jetpack Compose `AlertDialog`'s fixed 2-button/no-prompt-slot limitation, which can't cover the old API's N-button + prompt-mode surface). Re-reading the old package's actual source resolved both blockers at once: **the old package's Android/default `Alert` was never `@expo/ui` either** — it was already built on `@rn-primitives/alert-dialog` (an unstyled RN primitive, not a native bridge). Only the docs' original replacement-map guess (SwiftUI Alert + Jetpack Compose AlertDialog) assumed otherwise. + +Given that, the simplest and lowest-risk path for both platforms: +- **`alert.ios.tsx`**: rewritten to use RN core's `Alert.alert`/`Alert.prompt` directly — both already render a real native `UIAlertController`, so there's no reason to route through `@expo/ui`'s SwiftUI `Alert` and its unverified `Trigger` mechanism at all. Same native look, zero Host risk, `Alert.prompt` is iOS-only in RN core which happens to match this file being iOS-only. +- **`alert.tsx`** (Android/default): ported directly from the old package's `@rn-primitives/alert-dialog`-based implementation — no `@expo/ui` involved, N-button layout and prompt-mode (plain-text/secure-text/login-password) all carry over unchanged. + +One real prop-shape adaptation: `materialIcon` used the old package's own `Icon` (`materialCommunityIcon` prop); this app's `Icon` takes a plain `name` string. Real call sites (`AIPacksScreen.tsx`, `DeleteAccountButton.tsx`) already passed `{ name: '...', color: '...' }`-shaped objects, so `materialIcon` is now typed `{ name: MaterialIconName; color?: string }` — a drop-in match, no call-site rewrites needed beyond the import swap. + +A type-only gap in `@rn-primitives/hooks`' `useAugmentedRef`: its return type doesn't line up with `AlertDialogPrimitive.Root`'s `ref` prop even though the runtime behavior (methods merged onto the forwarded View ref) is the documented pattern — same category as the SwiftUI/Jetpack-Compose `Host` `className` typing gap, resolved the same way (local `as unknown as React.Ref` cast with a comment naming the mismatch). + +18 call sites updated, all pure `Alert`/`AlertAnchor`/`AlertMethods` imports (no splitting needed). + +**On-device verification gap, accepted deliberately:** both platforms' `Alert` only appear after a user action (button press or, for the auth-flow error alerts, a failed form submission) — no deep-linkable "alert shown" state, and the mandated `xcrun simctl` deep-link+screenshot workflow can't type into fields or press buttons to trigger one. Typecheck and lint are clean. Risk is asymmetric by platform: iOS is essentially zero-risk (unmodified RN core native API); Android is a direct, unmodified port of already-shipped code using an already-installed primitive, same risk class as `Sheet`/`Form`. Both accepted on typecheck+lint given that profile — flagging here per the same standard as the `Sheet` gap above. + +**Android half of this gap is now closed** (2026-08-06) — see the Android `AlertDialog` verification below. **The iOS half is still open**, and one earlier note in this doc claimed otherwise: a failed login on the iOS simulator does present a real `UIAlertController`, but `app/auth/(login)/index.tsx:57` calls RN core's `Alert.alert` directly, *not* `AlertAnchor`. That screenshot therefore proves RN core works, which was never in doubt, and says nothing about `alert.ios.tsx`. Since `alert.ios.tsx` is itself a thin pass-through to the same RN core API, the residual risk is close to zero — but it has not been observed on a device, and shouldn't be recorded as if it had. + +Phase 4 remaining: `ContextMenu`/`DropdownMenu` (unverified `RNHostView`/Trigger mechanism on iOS), `Toolbar` (no `@expo/ui` equivalent identified). Phase 2's `SearchInput` also still open. + +## Resolved: ContextMenu/DropdownMenu — no @expo/ui bridge, third time this pattern holds + +The previously-documented blocker ("unverified `RNHostView`/Trigger mechanism on iOS") was based on the same wrong assumption as `Alert`: that these components used `@expo/ui`. They don't, on either platform: + +- **Android/default**: `@rn-primitives/context-menu` / `@rn-primitives/dropdown-menu` — unstyled RN primitives, not `@expo/ui`. +- **iOS**: `react-native-ios-context-menu` — a real, separate third-party native library (its own Fabric/paper native module), also not `@expo/ui`. `DropdownMenu` and `ContextMenu` share the same iOS library (`ContextMenuButton` vs. `ContextMenuView`). + +Ported directly to `packages/ui/src/context-menu/` and `packages/ui/src/dropdown-menu/` (each: `types.ts`, `utils.ts` for `create*Item`/`create*SubMenu`, the Android/default `.tsx`, the iOS `.ios.tsx`, and an `index.ts` barrel). `ContextMenuSubMenu`/`DropdownMenuSubMenu` on Android reuse `DropdownMenu` itself (same as the original), so `DropdownMenu` had to be built first. + +Adaptations beyond the mechanical port: +- `icon`/`materialIcon` fields switched from the old package's own `Icon` (`sfSymbol`/`materialCommunityIcon` object props) to this app's `Icon` (`name` string + `color`) — same pattern as `Form`'s `materialIconProps` and `Alert`'s `materialIcon`. No real call site used `icon`/`image` on any item, so this is a type-only change. +- `Button` (already-migrated `@expo/ui`-wrapped version) didn't expose `accessibilityHint` or `onLayout`, both used by menu item rows (accessibility hint text) and submenu triggers (measuring trigger position for flyout placement). Widened `packages/ui/src/button.tsx`'s `ButtonProps` to accept both and pass them through to `Host` — a real, reusable gap-fix, not menu-specific. +- Two stale `@ts-expect-error` suppressions (for a `react-native-ios-context-menu` type bug referenced in a GitHub issue) no longer reproduce against the currently-installed version's types — removed rather than left as dead suppressions (`tsc` flags unused `@ts-expect-error` as an error). +- `useMaxParams` (Biome, max 2) hit twice more: `toConfigMenu` (3 params, both iOS files) converted to an options object; `View.measure`'s 6-argument native callback (in `context-menu.tsx`'s `onTriggerLongPress`) can't be restructured since it's RN core's own signature — suppressed with `biome-ignore` and a comment naming the reason, consistent with the project's convention for third-party API shapes outside our control. + +11 call sites updated across the messages/chat feature and the two pack-category dropdown pickers (`PackForm.tsx`, `PackTemplateForm.tsx`). One dead-code exception: `ChatBubble.tsx` imports `Text as SelectableText` from the old package but its `ContextMenu` usage is fully commented out — left as-is, not migrated, since there's nothing live to migrate. + +**On-device verification**: `messages/conversations.tsx` (uses both `ContextMenu` per-row and `DropdownMenu` for the top-bar filter) loads and renders correctly via deep link — confirms the import graph and screen mount work. The actual menu-open interaction (long-press for `ContextMenu`, tap for `DropdownMenu`) requires a touch gesture the mandated `xcrun simctl` deep-link+screenshot workflow can't perform (no coordinate taps). Typecheck and lint are clean. Same accepted-gap category as `Sheet`/`Form`/`Alert` above — flagging per the same standard. + +## Resolved: Toolbar/ToolbarCTA/ToolbarIcon — no @expo/ui bridge, fourth (and final) time this pattern holds + +Same story again: the old package's `Toolbar` wrapped `expo-blur`'s `BlurView` (already an installed dependency), not `@expo/ui`. Ported directly to `packages/ui/src/toolbar.tsx`. `icon` props switched from the old package's `Icon` shape to this app's (`name` string + `color`); the one real call site (`conversations.android.tsx`'s `ToolbarCTA`) already passed a plain `{ name: '...' }` object. `ToolbarIcon` has zero real call sites but was ported anyway for API completeness (same shape as `ToolbarCTA`, cheap to keep). + +2 call sites updated (`conversations.tsx`, `conversations.android.tsx`). + +**This closes out Phase 4 and the entire component migration** — every component originally exported from `packages/ui/nativewindui/index.ts` is now ported to `packages/ui/src/`. In hindsight, all four of the "high-risk, paused" Phase 4 components (`Alert`, `ContextMenu`, `DropdownMenu`, `Toolbar`) turned out to need zero `@expo/ui` Host bridge — the original replacement-map guesses (SwiftUI Alert, Jetpack Compose AlertDialog/DropdownMenu, generic "platform-specific Toolbar") were never verified against the old package's actual source, which already used plain RN composition and third-party native libraries throughout. Only `Text`, `Button`, and `ActivityIndicator` (Phase 3) ever touched `@expo/ui` for real. + +Remaining before full removal: Phase 2's `SearchInput` (marked "reverted, pending re-migration" in the tracker), then the final removal phase (drop the `@packrat-ai/nativewindui` dependency, `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN`, etc.) once the tracker is fully empty. + +## Resolved: SearchInput — no @expo/ui bridge, kept as a component (not migrated to headerSearchBarOptions) + +The original plan (replacement map, above) called for deleting `SearchInput` entirely in favor of Expo Router's `Stack.SearchBar`/`headerSearchBarOptions` — the native nav-bar search pattern. Checked all 6 real call sites (`location-search.tsx`, `PackSelectionScreen.tsx`, `CatalogBrowserModal.tsx`, `PackStatsTile.tsx`, `LocationsScreen.tsx`, `LocationSearchScreen.tsx`): every one renders `SearchInput` inline, embedded in a modal or screen body above a list/map — none are a top-of-screen nav-bar search. `headerSearchBarOptions` doesn't fit that usage (it replaces the nav bar's own search affordance, not an in-content search field), so `SearchInput` was ported as a component instead, matching the same "old plan assumed something the old package's actual source didn't require" pattern found in every other Phase 4 component this session. + +Like `TextField`, this has two genuinely different platform designs — Android/default is a pill-shaped button-wrapped search bar, iOS is an animated cancel-button design (Reanimated `measure`-driven width animation). Ported both directly to `packages/ui/src/search-input.tsx` + `.ios.tsx`, sharing `search-input-types.ts`. `Icon` name references switched from the old package's `magnifyingglass`/`multiply` (SF Symbol names) to this app's own icon set naming (`magnify`/`close`, matching what other search/clear UI in this app already uses). + +`apps/expo/components/SearchInput.tsx` — a thin wrapper adding the Android keyboard-hide-blur fix (`useKeyboardHideBlur`) — updated to import from the new location instead of `@packrat/ui/nativewindui`; its internal alias renamed from `NativeWindUISearchInput` to `BaseSearchInput` since the old name was no longer accurate. + +Also widened `Button`'s props with `accessibilityLabel` (alongside the `accessibilityHint`/`onLayout` additions from the ContextMenu/DropdownMenu migration) — `SearchInput`'s pill-button trigger needed it. + +Verified on-device (iOS): `trip/location-search.tsx` — pill-shaped search bar renders correctly with magnifying-glass icon, placeholder text, positioned above the map. + +**This closes the entire `packages/ui/nativewindui/index.ts` tracker — every originally-exported component is now ported to `packages/ui/src/`.** Next: the final removal phase (drop `@packrat-ai/nativewindui` from `package.json`, remove `PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN` from `bunfig.toml`/docs, delete the now-empty `packages/ui/nativewindui/` directory) — not started yet. + +## Android A/B validation against the pre-migration build (2026-08-02) + +The whole migration was finally diffed screen-by-screen against a real pre-migration build on a +physical Android device (TECNO KL4). This was possible without rebuilding anything: the installed +production APK `com.packratai.mobile` v2.1.0 (tag `a1b43629c`) still ships +`@packrat-ai/nativewindui@2.2.1` and no `@expo/ui`, and none of the migration commits are +ancestors of `main` — so it is a genuine baseline. The migrated side ran in the dev client off +Metro. Two package ids, so both stay installed and you alternate between them. + +**Run Metro on a dedicated port** (`bun start --dev-client --port 8099` + `adb reverse tcp:8099 +tcp:8099`). The dev launcher discovers every Metro on the LAN and will happily attach to another +agent's server on 8081; its NSD discovery also crashed outright with a `NoSuchElementException` +when two were visible. + +Everything below was reproduced on-device, not inferred. + +### Root cause behind most of it: `Host` has no RN-side intrinsic size + +`@expo/ui`'s `Text`/`Button` render through `Host`, a bridge to a native SwiftUI/Compose surface. +Any axis `Host` does not `matchContents` is sized purely by Yoga, and Yoga sees no content — so it +collapses to zero. Normal RN text does `min(contentWidth, parentWidth)`; `Host` can only do +"shrink to content" (may overflow the parent) or "stretch to parent" (needs a stretch context). +**That one limitation produced the majority of the defects found**, and no single default fixes it +because a component cannot know its parent's flex direction. + +### Fixed + +- **`apps/expo/tailwind.config.js` never included `packages/ui/src`.** It still listed the deleted + `@packrat-ai/nativewindui` path. NativeWind is content-glob driven, so ~76 classes used *only* + in `packages/ui/src` compiled to nothing: invisible Android `ListItem` separators, zero-size + checkboxes, `Form` sections with no spacing, and an alert dialog with no width constraint + (it rendered full-bleed). One-line fix, very wide blast radius. `screens/**` and + `features/**/utils` were also uncovered. +- **`Button` is now a plain RN `Pressable`, not `@expo/ui`'s Button.** Three independent + on-device failures forced this, all from the `Host` limitation above: full-width CTAs + shrink-wrapped to their label; switching to vertical-only matching then collapsed buttons inside + a `flex-row` (the alert's "Got it" rendered one character per line); and on Android `@expo/ui` + hands `children` straight to a Compose composable, where the hosted RN view swallows the touch + so `onClick` never fires — **every `DropdownMenu` trigger was dead**, verified by instrumenting + `onPress` and seeing it never run. A `Pressable` has an intrinsic size and keeps touches, refs + and layout in the RN tree. This matches what the rest of this package already concluded. + Tradeoff: filled buttons no longer use native Material/SwiftUI button styling — they use the + app's brand tokens, which is what the pre-migration build looked like. +- **`Button` now forwards `ref` and rest props.** `@rn-primitives` menu/dialog primitives inject a + ref through `Slot` and call `.measure()` on it to place their portal; dropping it left + `triggerPosition` null so `Portal` returned null. Also restores `role`/`accessibilityState`/ + `nativeID`, which were being dropped on every menu row and alert title. +- **`Text` no longer deletes nested inline elements.** `flattenToString` mapped every element + child to `''`, silently removing the Terms/Privacy link text on the consent screen, the address + on the OTP screen and the timestamp on chat read-receipts. It now recurses, and warns in dev + when it flattens something with an `onPress`/`href` (whose tap handler cannot survive — + `@expo/ui` `Text.children` is `string`-only, so a tappable inline segment must be hoisted out). +- **`text-center` is no longer a no-op.** Aligning text inside a box shrink-wrapped to that same + text does nothing, so centered headings rendered flush-left (127 sites). A text-align class now + implies vertical-only matching plus a width, same as `wrap`. +- **~214 silently-dropped typography classes now apply.** The parser routed anything it didn't + recognise onto `Host`'s `className`, where it can never reach native text. Added: `text-white`/ + `text-black`, variant prefixes (`dark:`, `ios:`, `android:` — applied unconditionally, which + beats not at all), `tracking-*`/`leading-*` (both are supported by `UniversalTextStyle`), + arbitrary values (`text-[15px]`, `leading-[14px]`), opacity modifiers (`text-foreground/70`), + and `text-5xl`–`text-9xl`. Variant-prefixed *sizing* (`android:h-14`) is now detected too. +- **`Text` derives `wrap` from `numberOfLines`.** `numberOfLines={2}` could never be reached + because the box was sized to the unwrapped single-line width. `list.tsx`/`card.tsx` already + encoded this locally; it now lives in `text.tsx`. +- **`wrap` added to 40 prose call sites** (found by resolving each `t()` key against `en.json` and + filtering on string length, so short labels that should shrink-wrap were left alone). The + Settings "Wind & Distance" subtitle overlapping its SegmentedControl was one of these. +- **Alert title/message now wrap**, and `tonal` is a distinct filled-muted Button variant again + rather than being folded into `outlined`. + +### `Text` is a plain RN `Text` too — `@expo/ui` is no longer used for either primitive + +Fixing `Button` removed one symptom; `Text` had the same root cause and was converted for the +same reasons. That structurally eliminated the rest of the class rather than patching call sites: + +- `min(content, parent)` sizing is what real text does, so **`wrap` is no longer needed anywhere** + (the prop is kept, deprecated and inert, so the ~40 call sites still compile) and `text-center` + works without a width hack. +- Nested children compose natively, so the **consent screen's Terms/Privacy links render and are + tappable again** (verified on-device) instead of being deleted by the string-flattening. +- `numberOfLines` works natively. +- NativeWind applies `className` directly, so `dark:` variants, opacity modifiers and arbitrary + values all work — the bespoke class parser is now only consulted to see *which* properties a + className already sets, so `variant`/`color` defaults only fill gaps rather than override. +- The first Dashboard row no longer renders clipped under the large-title header: that was + `Host`'s async native measure reporting the wrong content height, and it went away with `Host`. + +One new bug this surfaced and fixed: a variant's `lineHeight` must not be kept when `className` +overrides the font size (`text-3xl` on a default `body` left a 24px line box around 30px glyphs +and clipped them top and bottom — seen on the iOS auth headline). + +**Only `ActivityIndicator` and `SegmentedControl` still touch `@expo/ui`.** In hindsight the +`Host` bridge was never a good fit for primitives that have to participate in a flexbox layout. + +### Validated on-device + +Android (TECNO KL4) and iOS (iPhone 17 sim), both against the pre-migration baseline where one +exists: auth screen, Dashboard, Profile, Settings (light + dark), Pack form, Create Trip, AI chat, +conversations list, the category `DropdownMenu` (opens, all 9 items, selection applies), the +Android `Alert` (inset card, wrapped message, horizontal buttons), the iOS native `Alert` via the +now-working `show()`, the `Sheet` (AI Mode bottom sheet — previously never validated on any +platform), and the consent screen's inline links. + +### Known remaining gaps + +- **`SearchInput`'s Android pill and `GapSuggestionRow`'s `MaskedView` shimmer are still + unvalidated.** Every reachable call site is behind authentication, a pack with items, or a + Google Maps view that crashes in this dev build (see below). +- **`ContextMenu` has no reachable Android call site** — every consumer wraps it in a + `Platform.OS !== 'ios'` bypass, so `context-menu.tsx` (the `@rn-primitives` Android + implementation) never actually runs. Either delete it or give it a real consumer. +- The dev build crashes with `IllegalStateException: API key not found` on any screen containing + a Google Map (Trips → Add Location). The JS env var is set; the native key in the installed APK + is not. Environment issue, not a migration one, but it blocks validating map-backed screens. +- `alert.ios.tsx` still drops each button's `testID` (RN core's `Alert` has no such option, so + Maestro cannot target alert buttons on iOS) and degrades `login-password` prompts to a single + plain-text field. +- Unrelated but blocking a clean checkout: `react-native-purchases`/`-ui` are declared `"*"` in + `apps/expo/package.json` but were not installed, so Metro could not bundle until `bun install`. + +## Second Android A/B pass (2026-08-03) + +A second screen-by-screen pass over the same rig, this time reading the **accessibility tree's +rects** alongside the screenshots rather than judging by eye. That is what caught the items below: +all three had survived the first pass because they look plausible in a screenshot. + +Screens diffed: Dashboard, Packs list, pack card, Pack detail, Trips, Catalog, Profile, Settings, +Weight Analysis, Pack Categories, Pack Stats, PackRat AI chat, Search (+ results), Create Pack. + +### Fixed in this pass + +- **`Button`'s `size` prop did nothing at all** — the single highest-impact defect found in either + pass, affecting 97 `size=` call sites plus the default `md` on every other button. Sizes were an + inline `style={({ pressed }) => [SIZE_STYLE[size], ...]}`, but NativeWind's `cssInterop` owns the + `style` prop on a `className`'d component and drops the **function form** Pressable needs, so the + whole array was silently discarded. Measured on-device (2x density): `size="icon"` buttons + rendered at the glyph's intrinsic ~20dp instead of 40dp, and content-sized `md` buttons had *no* + horizontal padding — "Add Item" on the pack detail screen was 142px wide around a 138px label, so + the rounded border cut into the glyphs. Now expressed as classes (`SIZE_CLASS`), which go through + the same pipeline as the variants; `cn` is `twMerge` so call sites still override. +- **Press feedback never rendered**, on `Button` and `ListItem` both — it rode on the same dropped + style array. Now `active:opacity-70`. +- **`SegmentedControl` painted from the platform palette.** It is one of the two components still + backed by `@expo/ui`, and no call site passed `tintColor`, so on Android it picked up Material + You's dynamic colour — a *brown* selected segment on the Settings unit switches, in an app whose + accent is blue everywhere else on that same screen. Now defaults to the theme's primary. + +### Verified equivalent, deliberately not changed + +- The pack card's `⋯` overflow menu sat 20px right and 19px up from baseline. This was a *symptom* + of the size bug, not a separate quirk: once `size="icon"` produced a real 40x40dp box, the glyph + landed at (616, 744) — the exact pixel the pre-migration build renders it at. Worth noting as a + reminder that small positional offsets are usually downstream of something structural. +- `md` buttons are ~15dp horizontal / 9dp vertical padding vs the baseline's ~21dp / 9dp, so + content-sized buttons are a little tighter and 4dp shorter (the height delta comes from the + `body` variant's explicit `lineHeight: 24` vs the platform's natural 28dp, not from padding). + Legible, unclipped, correctly centred — left alone rather than pixel-matched. +- Pack detail's action row *diverges in the migrated build's favour*: `className="flex-1"` on + "Ask AI" is honoured now, so the row fills the width and `⋯` sits at the right edge. The + pre-migration build left the buttons bunched left with dead space. +- Android `SearchInput` renders identically to baseline (no pill/border in either) — this was + listed as unvalidated after the first pass. +- The `Camping` filter chip clips at the right edge in **both** builds; it is a horizontal + scroller, not a regression. + +### Invisible text: a foreign-platform class suppressing `Text`'s own colour + +Found by the user, not by either sweep. `` — the "Continue +with Google" label on the auth screen — rendered black-on-black in dark mode on Android. + +The parser resolved `ios:text-foreground` unconditionally, so `Text` believed `className` already +set a colour and skipped its own themed default; NativeWind then correctly declined to apply an +`ios:` class on Android; nothing set a colour and RN fell back to black. The unconditional +resolution was *right* while `Text` rendered through `Host` (className never reached the native +text, so applying a prefixed class anyway beat dropping it). Once `Text` became a plain RN `Text` +and the parser was demoted to detecting *which* properties `className` sets, the trade-off +inverted: over-reporting now suppresses a correct default. Fixed by skipping tokens whose platform +variant does not match `Platform.OS`. `ListSectionHeader` (`ios:text-muted-foreground`, no base +colour) had the identical shape and is fixed by the same change. + +**Measure invisible text with standard deviation over the node's rect, not min..max range.** The +label measured sd 0.0 against ~43 for a real rendering — but its *range* was the full 0..255, +because one border pixel falls inside the rect. Checking `maxima` is what made an earlier pass +wrongly conclude the label rendered fine. A short label in a wide box still clears sd 3.0. + +### Two traps worth knowing before repeating this + +- **Metro dies on every source edit here** (`react-native-css-interop` → Metro's + `DependencyGraph._onHasteChange`, `TypeError: ... reading 'addedFiles'` under Node 25). Fast + Refresh never lands and the next capture silently shows the *old* bundle. Restart Metro and + reload after each edit, and re-measure before concluding a fix did nothing. +- **`screencap` races the renderer.** Screenshots taken right after navigation often show the + previous screen while `uiautomator dump` already shows the new one. When they disagree, trust the + dump. Blind tap-chains without verifying state produced several captures of the device home + screen before this was caught. + +## First side-by-side Android comparison in the test rig (2026-08-04) + +Previously the `nativewindui/apps/test-app` rig had only ever run on the iOS simulator: there was no +`android/` project and the dev client had never been installed, so no Android old-vs-new comparison +had actually rendered. Now it does — `Toggle` verified as the first component through it. + +### `Toggle` on Android: renders and behaves correctly + +Material 3 `Switch` via `@expo/ui/jetpack-compose`, side by side with the nativewindui original at +identical props. Checked state is blue (`rgb(0, 112, 233)`, the theme's Android `primary`, so the +app's accent survives instead of Material You's dynamic palette); unchecked is grey track with the +thumb correctly shrunk and moved left. Tapping the new one flipped only its own label ON→OFF and +left the old column untouched, so `onCheckedChange` fires and state is independent. This is direct +evidence for the leaf-control thesis: `ComposeClick` works fine for a self-contained native control +with no RN children — the failures documented above are specific to containers wrapping RN children. + +The M3 switch is visibly **larger** than RN's (104x96px box vs 94x54px, thumb noticeably bigger). +That is correct M3 sizing, not a defect, but it does mean rows containing a toggle get slightly +taller — worth watching on dense settings screens rather than assuming a drop-in swap. + +### Accessibility semantics require `testID` — passing it is mandatory, not optional + +A migrated leaf control that is given **no** `testID` renders as a bare +`androidx.compose.ui.platform.ComposeView` with `checkable=false clickable=false` and **no child +node at all** — invisible to both E2E and TalkBack. It is easy to mistake this for an `@expo/ui` +limitation. It isn't; it's a missing prop. + +Pass `testID` and the control emits a real node with correct semantics. Verified on-device with +`packages/ui`'s own `Toggle`: + +``` +resource-id="new_toggle" class="android.view.View" +checkable="true" checked="true" clickable="true" focusable="true" +``` + +`checked` tracks state (flipping the control gives `checked="false"` alongside the `OFF` label), so +it is both a usable E2E selector and correct accessibility semantics. + +The two platforms expose it differently, which is the trap: + +- **Android** — a **compose modifier**, `modifiers={[testID('…')]}` from + `@expo/ui/jetpack-compose/modifiers`. Not a prop, so it is absent from `SwitchProps` and easy to + conclude doesn't exist. It was moved to modifiers in + [expo/expo#39155](https://github.com/expo/expo/pull/39155); Android support originally landed in + [#38005](https://github.com/expo/expo/pull/38005). +- **iOS** — a plain `testID` **prop**, via `CommonViewModifierProps` ("Used to locate this view in + end-to-end tests"), added in [#37919](https://github.com/expo/expo/pull/37919). + +`jetpack-compose/modifiers` also has `semantics({ contentType })`, `toggleable(value, handler, +{ role })` for making a whole row togglable with a `'switch'`/`'checkbox'` role, and +`selectableGroup()`. SwiftUI has `accessibilityHidden`, `accessibilityIdentifier` and +`accessibilityInputLabels` modifiers (SDK 56.0.16), plus `accessibilityAddTraits`/`RemoveTraits` and +`accessibilityElement` in 57.0.3. + +**So: every migrated control must take and forward `testID`.** Treat a control that doesn't as an +accessibility bug, not as an upstream constraint. + +### Correction: containers **can** host interactive RN children (`RNHostView` works) + +The earlier note that containers "cannot" be migrated because `ComposeClick` never fires, and that +the `RNHostView` bridge "was tried and did not fix it", **does not hold on `@expo/ui` 56.0.16**. + +Probed directly on-device: an RN `Pressable` inside a Compose `Column`, wrapped in `RNHostView`, +receives every tap. Counters incremented 1→4 over three taps and 1→3 over two, with a pure-RN +control alongside confirming taps weren't being double-counted. Both `matchContents` and +`style={{ position: 'absolute' }}` variants worked, and the RN children rendered with their own +styling intact inside the Compose parent. + +Why the earlier attempt probably failed: the Expo docs are explicit that the shadow node's style must +match the Compose component's visual position — *"Misalignment causes hit-testing failures for +interactive elements like `Pressable`"*. That's a fixable layout problem, not a hard limit. Note also +that `matchContents` **cannot change after mount**, so it must be chosen correctly up front — a +likely source of a "it just doesn't work" conclusion. + +Relevant upstream history (both repos are on 56.0.16): + +- `RNHostView` added for Android in [#43495](https://github.com/expo/expo/pull/43495) (56.0.0); + `RNHost` for iOS in [#40938](https://github.com/expo/expo/pull/40938) (55.0.12). +- Touch fixes for RN children inside Compose landed in + [#46778](https://github.com/expo/expo/pull/46778) and + [#46805](https://github.com/expo/expo/pull/46805) (56.0.16) — i.e. *at* the installed version, so + an attempt made before it would legitimately have failed. +- Further scroll/touch fixes in 56.0.17 ([#47245](https://github.com/expo/expo/pull/47245)) and + 57.x, so upgrading is worth doing before concluding anything else is impossible. + +One real caveat, consistent with the `testID` finding above: RN children inside `RNHostView` expose +**no** accessibility node of their own (only the pure-RN control did in the probe). So container +migrations need explicit attention to a11y/E2E selectors, not just to whether touches land. + +**The leaf-vs-container split should therefore be re-tested, not assumed.** It is a version artefact, +not an architectural boundary. + +### Rig gap this exposed: PackRat's deps don't resolve from outside its repo + +Bringing Android up surfaced three Metro failures, all one root cause: PackRat's source sits outside +the test app's project root, so Metro's resolution never reaches PackRat's own `node_modules`. Fixed +in `nativewindui@fce2767` (aliases for the sibling `@packrat/*` packages, a `resolveRequest` hook +that retries via Node's resolver rooted in PackRat, and watching PackRat's `node_modules` so the +resolved files can be hashed). Verified exactly one copy of `react` in the output bundle. + +Note `bun check:migration`'s "24/24, 100%" counts components moved off nativewindui, **not** +components on `@expo/ui`. Only three are actually native today: `loading-indicator`, +`segmented-control`, `toggle`. + +## SDK 57 migration pass (2026-08-04) + +Upgraded to Expo SDK 57 (`@expo/ui` 57.0.9, RN 0.86.2) first, because the RN-children-in-Compose +fixes the container work depends on landed in 56.0.17 and 57.x. `check-types` clean on the upgrade +alone. `packages/ui` had pinned `@expo/ui` at `^56.0.9`, which silently held node_modules at 56.0.16 +even after `apps/expo` moved — both are now `~57.0.9`. + +### Drop-in replacements did most of the work + +`@expo/ui` ships eight [API-compatible replacements](https://docs.expo.dev/versions/latest/sdk/ui/drop-in-replacements/) +for popular community libraries; the docs say *"Most migrations only require changing the import."* +That held. Four packages removed from `package.json` outright: + +| Was | Now | Call sites | +|---|---|---| +| `@gorhom/bottom-sheet` | `@expo/ui/community/bottom-sheet` | 17 files | +| `@react-native-community/datetimepicker` | `@expo/ui/community/datetime-picker` | 1 | +| `@react-native-picker/picker` | `@expo/ui/community/picker` | 1 | +| `@react-native-masked-view/masked-view` | `@expo/ui/community/masked-view` | 1 | + +`Sheet` is now a real SwiftUI sheet / Material 3 `ModalBottomSheet`, and `present()`/`dismiss()`/ +`onDismiss` keep `@gorhom`'s contract so no call site logic changed. Three prop groups were dropped +because the native sheet owns them and passing them did nothing: the custom `BottomSheetBackdrop`, +`handleIndicatorStyle` (9 sites), and the `style` border/radius. Safe-area `topInset`/`bottomInset` +went too (7 sites) — the platform sheet insets its own content. + +Two pieces of scaffolding also became unnecessary: datetimepicker's config plugin (native code now +comes from `@expo/ui`) and its metro web stub (`@expo/ui` ships a real `.web` implementation). + +`BottomSheetView` is re-exported as `SheetView` with `cssInterop` applied — `@expo/ui`'s sheet types +are hand-written rather than extending `ViewProps`, so NativeWind's `className` augmentation doesn't +reach them. Same fix as `Host` in `toggle.{ios,android}.tsx`. + +### `Checkbox`: Android only, deliberately + +`checkbox.android.tsx` is now the Material 3 `Checkbox`. **iOS stays on RN** — SwiftUI has no +checkbox toggle style, only a switch, so routing iOS through `@expo/ui` would render every checkbox +as a switch. A checkmark is also the iOS convention. Not a stopgap; the right end state. + +### `TextField`: deliberately NOT migrated + +The native `TextField` has all seven decoration slots we'd want (`Label`, `Placeholder`, +`LeadingIcon`, `TrailingIcon`, `Prefix`, `Suffix`, `SupportingText`) — but it **does not accept a +React string `value`**. State must live in native observable state via +[`useNativeState`](https://docs.expo.dev/versions/latest/sdk/ui/jetpack-compose/usenativestate/), +which deliberately bypasses the JS thread. + +That is a fundamental mismatch with TanStack Form, which is controlled-only by design. Of 34 +`` call sites, 16 are controlled and 10 are wired to `field.state.value`. Migrating would +mean replacing declarative form binding with imperative workarounds across those files, risking +validation behaviour — and buying nothing visually, because RN's `TextInput` already renders a +native `EditText`/`UITextField`. + +Revisit only if `@expo/ui` grows a controlled `value: string` prop. + +### Containers: `RNHostView` works for touches but not for layout + +The earlier correction stands — **touches do fire** through `RNHostView`, so "`ComposeClick` never +fires" is not the blocker. Verified again with the real migrated `Card` on-device: an RN `Button` in +the card footer incremented a counter to `TAPS 2`. Interactivity is genuinely solved. + +**Layout is the actual blocker — but the mechanism below was wrong. See the correction under +"SDK 57 migration pass" for what was actually ruled in and out.** + +The original (56.0.16-era) reading was that `RNHostView`'s two sizing modes are mutually exclusive +for a content-sized container: `matchContents` leaves the RN subtree with no width constraint so flex +collapses, and omitting it gives the card no intrinsic height so it renders at zero height. +`matchContents` also cannot change after mount. + +Half of that survives re-testing on 57 (omitting `matchContents` does render nothing), but the +`matchContents` half does **not** — see the correction. Do not cite this paragraph as the reason +containers can't migrate. + +Expo's own `Card` documentation does only ever put Compose primitives (`Text`, `Column`) inside a +`Card`, never RN children — that remains true and is a signal about intended usage, but it is not +proof of impossibility. + +`Card` was implemented, tested, and **reverted** — twice, on two different SDK versions, for reasons +that turned out to be different each time. + +The same structural mismatch rules out the other containers, each for a concrete reason: + +| Component | Native equivalent | Why not | +|---|---|---| +| `card` | `Card` | Content-sized surface wrapping arbitrary RN children — the sizing conflict above. Tested and reverted. | +| `list` | `LazyColumn` | `list.tsx` is `FlashList`; there is no native equivalent for its virtualization/recycling API, and every item's content is arbitrary RN. | +| `alert` (Android) | `AlertDialog` | Slots are Title/Text/Confirm/Dismiss/Icon only — no text input. `prompt()` is used for typed delete-account confirmation. iOS already renders a real `UIAlertController` via RN core. | +| `form`, `toolbar` | — | Pure RN layout wrapping arbitrary children; same sizing conflict, no styling gain. | + +### Where this leaves the migration + +`@expo/ui` is the right tool for **leaf controls** (self-contained native widgets: switch, checkbox, +slider, segmented control) and for **whole-surface drop-in replacements** (bottom sheet, picker, +date picker, masked view) where the native component owns its own dimensions. + +It is *not* currently a tool for wrapping arbitrary React Native subtrees in native containers. +Until `RNHostView` can take a width constraint from the Compose parent while still reporting its +content height, container migration means breaking layout — so the remaining containers stay RN by +choice, not by oversight. + +## Correction: what actually blocks `Card` on SDK 57 (2026-08-04, second attempt) + +The 56-era explanation above was re-tested on `@expo/ui` 57.0.9 and is **wrong about the mechanism**. +Isolated one variable at a time in the rig, on Android hardware: + +| Probe | Result | +|---|---| +| `matchContents` on both `Host` and `RNHostView`, child sized only by `flex: 1` | **Renders correctly.** Card is a real 624×129 box, the `flex-1` label wraps inside it, the trailing pill keeps its background. | +| Same + an explicit numeric width on the child | Renders correctly. | +| **No** `matchContents` on `RNHostView` | Zero height, nothing visible. (This half of the old claim holds.) | +| NativeWind `className` vs inline `style` on hosted children | **Identical.** `cssInterop` reaches inside `RNHostView` fine. | +| `BlurView` (`expo-blur`, a third-party native view, as `CardFooter` uses) hosted inside | Renders correctly. | + +So `matchContents` does **not** starve the subtree of a width constraint, `className` is not lost, and +a nested native view is not the problem. Part of why the first attempt failed is version: the +`Pressable`-in-`RNHostView` fix ([expo/expo#48131](https://github.com/expo/expo/issues/48131)) landed +in **57.0.8**, and `RNHostView` now sets `layoutRoot: true` specifically so `measure()` reports the +right coordinate space. The original Card test ran on 56.0.16, before any of that. + +**But the real `Card` still renders wrong on 57**, with a distinctive signature: collapsed font sizes, +the footer `Button` losing its background, and — the useful clue — **no accessibility nodes at all** +for the hosted subtree (`uiautomator` sees the old card's `Card Title`/`Action` nodes and nothing for +the new one), even though pixels are painted. Ruled out along the way: dynamic component variable + +spread props (rewritten as literal JSX tags per branch, no change). + +That points at something specific to the compound `Card`/`CardContent`/`CardFooter` composition rather +than at `RNHostView`'s contract. It was reverted again rather than shipped half-understood. + +**If picking this up:** the isolated mechanisms all work, so bisect the compound structure — build up +from the known-good probe (`Host matchContents` → `Card` → `RNHostView matchContents` → `View` ++ `className`) one Card part at a time until the a11y nodes disappear. The missing-node symptom is +the fastest signal; `uiautomator dump` shows it immediately and doesn't race the renderer the way a +screenshot does. + +**Unchanged and still correct:** the `TextField` blocker (`useNativeState` vs TanStack Form) and the +`list` blocker (`FlashList` virtualization has no native equivalent) are independent of all this. + +## `ListItem`: named slots work, but a `Host` per row kills list scrolling (2026-08-04) + +`ListItem` looked like the strongest remaining candidate, because unlike `Card` it has a **defined +children contract** — five named slots (`HeadlineContent`, `OverlineContent`, `SupportingContent`, +`LeadingContent`, `TrailingContent`) that map directly onto our `title`/`subTitle`/`leftView`/ +`rightView`. That part delivered. Probed on Android with 200 rows in a `FlashList`: + +- Slots render correctly — headline, supporting text, and an RN `Pressable` in `TrailingContent` + (via `RNHostView`) keeping its own background. +- **Accessibility nodes exist**: 10 `li_row_*` nodes in the `uiautomator` dump, which is more than + the `Card` attempt ever produced. +- Taps on the trailing `Pressable` fire (counter went to 2). + +**But the list does not scroll.** Fifteen upward swipes left the viewport on rows 0–9; the plain-RN +list on the neighbouring route scrolled normally under the identical gesture, so it is the row +wrapper, not the probe. Wrapping each row in a Compose `Host` gives Compose the vertical drag and +`FlashList` never sees it. + +This is consistent with documented platform behaviour rather than a bug we can prop our way out of: +Android gives the parent scroll container gesture priority and Compose expects participants to opt +into `nestedScroll`. Every upstream nested-scroll fix in `@expo/ui`'s changelog is scoped to +`community/bottom-sheet` ([#46544](https://github.com/expo/expo/pull/46544), +[#47197](https://github.com/expo/expo/pull/47197), +[#47245](https://github.com/expo/expo/pull/47245)) — there is nothing for a `Host` used as a row +inside an RN list, because that is not a shape the library targets. + +So `ListItem` joins `list` as RN-by-necessity, and for a sharper reason than before: it is not that +the row can't be native, it's that **a native row inside an RN virtualized scroller breaks the +scroller**. Revisit only if `@expo/ui` exposes `nestedScroll` participation on `Host`. + +## `AlertDialog`: works end to end (2026-08-04) + +Android `alert` is the one non-leaf component that **does** migrate. Probed on-device and verified +all four behaviours: + +| Behaviour | Result | +|---|---| +| Renders as a real Material 3 dialog | ✅ correct scrim, rounded surface, M3 title/body typography | +| `Title` / `Text` slots | ✅ real accessibility nodes (`Delete pack?`, `This cannot be undone.`) | +| `ConfirmButton` / `DismissButton` callbacks | ✅ `RESULT confirmed` / `RESULT cancelled` | +| `onDismissRequest` (back button) | ✅ `RESULT dismissed` | + +Why it succeeds where `ListItem` and `Card` failed: it has named slots **and** a dialog is not inside +a scroller, so neither the bare-`children` problem nor the gesture conflict applies. + +**Two API gotchas cost time and are worth knowing:** + +1. `Button`'s press handler is **`onClick`**, not `onPress`. Passing `onPress` type-checks (it lands in + the props bag) and silently never fires. +2. `Button`'s label must be a Compose **``** child, not a bare string. With a bare string the + button renders as an unlabelled pill — no text, no a11y node — while remaining tappable. That + combination is easy to misread as "the dialog is broken". + +**Scope for the real migration:** only **one** `prompt()` call site exists in the whole app (typed +delete-account confirmation). `AlertDialog` has no text-input slot, so the plan is native +`AlertDialog` for the other 32 `alert` call sites and the RN implementation retained for that single +`prompt()`. iOS already renders a real `UIAlertController` via RN core and needs no change. + +## `DropdownMenu` / `ContextMenu`: assessed as viable, not yet built + +`@expo/ui/community/menu` exports `MenuView`, a documented drop-in for `@react-native-menu/menu` that +renders a real Material `DropdownMenu` on Android. It is the **best structural fit** of any container +assessed, because our menu is already data-driven rather than JSX-driven: + +| Ours (`dropdown-menu/types.ts`) | `MenuAction` | +|---|---| +| `actionKey` | `id` (defaults to `title`) | +| `title` / `subTitle` | `title` / `subtitle` | +| `icon` | `image` + `imageColor` | +| `destructive`, `disabled` | same | +| nested `items` | `subactions` (+ `displayInline` for inline sections) | +| `children` (trigger) | `children` (trigger) | +| `onItemPress` | `onPressAction(e.nativeEvent.event)` | + +That mapping is field-for-field, and unlike `Card` there is no arbitrary-RN-children problem — the menu +content is native, built from a data tree. + +**Probed on-device (2026-08-04) — every mechanism our menu needs works:** + +| Check | Result | +|---|---| +| Trigger renders, opens a real Material `DropdownMenu` | ✅ | +| All 6 actions render as real accessibility nodes | ✅ | +| `onPressAction` delivers the id | ✅ `PRESSED edit` | +| `subactions` submenu | ✅ `Sub one` / `Sub two` after tapping the parent | +| `state: 'on'` → checkmark (our `state.checked`) | ✅ checkmark renders | +| `image: 'trash'` (a bare Material icon name) | ❌ nothing renders — as expected | +| `destructive: true` | ❌ no red tint on Android | +| `disabled: true` | ⚠️ node reports `enabled="true"`; visually indistinguishable | + +Two concrete build tasks fall out of that: our `DropdownItem.icon` is `{ name: string }` (a Material +icon name), but `MenuAction.image` needs an `SFSymbol` (iOS) or `ImageSourcePropType` (Android), so the +icon layer needs a name→source mapping or the icons get dropped; and `destructive`/`disabled` need +`titleColor` and a manual visual treatment because Android doesn't apply them. + +**Known gaps to design around before building it:** + +- `presentMenu()` is a **no-op on iOS** in `MenuView` (SwiftUI `Menu`/`ContextMenu` cannot be opened + programmatically). Our type exposes it; only `dismissMenu?.()` is actually called, at two context-menu + sites in `messages/chat.tsx`, and it is already optional-chained. +- On Android `MenuView.title` is unused (Material `DropdownMenu` has no title slot), and `subtitle` + renders as a leading icon rather than a second line. +- `state: { checked }` has no direct equivalent — check for a `MenuAction` checked/selected field before + committing, or render the checkmark as an image. + +### Second attempt with the low-level primitive: closer, one real defect left + +The earlier `MenuView` verdict was partly the rig defect (see the correction above) — but not entirely. +Re-tested in **PackRat's own app** and the trigger question resolved cleanly: + +- A `@packrat/ui` `Button` trigger inside `MenuView` **keeps its styling** (the rig said otherwise; the + rig was wrong). +- But it **never opens the menu**. `MenuView` wraps the trigger in its own `Pressable`, which on Android + claims the gesture, so a `Pressable` child never receives the tap. Measured: a bare `View` trigger + opened the menu; the identical `Button` did not. This is [documented upstream + behaviour](https://docs.expo.dev/versions/v56.0.0/sdk/ui/drop-in-replacements/menu/) with a + documented workaround — use the lower-level primitive. + +So it was rebuilt on `@expo/ui/jetpack-compose`'s controlled `DropdownMenu` (`expanded` + +`onDismissRequest` + `Trigger`/`Items`), driving `expanded` from our own `Pressable`. That got much +further than `MenuView` ever did, verified on-device: + +- Trigger keeps its styling **and** opens the menu (both `size="icon" variant="plain"` and `primary`). +- All items render; `onClick` delivers the right `actionKey` (`edit`, `del`). +- `destructive` renders a genuinely **red** label via `elementColors.textColor` — `MenuAction` had no + way to do this. +- `disabled` renders **greyed out** via `enabled={false}` — also impossible with `MenuAction`. + +**The blocking defect, now precisely characterised: `DropdownMenuItem`'s `enabled={false}` is +presentation-only.** It greys the label but does not block the press. + +What was ruled out along the way, each measured on-device with unambiguous per-item keys and an +append-only log: + +- **Not positional mis-dispatch.** With every item enabled, each row delivered exactly its own + `actionKey` (`Alpha`→`KEY_ALPHA`, Charlie's row→`KEY_CHARLIE_DISABLED`). Dispatch is correct. +- **Not our guard.** `if (item.disabled) return` ran with `disabled=true` visible in the very payload + that got through — logically impossible unless the press never entered our closure. +- **Not a stale bundle.** Grepped the served bundle for the guard, the new probe string, and the + platform file; all three present. +- **Not a stale native callback.** Passing `onClick={undefined}` for disabled items — so + `DropdownMenuItem` forwards `onItemPressed: undefined` — still fired. Adding the disabled state to the + React `key` to force a remount still fired. + +Five independent fixes, one identical result: the press is handled natively regardless of the JS +callback. That is an upstream defect in `@expo/ui`'s `DropdownMenuItemView`, not something `packages/ui` +can work around — the only local mitigation would be to filter disabled items out of the menu entirely, +which changes the UI contract (they should be visible-but-inert) rather than fixing it. + +**Reading the native source narrows it further, and rules out the obvious explanations.** +`node_modules/@expo/ui/android/.../menu/DropdownMenuItem.kt` is correct on its face — it passes +`enabled = props.enabled` straight into Compose's `DropdownMenuItem`, which *does* block clicks — and +`ExpoUIModule.kt` wires `onItemPressed` per view. So the JS→native chain looks right, and the greyed +label proves `enabled` reaches the *colour* path. + +Two theories checked and discarded rather than left as plausible-sounding leads: + +- **"The APK lacks `@expo/ui` native code."** No — `Toggle`, `Alert` and `Card` all use `@expo/ui` + native views on this same dev client and work. An APK/dex inspection that appeared to support this + was a broken shell pipeline, not evidence. +- **"`DropdownMenuItemProps` is missing `@Field` on `enabled`."** No — `@OptimizedComposeProps` + classes deliberately don't use `@Field` (`ModalBottomSheetView` declares plain + `val skipPartiallyExpanded: Boolean = false` and our sheet works). + +So the defect is real and reproducible but its mechanism is still unidentified: `enabled` reaches the +colour path and not the click path. That is the question to put upstream, with this repro: a +`DropdownMenuItem` with `enabled={false}` and `onClick={undefined}` still dispatches `onItemPressed`. + +Original framing, superseded by the above: Tapping the greyed-out item changed the +result state to its `actionKey`. Three guards were tried — an in-handler `if (item.disabled) return`, +moving that check before `setExpanded`, and finally giving disabled items a handler that closes over +*nothing* but `setExpanded` — and the state still changed. Since the last variant holds no reference to +`onItemPress` at all, the only consistent explanation is that `DropdownMenuItem`'s `onClick` is +dispatched positionally against a native item list, so a disabled row triggers a neighbour's handler. + +That is a wrong-action-fired bug on menus whose real call sites include destructive items, which is +strictly worse than the RN menu it would replace. Reverted. Everything above is recorded so a retry is +mechanical: the next step is to check whether `DropdownMenuItem` needs a stable `key`/id the native side +uses for dispatch, or to file it upstream. + +### Superseded: first attempt (MenuView), reverted on a trigger regression that was partly the rig + +`dropdown-menu.android.tsx` was implemented against `MenuView` and **the menu itself worked**: +verified on-device via the rig's dropdown-menu route, tapping the trigger opened a real Material +`DropdownMenu` with PackRat's own items (`Edit`, `Duplicate`, `Delete`) as real accessibility nodes. + +It was reverted because of what happens to the *trigger*. The real call sites pass +` + + + ); + } + if (button.style === 'destructive') { + return ( + + + + + + ); + } + return ( + + + + + + ); + })} + + + + + + + + ); +} + +function AlertAnchor({ ref }: { ref: React.Ref }) { + return ; +} + +export { Alert, AlertAnchor }; +export type { AlertButtonDef, AlertInputValue, AlertMethods, AlertProps }; diff --git a/packages/ui/src/alert.tsx b/packages/ui/src/alert.tsx new file mode 100644 index 0000000000..71cb6d1e9a --- /dev/null +++ b/packages/ui/src/alert.tsx @@ -0,0 +1,14 @@ +/** + * Web/default `Alert` entry point. + * + * The Material-style React Native implementation lives in `alert.rn.tsx` under its own name so the + * platform files can import it without resolving back to themselves — `alert.android.tsx` importing + * `./alert` would resolve to `alert.android.tsx` on Android. Same reason `toggle-props.ts` exists. + * + * Platform routing: `alert.ios.tsx` (RN core `Alert.alert`/`Alert.prompt` → real + * `UIAlertController`), `alert.android.tsx` (Material 3 `AlertDialog`, falling back to the RN + * implementation for prompts and >2-button alerts), and this file for web. + */ + +export type { AlertButtonDef, AlertInputValue, AlertMethods, AlertProps } from './alert.rn'; +export { Alert, AlertAnchor } from './alert.rn'; diff --git a/packages/ui/src/avatar.tsx b/packages/ui/src/avatar.tsx new file mode 100644 index 0000000000..b8d4500021 --- /dev/null +++ b/packages/ui/src/avatar.tsx @@ -0,0 +1,31 @@ +import * as AvatarPrimitive from '@rn-primitives/avatar'; +import { cn } from 'expo-app/lib/cn'; + +function Avatar({ className, ...props }: AvatarPrimitive.RootProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.ImageProps) { + return ( + + ); +} + +function AvatarFallback({ className, ...props }: AvatarPrimitive.FallbackProps) { + return ( + + ); +} + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/packages/ui/src/bottom-sheet.tsx b/packages/ui/src/bottom-sheet.tsx new file mode 100644 index 0000000000..d0c8fd50db --- /dev/null +++ b/packages/ui/src/bottom-sheet.tsx @@ -0,0 +1,74 @@ +import { + BottomSheetModal, + BottomSheetView as ExpoBottomSheetView, +} from '@expo/ui/community/bottom-sheet'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import * as React from 'react'; + +/** + * `@expo/ui`'s sheet types are hand-written rather than extending RN's `ViewProps`, so NativeWind's + * global `className`→`style` augmentation never reaches them. These `cssInterop` calls make + * `className` work at runtime, and the widened types below tell TS the same — the pattern already + * used for `Host` in toggle.{ios,android}.tsx. + */ +cssInterop(ExpoBottomSheetView, { className: 'style' }); + +type SheetViewProps = React.ComponentProps & { className?: string }; + +/** Content wrapper for a `Sheet`. A pass-through view — the parent `Sheet` owns sizing. */ +const SheetView = ExpoBottomSheetView as (props: SheetViewProps) => React.ReactElement; + +type SheetProps = React.ComponentPropsWithoutRef & { + ref?: React.Ref>; +}; + +/** + * Native bottom sheet — a real SwiftUI sheet on iOS and a Material 3 `ModalBottomSheet` on + * Android, via `@expo/ui`'s API-compatible replacement for `@gorhom/bottom-sheet`. + * + * `present()`, `dismiss()` and `onDismiss` keep the same contract as `@gorhom`'s + * `BottomSheetModal`, so call sites and `useSheetRef` need no changes. + * + * Three things the old wrapper passed are deliberately gone, because the native sheet owns them and + * supplying them had no effect: the custom `BottomSheetBackdrop`, `handleIndicatorStyle`, and the + * `style` border/radius. Safe-area insets go the same way — there are no `topInset`/`bottomInset` + * props here because the platform sheet already insets its own content. + * + * `backgroundStyle` is kept: Android maps it to the sheet background (iOS uses the system one). + * + * `enablePanDownToClose` defaults to **true** here, unlike `@expo/ui`'s own `false`. The old wrapper + * passed a `BottomSheetBackdrop` to every sheet unconditionally, and that component's default + * `pressBehavior` is `'close'` — so every sheet in this app has always been dismissable by tapping + * outside it. `@expo/ui` folds that behaviour into this one prop (on Android it also gates the + * hardware back button and scrim tap), so leaving it at the library default silently traps the user + * in any sheet whose call site doesn't pass it and has no close button. Defaulting it on preserves + * the pre-migration contract; a call site that genuinely wants a non-dismissable sheet still opts + * out by passing `false` explicitly, because `...props` is spread after this. + */ +function Sheet({ + index = 0, + backgroundStyle, + enablePanDownToClose = true, + ref, + ...props +}: SheetProps) { + const { colors } = useColorScheme(); + + return ( + + ); +} + +function useSheetRef() { + return React.useRef>(null); +} + +export { Sheet, SheetView, useSheetRef }; +export type { SheetProps, SheetViewProps }; diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx new file mode 100644 index 0000000000..028e4cd792 --- /dev/null +++ b/packages/ui/src/button.tsx @@ -0,0 +1,184 @@ +import { isString } from '@packrat/guards'; +import { cn } from 'expo-app/lib/cn'; +import { Children, isValidElement, type ReactNode } from 'react'; +import { + type LayoutChangeEvent, + Pressable, + type StyleProp, + type View, + type ViewProps, + type ViewStyle, +} from 'react-native'; +import { Text } from './text'; + +// Legacy NativeWindUI variant names, kept so call sites don't need rewriting. +type LegacyButtonVariant = 'primary' | 'secondary' | 'tonal' | 'plain'; +type ButtonVariant = 'filled' | 'outlined' | 'text' | LegacyButtonVariant; +type ButtonSize = 'none' | 'sm' | 'md' | 'lg' | 'icon'; + +/** + * The legacy names are kept as distinct styles rather than folded into the three @expo/ui + * variants. `tonal` in particular is a *filled* muted button in the pre-migration design (the + * auth screen's "Sign In"), which collapsing it to `outlined` visibly changed. + */ +type ResolvedVariant = 'filled' | 'outlined' | 'tonal' | 'text'; + +/** + * Total map over every `ButtonVariant`, legacy and current, so resolving one is a plain lookup with + * no cast. `Record` makes TypeScript reject the file if a variant is + * ever added without a resolution, which an `in`-check plus two casts could not do. + */ +const VARIANT_MAP: Record = { + // legacy names, kept as distinct styles + primary: 'filled', + secondary: 'outlined', + tonal: 'tonal', + plain: 'text', + // current names resolve to themselves + filled: 'filled', + outlined: 'outlined', + text: 'text', +}; + +/** + * The old cva size classes, kept as classes rather than an inline `style`. + * + * They were briefly a `ViewStyle` lookup applied through `style`, which silently did nothing: + * NativeWind's `cssInterop` owns the `style` prop on a `className`'d component and drops the + * function form (`style={({ pressed }) => [...]}`) that Pressable needs for press feedback, so + * every size — including `icon`'s 40x40 — was discarded. On-device that left `size="icon"` + * buttons at their glyph's intrinsic ~20dp and gave content-sized `md` buttons no horizontal + * padding at all, so the rounded border cut into the label ("Add Item" on the pack detail screen). + * + * As classes they go through the same pipeline as the variants, and because `cn` is `twMerge` a + * call site's own `px-*`/`h-*` still overrides them. + */ +const SIZE_CLASS: Record = { + none: '', + sm: 'py-1 px-2.5', + md: 'py-2 px-3.5', + lg: 'py-2.5 px-5', + icon: 'h-10 w-10', +}; + +/** + * Press feedback as a class, for the same reason the sizes are: it used to ride on the same + * dropped `style={({ pressed }) => [...]}` array as SIZE_STYLE, so it could not have been + * applied either. `active:` is NativeWind's binding for Pressable's pressed state; on-device a + * held button measures 0.70x the released frame's mean brightness, and identical on release. + */ +const BASE_CLASS = 'flex-row items-center justify-center rounded-full active:opacity-70'; + +const VARIANT_CLASS: Record = { + filled: `${BASE_CLASS} bg-primary`, + outlined: `${BASE_CLASS} border border-border`, + tonal: `${BASE_CLASS} bg-primary/10 dark:bg-primary/25`, + text: BASE_CLASS, +}; + +const LABEL_CLASS: Record = { + filled: 'text-primary-foreground font-medium', + outlined: 'text-foreground font-medium', + tonal: 'text-primary font-medium', + text: 'text-primary font-medium', +}; + +function resolveVariant(variant: ButtonVariant): ResolvedVariant { + return VARIANT_MAP[variant]; +} + +/** Unwraps `` to the string `'Save'`. */ +function extractLabel(children: ReactNode): string | undefined { + const kids = Children.toArray(children); + if (kids.length !== 1) return undefined; + const only = kids[0]; + if (isString(only)) return only; + if (isValidElement(only) && only.type === Text) { + const inner = (only.props as { children?: ReactNode }).children; + return isString(inner) ? inner : undefined; + } + return undefined; +} + +type ButtonProps = { + children?: ReactNode; + label?: string; + onPress?: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + disabled?: boolean; + className?: string; + /** ANDROID ONLY on the old API — no equivalent here (no ripple-overflow root). Accepted and ignored. */ + androidRootClassName?: string; + style?: StyleProp; + onLayout?: (event: LayoutChangeEvent) => void; + /** + * Required by the `@rn-primitives` menu/dialog primitives: they inject a ref through `Slot` and + * call `.measure()` on it to position their portal. Dropping it left `triggerPosition` null and + * the portal never rendered — that is why the Android category DropdownMenu never opened. + */ + ref?: React.Ref; + /** + * Every other RN View prop (role, nativeID, accessibility*, aria-*, ...). `asChild` primitives + * inject role/accessibilityState/nativeID here, so these must reach the underlying view or + * screen readers announce menu rows as bare buttons with no checked/disabled state. + */ +} & Omit; + +/** + * A plain React Native `Pressable`, deliberately NOT `@expo/ui`'s Button. + * + * @expo/ui's Button renders through `Host`, a bridge to a native SwiftUI/Compose surface. That + * turned out to be unworkable for this app's call sites, for three reasons found on-device: + * + * 1. **Sizing is unsolvable in the general case.** `Host` has no RN-side intrinsic size, so any + * axis it doesn't `matchContents` collapses to zero. Matching both axes shrink-wraps every + * full-width CTA to its label; matching only the vertical axis fixes those but collapses the + * width of buttons in a `flex-row` (the alert's "Got it" rendered one character per line). + * A component cannot know its parent's flex direction, so no single default is correct. + * 2. **Non-text children don't work on Android at all.** @expo/ui hands `children` straight to a + * Compose composable; the hosted RN view then swallows the touch and Compose's `onClick` never + * fires. Icon buttons rendered but were dead, which broke every DropdownMenu trigger. + * 3. **The label can't be styled.** @expo/ui's Button paints its own label from the platform + * palette, so brand colors and per-variant label colors were silently dropped. + * + * A Pressable has an intrinsic size, keeps touches/refs/layout in the RN tree, and lets Yoga size + * it exactly like the NativeWindUI Button it replaces. This matches what the rest of this package + * already concluded — Alert, Sheet, Form, TextField, List, ContextMenu, DropdownMenu and Toolbar + * are all plain RN composition too. The tradeoff is that filled buttons no longer use the native + * Material/SwiftUI button styling; they use the app's own brand tokens, which is what the + * pre-migration build looked like. + */ +function Button({ + children, + label, + onPress, + variant = 'primary', + size = 'md', + disabled, + className, + androidRootClassName: _androidRootClassName, + style, + ...viewProps +}: ButtonProps) { + const resolved = resolveVariant(variant); + const resolvedLabel = label ?? extractLabel(children); + return ( + + {resolvedLabel === undefined ? ( + children + ) : ( + {resolvedLabel} + )} + + ); +} + +export { Button }; +export type { ButtonProps, ButtonSize, ButtonVariant }; diff --git a/packages/ui/src/card-parts.tsx b/packages/ui/src/card-parts.tsx new file mode 100644 index 0000000000..cc3fa44291 --- /dev/null +++ b/packages/ui/src/card-parts.tsx @@ -0,0 +1,72 @@ +import { cn } from 'expo-app/lib/cn'; +import { BlurView, type BlurViewProps } from 'expo-blur'; +import { Platform, type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native'; +import { Text, type TextProps } from './text'; + +/** + * The non-surface parts of `Card`, shared by every platform. Only `Card` itself — the surface that + * draws the elevation and background — is platform-specific (see card.android.tsx). + */ + +function CardContent({ + className, + iosBlurIntensity = 3, + iosBlurClassName, + ...props +}: ViewProps & { iosBlurIntensity?: number; iosBlurClassName?: string }) { + return ( + <> + {Platform.OS === 'ios' && ( + + )} + + + ); +} + +function CardTitle({ className, ...props }: TextProps) { + return ( + + ); +} + +function CardSubtitle({ className, variant, ...props }: TextProps) { + return ( + + ); +} + +function CardDescription({ className, ...props }: TextProps) { + return ( + 1} + {...props} + /> + ); +} + +function CardFooter({ className, ...props }: BlurViewProps) { + return ( + + ); +} + +type CardProps = ViewProps & { rootClassName?: string; rootStyle?: StyleProp }; + +export { CardContent, CardDescription, CardFooter, CardSubtitle, CardTitle }; +export type { CardProps }; diff --git a/packages/ui/src/card.android.tsx b/packages/ui/src/card.android.tsx new file mode 100644 index 0000000000..92d32d1107 --- /dev/null +++ b/packages/ui/src/card.android.tsx @@ -0,0 +1,98 @@ +import { Card as JCCard, Host as JCHost, RNHostView } from '@expo/ui/jetpack-compose'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import { View } from 'react-native'; +import type { CardProps } from './card-parts'; + +cssInterop(JCHost, { className: 'style' }); + +// jetpack-compose's Host prop type doesn't extend RN's ViewProps, so NativeWind's global +// className→style augmentation never reaches it. The cssInterop call above makes className work at +// runtime; this widened type tells TS the same. Same recipe as toggle.android.tsx. +type HostProps = ComponentProps & { className?: string }; +const Host = JCHost as (props: HostProps) => ReturnType; + +const DEFAULT_ELEVATION = 2; + +/** + * Material 3 `Card` — a real Compose card surface with the card's React Native children rendered + * inside it through `RNHostView`. + * + * Verified in PackRat's own dev client (not the nativewindui rig, which cannot answer styling + * questions for `@packrat/ui` components — see docs/migrations/nativewindui-to-expo-ui.md): the + * surface draws with real Material elevation and rounded corners, an explicit `containerColor` is + * honoured, and the hosted RN children lay out correctly inside it with their own accessibility + * nodes intact. + * + * `matchContents` on **both** the `Host` and the `RNHostView` is what makes it work: the card sizes to + * its content the way the RN version did, while the hosted subtree still lays out normally. It cannot + * change after mount (the component force-remounts on change), so it is fixed here rather than + * exposed. + * + * Requires `@expo/ui` >= 57.0.8, which fixed hosted touchables dropping presses on any finger + * movement (expo/expo#48131) and made `RNHostView` report the right coordinate space via `layoutRoot`. + * + * Call sites that pass `rootClassName="… shadow-none bg-inherit"` are opting out of the card surface + * to draw their own border. Tailwind can't reach a native card, so those intents map onto the real + * Compose props: a flat+bordered card is a filled `Card` at zero elevation with an explicit border — + * Material's `OutlinedCard` collapsed to a hairline on-device, so it is deliberately not used. + * + * **`rootClassName` is deliberately NOT forwarded to the `Host`.** That was the bug behind three + * earlier failed attempts at this component: `cssInterop` turns the className into a `style` on the + * `Host`, which fights `matchContents` and collapses the whole card to a hairline. Isolated on-device — + * `shadow-none` alone, `border` alone, and the combined ToolCard shape all collapsed, while the same + * card with no `rootClassName` rendered perfectly. The className's *intent* is read above and mapped to + * Compose props instead; `rootStyle` still passes through, since an explicit style is the caller + * knowingly sizing the host. + */ +function Card({ className, rootClassName, rootStyle, ...props }: CardProps) { + const { colors } = useColorScheme(); + + const flat = rootClassName?.includes('shadow-none') ?? false; + const bordered = rootClassName?.includes('border') ?? false; + // `bg-inherit` means "don't paint your own fill", which for a filled card means the screen + // background rather than literal transparency — a transparent container collapses the Compose + // surface to nothing (measured on-device: the OutlinedCard rendered as a bare line). + const transparent = rootClassName?.includes('bg-inherit') ?? false; + const containerColor = transparent ? colors.background : colors.card; + + const content = ( + + + + ); + + // Each branch renders the @expo/ui component as a literal JSX tag with static props — these are + // native views resolved by name, so a component variable plus a spread ternary is not equivalent. + // + // `OutlinedCard` is deliberately NOT used for the flat+bordered case: on-device it collapsed to a + // bare hairline regardless of container colour. A filled `Card` at zero elevation with an explicit + // border draws the same intent and actually renders. + if (flat && bordered) { + return ( + + + {content} + + + ); + } + + return ( + + + {content} + + + ); +} + +export { CardContent, CardDescription, CardFooter, CardSubtitle, CardTitle } from './card-parts'; +export { Card }; +export type { CardProps }; diff --git a/packages/ui/src/card.tsx b/packages/ui/src/card.tsx new file mode 100644 index 0000000000..7085de1c82 --- /dev/null +++ b/packages/ui/src/card.tsx @@ -0,0 +1,32 @@ +import { cn } from 'expo-app/lib/cn'; +import { Platform, View } from 'react-native'; +import type { CardProps } from './card-parts'; + +/** + * iOS/web `Card` surface — Android uses `card.android.tsx` (Material 3 `Card` + `RNHostView`). + * + * Kept as RN composition here deliberately. SwiftUI has no direct `Card` equivalent; the iOS look is + * a rounded container with a soft shadow, which this already draws. Web needs a real RN fallback + * regardless, since `@expo/ui` has no web target. + */ +function Card({ className, rootClassName, rootStyle, ...props }: CardProps) { + return ( + + + + ); +} + +export { CardContent, CardDescription, CardFooter, CardSubtitle, CardTitle } from './card-parts'; +export { Card }; +export type { CardProps }; diff --git a/packages/ui/src/checkbox-props.ts b/packages/ui/src/checkbox-props.ts new file mode 100644 index 0000000000..78c5c331f9 --- /dev/null +++ b/packages/ui/src/checkbox-props.ts @@ -0,0 +1,21 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +/** + * Shared surface for the `checkbox.*` implementations, kept in its own module because a platform + * file cannot import from its own fallback sibling (`checkbox.android.tsx` importing `./checkbox` + * resolves back to itself) — same reason as `toggle-props.ts`. + */ +export type CheckboxProps = { + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; + className?: string; + style?: StyleProp; + /** + * Forwarded to the native control. On Android this becomes the `testID` compose modifier, which + * is what makes the checkbox visible to the accessibility tree at all — without it the control + * renders as a bare `ComposeView` with no selectable node. See `toggle-props.ts`. + */ + testID?: string; +}; diff --git a/packages/ui/src/checkbox.android.tsx b/packages/ui/src/checkbox.android.tsx new file mode 100644 index 0000000000..87f921e6aa --- /dev/null +++ b/packages/ui/src/checkbox.android.tsx @@ -0,0 +1,60 @@ +import { Checkbox as JCCheckbox, Host as JCHost } from '@expo/ui/jetpack-compose'; +import { testID as testIDModifier } from '@expo/ui/jetpack-compose/modifiers'; +import { useControllableState } from '@rn-primitives/hooks'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import type { CheckboxProps } from './checkbox-props'; + +cssInterop(JCHost, { className: 'style' }); + +// jetpack-compose's Host prop type doesn't extend RN's ViewProps, so NativeWind's global +// className→style augmentation never reaches it. The cssInterop call above makes className work at +// runtime; this widened type tells TS the same. Same recipe as toggle.android.tsx. +type HostProps = ComponentProps & { className?: string }; +const Host = JCHost as (props: HostProps) => ReturnType; + +/** + * Material 3 `Checkbox`, replacing the `@rn-primitives/checkbox` + `Icon` composition. + * + * Leaf-control shape: one `Host` around a self-contained native control with no RN children, sized + * by `matchContents`, so it drops into the existing RN rows unchanged. + * + * iOS deliberately keeps the RN implementation (`checkbox.tsx`) — SwiftUI has no checkbox toggle + * style, only a switch, so routing iOS through `@expo/ui` would turn every checkbox into a switch. + */ +function Checkbox({ + checked: checkedProp, + defaultChecked = false, + onCheckedChange: onCheckedChangeProp, + disabled, + className, + style, + testID, +}: CheckboxProps) { + const { colors } = useColorScheme(); + const [checked = false, onCheckedChange] = useControllableState({ + prop: checkedProp, + defaultProp: defaultChecked, + onChange: onCheckedChangeProp, + }); + + return ( + + + + ); +} + +export { Checkbox }; +export type { CheckboxProps }; diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx new file mode 100644 index 0000000000..d066a097aa --- /dev/null +++ b/packages/ui/src/checkbox.tsx @@ -0,0 +1,54 @@ +import * as CheckboxPrimitive from '@rn-primitives/checkbox'; +import { useControllableState } from '@rn-primitives/hooks'; +import { Icon } from 'expo-app/components/Icon'; +import { cn } from 'expo-app/lib/cn'; +import type { CheckboxProps } from './checkbox-props'; + +/** + * iOS/web implementation — Android uses `checkbox.android.tsx` (Material 3 `Checkbox`). + * + * This is not a stopgap: SwiftUI has no checkbox toggle style, only a switch, so putting iOS on + * `@expo/ui` would render every checkbox as a switch. A checkmark is also the platform convention + * here, which this already draws. Web needs a real RN fallback regardless — `@expo/ui` has no web + * target and `apps/expo` does build for web. + */ +const HIT_SLOP = 16; + +function Checkbox({ + className, + checked: checkedProps, + onCheckedChange: onCheckedChangeProps, + defaultChecked = false, + disabled, + style, + testID, +}: CheckboxProps) { + const [checked = false, onCheckedChange] = useControllableState({ + prop: checkedProps, + defaultProp: defaultChecked, + onChange: onCheckedChangeProps, + }); + return ( + + + + + + ); +} + +export { Checkbox }; +export type { CheckboxProps }; diff --git a/packages/ui/src/context-menu/context-menu.ios.tsx b/packages/ui/src/context-menu/context-menu.ios.tsx new file mode 100644 index 0000000000..36ac8db20b --- /dev/null +++ b/packages/ui/src/context-menu/context-menu.ios.tsx @@ -0,0 +1,186 @@ +import { cssInterop } from 'nativewind'; +import { View } from 'react-native'; +import { + ContextMenuView, + type MenuAttributes, + type MenuConfig, + type MenuElementConfig, + type OnPressMenuItemEvent, + // @ts-expect-error - https://github.com/dominicstop/react-native-ios-context-menu/issues/129 +} from 'react-native-ios-context-menu'; +import type { ContextItem, ContextMenuConfig, ContextMenuProps, ContextSubMenu } from './types'; + +// Plain RN composition — ContextMenu never needed a Host bridge on iOS either, it already +// wrapped react-native-ios-context-menu (a real, unmodified third-party native library, not +// @expo/ui). Ported directly. + +cssInterop(ContextMenuView, { className: 'style' }); + +const PREVIEW_CONFIG = { + previewSize: 'INHERIT', + preferredCommitStyle: 'dismiss', + isResizeAnimated: true, + previewType: 'CUSTOM', +} as const; + +function getAuxiliaryPreviewPosition(position: 'start' | 'center' | 'end') { + switch (position) { + case 'start': + return 'targetLeading'; + case 'center': + return 'targetCenter'; + case 'end': + return 'targetTrailing'; + } +} + +function getAuxiliaryPreviewConfig(position: 'start' | 'center' | 'end') { + return { + verticalAnchorPosition: 'automatic', + horizontalAlignment: getAuxiliaryPreviewPosition(position), + transitionConfigEntrance: { mode: 'syncedToMenuEntranceTransition', shouldAnimateSize: false }, + transitionExitPreset: { mode: 'zoomAndSlide' }, + } as const; +} + +function ContextMenu({ + items, + title, + iOSItemSize = 'large', + onItemPress, + enabled = true, + iosRenderPreview, + iosOnPressMenuPreview, + iosPreviewConfig, + renderAuxiliaryPreview, + auxiliaryPreviewPosition = 'start', + materialPortalHost: _materialPortalHost, + materialSideOffset: _materialSideOffset, + materialAlignOffset: _materialAlignOffset, + materialAlign: _materialAlign, + materialWidth: _materialWidth, + materialMinWidth: _materialMinWidth, + materialLoadingText: _materialLoadingText, + materialSubMenuTitlePlaceholder: _materialSubMenuTitlePlaceholder, + materialOverlayClassName: _materialOverlayClassName, + ...props +}: ContextMenuProps) { + return ( + + + + ); +} + +export { ContextMenu }; + +// react-native-ios-context-menu ships no .d.ts files at all (see the @ts-expect-error import +// above — https://github.com/dominicstop/react-native-ios-context-menu/issues/129), so +// OnPressMenuItemEvent's own nativeEvent param has no usable type. Declared locally from the +// library's documented native event payload shape, matching the properties actually read below. +type ContextMenuNativeEvent = { + actionKey: string; + actionTitle?: string; + actionSubtitle?: string; + menuState?: 'on' | 'off' | 'mixed'; + menuAttributes?: string[]; +}; + +function toOnPressMenuItem(onItemPress: ContextMenuProps['onItemPress']): OnPressMenuItemEvent { + return ({ nativeEvent }: { nativeEvent: ContextMenuNativeEvent }) => { + onItemPress?.({ + actionKey: nativeEvent.actionKey, + title: nativeEvent.actionTitle, + subTitle: nativeEvent.actionSubtitle, + state: nativeEvent.menuState ? { checked: nativeEvent.menuState === 'on' } : undefined, + destructive: nativeEvent.menuAttributes?.includes('destructive'), + disabled: nativeEvent.menuAttributes?.includes('disabled'), + hidden: nativeEvent.menuAttributes?.includes('hidden'), + keepOpenOnPress: nativeEvent.menuAttributes?.includes('keepsMenuPresented'), + loading: false, + }); + }; +} + +function toConfigMenu({ + items, + iOSItemSize, + title, +}: Pick): MenuConfig { + return { + menuTitle: title ?? '', + menuPreferredElementSize: iOSItemSize, + menuItems: items.map((item) => ('items' in item ? toConfigSubMenu(item) : toConfigItem(item))), + }; +} + +function toConfigSubMenu(subMenu: ContextSubMenu): MenuElementConfig { + if (subMenu.loading) { + return { type: 'deferred', deferredID: `${subMenu.title ?? ''}-${Date.now()}` }; + } + return { + menuOptions: subMenu.iOSType === 'inline' ? ['displayInline'] : undefined, + menuTitle: subMenu.title ?? '', + menuSubtitle: subMenu.subTitle, + menuPreferredElementSize: subMenu.iOSItemSize, + menuItems: subMenu.items.map((item) => + 'items' in item ? toConfigSubMenu(item) : toConfigItem(item), + ), + }; +} + +function toConfigItem(item: ContextItem): MenuElementConfig { + if (item.loading) { + return { type: 'deferred', deferredID: `${item.actionKey}-deferred}` }; + } + const menuAttributes: MenuAttributes[] = []; + if (item.destructive) menuAttributes.push('destructive'); + if (item.disabled) menuAttributes.push('disabled'); + if (item.hidden) menuAttributes.push('hidden'); + if (item.keepOpenOnPress) menuAttributes.push('keepsMenuPresented'); + return { + actionKey: item.actionKey, + actionTitle: item.title ?? '', + actionSubtitle: item.subTitle, + menuState: item.state?.checked ? 'on' : 'off', + menuAttributes, + discoverabilityTitle: item.subTitle, + icon: item?.image?.url + ? { + type: 'IMAGE_REMOTE_URL', + imageValue: { url: item.image.url }, + imageOptions: { cornerRadius: item.image.cornerRadius, tint: item.image.tint }, + } + : item.icon + ? { iconType: 'SYSTEM', iconValue: item.icon.name, iconTint: item.icon.color } + : undefined, + }; +} + +function getPreviewConfig({ + hasPreview, + iosPreviewConfig, +}: { + hasPreview: boolean; + iosPreviewConfig?: ContextMenuProps['iosPreviewConfig']; +}) { + if (!hasPreview) return iosPreviewConfig; + if (!iosPreviewConfig) return PREVIEW_CONFIG; + return { ...PREVIEW_CONFIG, ...iosPreviewConfig }; +} diff --git a/packages/ui/src/context-menu/context-menu.tsx b/packages/ui/src/context-menu/context-menu.tsx new file mode 100644 index 0000000000..3d81a5a32f --- /dev/null +++ b/packages/ui/src/context-menu/context-menu.tsx @@ -0,0 +1,404 @@ +import { isNumber } from '@packrat/guards'; +import * as ContextMenuPrimitive from '@rn-primitives/context-menu'; +import { useAugmentedRef, useRelativePosition } from '@rn-primitives/hooks'; +import { Icon } from 'expo-app/components/Icon'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import * as Haptics from 'expo-haptics'; +import * as React from 'react'; +import { + Image, + type LayoutChangeEvent, + type LayoutRectangle, + Pressable, + StyleSheet, + View, + type ViewProps, +} from 'react-native'; +import Animated, { + FadeIn, + FadeInLeft, + FadeOut, + FadeOutLeft, + LayoutAnimationConfig, + LinearTransition, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button } from '../button'; +import { DropdownMenu } from '../dropdown-menu'; +import { Text } from '../text'; +import type { ContextItem, ContextMenuMethods, ContextMenuProps, ContextSubMenu } from './types'; + +// Plain RN composition — ContextMenu never needed a Host bridge, it already wrapped +// @rn-primitives/context-menu (an unstyled RN primitive, not @expo/ui). Ported directly. + +const ContextMenuContext = React.createContext<{ + onItemPress: ContextMenuProps['onItemPress']; + dismissMenu?: () => void; + materialLoadingText: string; + materialSubMenuTitlePlaceholder: string; + subMenuRefs: React.RefObject; + closeSubMenus: () => void; +} | null>(null); + +const ROOT_DEFAULT_LAYOUT = { height: 0, width: 0, pageX: 0, pageY: 0 }; + +function ContextMenu({ + ref, + items, + title, + iOSItemSize: _iOSItemSize, + onItemPress: onItemPressProp, + enabled: _enabled = true, + children, + materialPortalHost, + materialSideOffset = 2, + materialAlignOffset, + materialAlign = 'center', + materialWidth, + materialMinWidth = 200, + materialLoadingText = 'Loading...', + materialSubMenuTitlePlaceholder = 'More ...', + iosRenderPreview: _iosRenderPreview, + iosOnPressMenuPreview: _iosOnPressMenuPreview, + iosPreviewConfig: _iosPreviewConfig, + renderAuxiliaryPreview, + auxiliaryPreviewPosition = 'start', + materialOverlayClassName, + ...props +}: ContextMenuProps) { + const [rootLayout, setRootLayout] = React.useState(ROOT_DEFAULT_LAYOUT); + const [auxiliaryContentLayout, setAuxiliaryContentLayout] = + React.useState(null); + const [contentLayout, setContentLayout] = React.useState(null); + const subMenuRefs = React.useRef([]); + const insets = useSafeAreaInsets(); + const triggerRef = React.useRef(null); + const rootRef = useAugmentedRef({ + ref: ref as React.Ref, + methods: { + presentMenu: () => triggerRef.current?.open(), + dismissMenu, + }, + deps: [triggerRef.current], + }); + + const positionStyle = useRelativePosition({ + align: auxiliaryPreviewPosition, + avoidCollisions: true, + triggerPosition: rootLayout, + contentLayout: auxiliaryContentLayout, + alignOffset: 0, + insets: { + top: insets.top, + right: 8, + bottom: insets.bottom + (contentLayout?.height ?? 0), + left: 8, + }, + sideOffset: 4, + side: 'top', + disablePositioningStyle: false, + }); + + function onLayout(event: LayoutChangeEvent) { + setAuxiliaryContentLayout(event.nativeEvent.layout); + } + + function onTriggerLongPress() { + // biome-ignore lint/complexity/useMaxParams: RN core's View.measure callback signature, not ours to restructure + rootRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setRootLayout({ height, width, pageX, pageY }); + }); + } + + function closeSubMenus() { + for (const subMenuRef of subMenuRefs.current) { + subMenuRef.dismissMenu?.(); + } + } + + function onItemPress(item: Omit) { + closeSubMenus(); + onItemPressProp?.(item); + } + + function dismissMenu() { + triggerRef.current?.close(); + } + + function onOpenChange(open: boolean) { + if (!open) { + setAuxiliaryContentLayout(null); + setContentLayout(null); + return; + } + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + } + + function onContentLayout(ev: LayoutChangeEvent) { + setContentLayout(ev.nativeEvent.layout); + } + + return ( + + } + relativeTo="trigger" + onOpenChange={onOpenChange} + {...props} + collapsable={false} + > + + {children} + + + + + + {renderAuxiliaryPreview && contentLayout && ( + + {renderAuxiliaryPreview()} + + )} + + + {!!title && {title}} + + + + + + + + + + ); +} + +export { ContextMenu }; + +function useContextMenuContext() { + const context = React.useContext(ContextMenuContext); + if (!context) { + throw new Error( + 'ContextMenu compound components cannot be rendered outside the ContextMenu component', + ); + } + return context; +} + +function ContextMenuAuxiliaryPreview({ + style, + onLayout, + children, +}: Required>) { + return ( + + {children} + + ); +} + +function ContextMenuInnerContent({ items }: { items: (ContextItem | ContextSubMenu)[] }) { + const { materialLoadingText } = useContextMenuContext(); + const id = React.useId(); + + return ( + + {items.map((item, index) => { + if (item.loading) { + return ( + + + + ); + } + if ('items' in item) { + if (item.items.length === 0) return null; + return ( + + + + ); + } + return ( + + + + ); + })} + + ); +} + +function ContextMenuLabel(props: { children: React.ReactNode }) { + return ( + + {props.children} + + ); +} + +function ContextMenuItem(props: Omit) { + const { colors } = useColorScheme(); + const { onItemPress } = useContextMenuContext(); + + function onPress() { + onItemPress?.(props); + } + + if (props.hidden) return null; + + return ( + + + + + + ); +} + +const DEFAULT_LAYOUT = { width: 0, height: 0 }; + +function ContextMenuSubMenu({ title, subTitle, items }: Omit) { + const { colors } = useColorScheme(); + const { + onItemPress: onContextMenuItemPress, + dismissMenu, + materialSubMenuTitlePlaceholder, + subMenuRefs, + closeSubMenus, + } = useContextMenuContext(); + const [triggerLayout, setTriggerLayout] = React.useState(DEFAULT_LAYOUT); + + function onItemPress(item: Omit) { + dismissMenu?.(); + onContextMenuItemPress?.(item); + } + + function onLayout(ev: LayoutChangeEvent) { + setTriggerLayout(ev.nativeEvent.layout); + } + + function addSubMenuRef(subMenuRef: ContextMenuMethods | null) { + if (!subMenuRef) return; + subMenuRefs.current.push(subMenuRef); + } + + return ( + } + > + + + ); +} diff --git a/packages/ui/src/context-menu/index.ts b/packages/ui/src/context-menu/index.ts new file mode 100644 index 0000000000..0842824731 --- /dev/null +++ b/packages/ui/src/context-menu/index.ts @@ -0,0 +1,9 @@ +export { ContextMenu } from './context-menu'; +export type { + ContextItem, + ContextMenuConfig, + ContextMenuMethods, + ContextMenuProps, + ContextSubMenu, +} from './types'; +export { createContextItem, createContextSubMenu } from './utils'; diff --git a/packages/ui/src/context-menu/types.ts b/packages/ui/src/context-menu/types.ts new file mode 100644 index 0000000000..f6286bd925 --- /dev/null +++ b/packages/ui/src/context-menu/types.ts @@ -0,0 +1,85 @@ +import type { View, ViewProps } from 'react-native'; + +type ContextMenuIcon = { name: string; color?: string }; + +type ContextItem = { + actionKey: string; + title?: string; + subTitle?: string; + state?: { checked: boolean }; + keepOpenOnPress?: boolean; + // iOS 14 and above + loading?: boolean; + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; + // icon or image, not both — image has higher priority + icon?: ContextMenuIcon; + image?: { url?: string; cornerRadius?: number; tint?: string }; +}; + +type ContextMenuSubMenuDropdown = { + iOSType?: 'dropdown'; + iOSItemSize?: 'large'; + destructive?: boolean; +}; + +type ContextMenuSubMenuInline = { + iOSType: 'inline'; + iOSItemSize?: 'small' | 'medium'; +}; + +type ContextSubMenu = (ContextMenuSubMenuDropdown | ContextMenuSubMenuInline) & { + title: string; + subTitle?: string; + loading?: boolean; + items: (ContextItem | ContextSubMenu)[]; +}; + +type ContextMenuConfig = { + title?: string; + items: (ContextItem | ContextSubMenu)[]; + iOSItemSize?: 'small' | 'medium' | 'large'; +}; + +type ContextMenuMethods = View & { + presentMenu?: () => void; + dismissMenu?: () => void; +}; + +type ContextMenuProps = ContextMenuConfig & + ViewProps & { + ref?: React.Ref; + children: React.ReactNode; + onItemPress?: (item: Omit) => void; + enabled?: boolean; + iosRenderPreview?: () => React.ReactElement; + iosOnPressMenuPreview?: () => void; + iosPreviewConfig?: { + previewType?: 'DEFAULT' | 'CUSTOM'; + previewSize?: 'INHERIT' | 'STRETCH'; + isResizeAnimated?: boolean; + borderRadius?: number; + backgroundColor?: string; + preferredCommitStyle?: 'dismiss' | 'pop'; + }; + renderAuxiliaryPreview?: () => React.ReactElement; + auxiliaryPreviewPosition?: 'start' | 'center' | 'end'; + materialPortalHost?: string; + materialSideOffset?: number; + materialAlignOffset?: number; + materialAlign?: 'start' | 'center' | 'end'; + materialWidth?: number; + materialMinWidth?: number; + materialLoadingText?: string; + materialSubMenuTitlePlaceholder?: string; + materialOverlayClassName?: string; + }; + +export type { + ContextMenuProps, + ContextMenuConfig, + ContextSubMenu, + ContextItem, + ContextMenuMethods, +}; diff --git a/packages/ui/src/context-menu/utils.ts b/packages/ui/src/context-menu/utils.ts new file mode 100644 index 0000000000..5ab5bf4972 --- /dev/null +++ b/packages/ui/src/context-menu/utils.ts @@ -0,0 +1,20 @@ +import type { ContextItem, ContextSubMenu } from './types'; + +function createContextSubMenu({ + subMenu, + items, +}: { + subMenu: Omit; + items: ContextSubMenu['items']; +}) { + // safe-cast: Object.assign's return type is a plain intersection, not the ContextSubMenu + // union — merging items onto the rest of the fields genuinely produces a ContextSubMenu, + // but TS can't express that through Object.assign's signature. + return Object.assign(subMenu, { items }) as ContextSubMenu; +} + +function createContextItem(item: ContextItem) { + return item; +} + +export { createContextSubMenu, createContextItem }; diff --git a/packages/ui/src/dropdown-menu/dropdown-menu.android.tsx b/packages/ui/src/dropdown-menu/dropdown-menu.android.tsx new file mode 100644 index 0000000000..8098145204 --- /dev/null +++ b/packages/ui/src/dropdown-menu/dropdown-menu.android.tsx @@ -0,0 +1,115 @@ +import { + DropdownMenu as JCDropdownMenu, + DropdownMenuItem as JCDropdownMenuItem, + Host as JCHost, + Text as JCText, + RNHostView, +} from '@expo/ui/jetpack-compose'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import * as React from 'react'; +import { Pressable } from 'react-native'; +import type { DropdownItem, DropdownMenuProps, DropdownSubMenu } from './types'; + +/** + * Material 3 `DropdownMenu` for Android, replacing the `@rn-primitives/dropdown-menu` + Reanimated + * composition. + * + * Our menu is data-driven — items are a serialisable tree (`actionKey`, `title`, nested `items`) — so + * only the trigger stays React Native while the menu surface is entirely native. + * + * **Uses the low-level `DropdownMenu` primitive, not `community/menu`'s `MenuView`.** `MenuView` wraps + * the trigger in its own `Pressable` which claims the Android gesture, so a `Pressable` child — which + * every one of our call sites passes, as ` + + ); + } + if ('items' in item) { + if (item.items.length === 0) return null; + return ( + + + + ); + } + return ( + + + + ); + })} + + ); +} + +function DropdownMenuLabel(props: { children: React.ReactNode }) { + return ( + + {props.children} + + ); +} + +function DropdownMenuItem(props: Omit) { + const { colors } = useColorScheme(); + const { onItemPress } = useDropdownContext(); + + function onPress() { + onItemPress?.(props); + } + + if (props.hidden) return null; + + return ( + + + + + + ); +} + +const DEFAULT_LAYOUT = { width: 0, height: 0 }; + +function DropdownMenuSubMenu({ title, subTitle, items }: Omit) { + const { + onItemPress: onDropdownItemPress, + dismissMenu, + materialSubMenuTitlePlaceholder, + subMenuRefs, + closeSubMenus, + } = useDropdownContext(); + const [triggerLayout, setTriggerLayout] = React.useState(DEFAULT_LAYOUT); + const { colors } = useColorScheme(); + + function onItemPress(item: Omit) { + dismissMenu?.(); + onDropdownItemPress?.(item); + } + + function onLayout(ev: LayoutChangeEvent) { + setTriggerLayout(ev.nativeEvent.layout); + } + + function addSubMenuRef(subMenuRef?: DropdownMenuMethods | null) { + if (!subMenuRef) return; + subMenuRefs.current.push(subMenuRef); + } + + return ( + } + > + + + ); +} diff --git a/packages/ui/src/dropdown-menu/index.ts b/packages/ui/src/dropdown-menu/index.ts new file mode 100644 index 0000000000..93fd4c818a --- /dev/null +++ b/packages/ui/src/dropdown-menu/index.ts @@ -0,0 +1,9 @@ +export { DropdownMenu } from './dropdown-menu'; +export type { + DropdownItem, + DropdownMenuConfig, + DropdownMenuMethods, + DropdownMenuProps, + DropdownSubMenu, +} from './types'; +export { createDropdownItem, createDropdownSubMenu } from './utils'; diff --git a/packages/ui/src/dropdown-menu/types.ts b/packages/ui/src/dropdown-menu/types.ts new file mode 100644 index 0000000000..f009d7dd59 --- /dev/null +++ b/packages/ui/src/dropdown-menu/types.ts @@ -0,0 +1,73 @@ +import type { View, ViewProps } from 'react-native'; + +type DropdownIcon = { name: string; color?: string }; + +type DropdownItem = { + actionKey: string; + title?: string; + subTitle?: string; + state?: { checked: boolean }; + keepOpenOnPress?: boolean; + // iOS 14 and above + loading?: boolean; + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; + // icon or image, not both — image has higher priority + icon?: DropdownIcon; + image?: { url?: string; cornerRadius?: number; tint?: string }; +}; + +type DropdownSubMenuDropdown = { + iOSType?: 'dropdown'; + iOSItemSize?: 'large'; + destructive?: boolean; +}; + +type DropdownSubMenuInline = { + iOSType: 'inline'; + iOSItemSize?: 'small' | 'medium'; +}; + +type DropdownSubMenu = (DropdownSubMenuDropdown | DropdownSubMenuInline) & { + title: string; + subTitle?: string; + loading?: boolean; + items: (DropdownItem | DropdownSubMenu)[]; +}; + +type DropdownMenuConfig = { + title?: string; + items: (DropdownItem | DropdownSubMenu)[]; + iOSItemSize?: 'small' | 'medium' | 'large'; +}; + +type DropdownMenuMethods = View & { + presentMenu?: () => void; + dismissMenu?: () => void; +}; + +type DropdownMenuProps = DropdownMenuConfig & + ViewProps & { + ref?: React.Ref; + children: React.ReactNode; + onItemPress?: (item: Omit) => void; + enabled?: boolean; + materialPortalHost?: string; + materialSideOffset?: number; + materialAlignOffset?: number; + materialAlign?: 'start' | 'center' | 'end'; + materialWidth?: number; + materialMinWidth?: number; + materialLoadingText?: string; + materialSubMenuTitlePlaceholder?: string; + materialOverlayClassName?: string; + }; + +export type { + DropdownMenuProps, + DropdownMenuConfig, + DropdownSubMenu, + DropdownItem, + DropdownMenuMethods, +}; diff --git a/packages/ui/src/dropdown-menu/utils.ts b/packages/ui/src/dropdown-menu/utils.ts new file mode 100644 index 0000000000..8e2510674c --- /dev/null +++ b/packages/ui/src/dropdown-menu/utils.ts @@ -0,0 +1,20 @@ +import type { DropdownItem, DropdownSubMenu } from './types'; + +function createDropdownSubMenu({ + subMenu, + items, +}: { + subMenu: Omit; + items: DropdownSubMenu['items']; +}) { + // safe-cast: Object.assign's return type is a plain intersection, not the DropdownSubMenu + // union — merging items onto the rest of the fields genuinely produces a DropdownSubMenu, + // but TS can't express that through Object.assign's signature. + return Object.assign(subMenu, { items }) as DropdownSubMenu; +} + +function createDropdownItem(item: DropdownItem) { + return item; +} + +export { createDropdownSubMenu, createDropdownItem }; diff --git a/packages/ui/src/form.tsx b/packages/ui/src/form.tsx new file mode 100644 index 0000000000..af797f9a09 --- /dev/null +++ b/packages/ui/src/form.tsx @@ -0,0 +1,118 @@ +import { Icon } from 'expo-app/components/Icon'; +import type { MaterialIconName } from 'expo-app/components/Icon/types'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import * as React from 'react'; +import { Platform, View, type ViewProps, type ViewStyle } from 'react-native'; +import { Text } from './text'; + +// Plain RN View composition — Form never needed a Host bridge, ported directly. + +function Form({ className, ...props }: ViewProps) { + return ; +} + +// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522 +const BORDER_CURVE: ViewStyle = { borderCurve: 'continuous' }; + +type FormSectionProps = ViewProps & { + rootClassName?: string; + footnote?: string; + footnoteClassName?: string; + ios?: { + title: string; + titleClassName?: string; + }; + materialIconProps?: { name: MaterialIconName }; +}; + +function FormSection({ + rootClassName, + className, + footnote, + footnoteClassName, + ios, + materialIconProps, + style = BORDER_CURVE, + children: childrenProps, + ...props +}: FormSectionProps) { + const { colors } = useColorScheme(); + const children = React.useMemo(() => { + if (Platform.OS !== 'ios') return childrenProps; + const childrenArray = React.Children.toArray(childrenProps); + return React.Children.map(childrenArray, (child, index) => { + if (!React.isValidElement(child)) return child; + const isLast = index === childrenArray.length - 1; + return React.cloneElement( + child as React.ReactElement, + { isLast }, + ); + }); + }, [childrenProps]); + + return ( + + {Platform.OS === 'ios' && !!ios?.title && ( + + {ios.title} + + )} + {!!materialIconProps && ( + + + + )} + + + {children} + + {!!footnote && ( + + {footnote} + + )} + + + ); +} + +function FormItem({ + className, + isLast, + iosSeparatorClassName, + ...props +}: ViewProps & { + isLast?: boolean; + iosSeparatorClassName?: string; +}) { + return ( + <> + + {Platform.OS === 'ios' && !isLast && ( + + )} + + ); +} + +export { Form, FormItem, FormSection }; diff --git a/packages/ui/src/lib/text-class-parser.ts b/packages/ui/src/lib/text-class-parser.ts new file mode 100644 index 0000000000..19975ff7db --- /dev/null +++ b/packages/ui/src/lib/text-class-parser.ts @@ -0,0 +1,429 @@ +import { isObject } from '@packrat/guards'; +import { Platform } from 'react-native'; +import colors from 'tailwindcss/colors'; + +// Matches expo-app/theme/colors.ts COLORS[colorScheme] shape. +type ThemeColors = { + grey6: string; + grey5: string; + grey4: string; + grey3: string; + grey2: string; + grey: string; + yellow: string; + green: string; + background: string; + foreground: string; + root: string; + card: string; + destructive: string; + primary: string; +}; + +type ParsedTextStyle = { + fontWeight?: + | 'normal' + | 'bold' + | '100' + | '200' + | '300' + | '400' + | '500' + | '600' + | '700' + | '800' + | '900'; + fontSize?: number; + color?: string; + textAlign?: 'left' | 'right' | 'center'; + letterSpacing?: number; + lineHeight?: number; +}; + +const FONT_WEIGHT: Record = { + 'font-thin': '100', + 'font-extralight': '200', + 'font-light': '300', + 'font-normal': 'normal', + 'font-medium': '500', + 'font-semibold': '600', + 'font-bold': 'bold', + 'font-extrabold': '800', + 'font-black': '900', +}; + +const FONT_SIZE: Record = { + 'text-xs': 12, + 'text-sm': 14, + 'text-base': 16, + 'text-lg': 18, + 'text-xl': 20, + 'text-2xl': 24, + 'text-3xl': 30, + 'text-4xl': 36, + 'text-5xl': 48, + 'text-6xl': 60, + 'text-7xl': 72, + 'text-8xl': 96, + 'text-9xl': 128, +}; + +// Tailwind's tracking-* scale is in em; converted against the resolved font size at parse time. +const LETTER_SPACING_EM: Record = { + 'tracking-tighter': -0.05, + 'tracking-tight': -0.025, + 'tracking-normal': 0, + 'tracking-wide': 0.025, + 'tracking-wider': 0.05, + 'tracking-widest': 0.1, +}; + +// Tailwind's leading-* keyword scale is a unitless multiple of the font size. +const LINE_HEIGHT_RATIO: Record = { + 'leading-none': 1, + 'leading-tight': 1.25, + 'leading-snug': 1.375, + 'leading-normal': 1.5, + 'leading-relaxed': 1.625, + 'leading-loose': 2, +}; + +// leading- is n * 0.25rem (4px) in Tailwind's spacing scale. +const LEADING_STEP = /^leading-(\d+(?:\.\d+)?)$/; +// Arbitrary values: text-[15px], leading-[14px], tracking-[0.5px]. +const ARBITRARY_PX = /^\[(-?\d+(?:\.\d+)?)(?:px)?\]$/; +const REM_PX = 16; + +const TEXT_ALIGN: Record = { + 'text-left': 'left', + 'text-right': 'right', + 'text-center': 'center', +}; + +// Semantic theme tokens (rely on the live-resolved `colors` from useColorScheme, not a hex constant). +const THEME_COLOR_KEY: Record = { + 'text-foreground': 'foreground', + 'text-primary': 'primary', + 'text-muted-foreground': 'grey', + 'text-destructive': 'destructive', +}; + +// *-foreground tokens are white in both themes (see apps/expo/global.css) — not present in +// expo-app/theme/colors.ts's COLORS object, so resolved as a fixed value instead. +const FIXED_COLOR: Record = { + 'text-primary-foreground': '#FFFFFF', + 'text-secondary-foreground': '#FFFFFF', + 'text-destructive-foreground': '#FFFFFF', + // Shadeless palette colors: `text-white`/`text-black` have no numeric shade, so the + // TEXT_COLOR_CLASS regex below never matched them and they fell through to Host, where they + // could not reach the native text at all — white-on-primary labels rendered in body color. + 'text-white': '#FFFFFF', + 'text-black': '#000000', + 'text-transparent': 'transparent', +}; + +type TailwindPalette = typeof colors; + +const TEXT_COLOR_CLASS = /^text-([a-z]+)-(\d{2,3})$/; + +function resolveTailwindPaletteColor(className: string): string | undefined { + const match = className.match(TEXT_COLOR_CLASS); + if (!match) return undefined; + const [, family, shade] = match; + if (!family || !shade) return undefined; + // safe-cast: `family` is a regex capture group (arbitrary string), not statically known to be + // a real Tailwind color family — indexing widens it to the palette's key type on purpose; + // the isObject guard below is what actually makes this safe at runtime. + const palette = colors[family as keyof TailwindPalette]; + if (!palette || !isObject(palette)) return undefined; + // safe-cast: same reasoning as above, but for `shade` against the now-narrowed palette object — + // TS can't prove an arbitrary string key maps to `string`, only that the object shape allows it. + return (palette as Record)[shade]; +} + +// Classes that give Yoga an explicit sizing signal for the Host box (stretch/grow/fixed +// dimensions). When none of these are present, Host has nothing to size itself by and +// collapses to zero height — matchContents is needed so it sizes to its native content instead. +// The two are mutually exclusive: matchContents fights flex-1 (see button.tsx/text.tsx comments). +const SIZING_CLASS = /^(flex-1|flex-auto|flex-grow|self-stretch|w-|h-|min-w-|min-h-)/; + +function hasExplicitSizing(tokens: string[]): boolean { + // Variant-prefixed sizing counts too: `android:h-14` is still an explicit height on the + // platform that matters. Testing the raw token missed those, so a prefixed-only sizing class + // let the Host shrink-wrap and silently lose the declared dimension (e.g. SearchInput's pill). + return tokens.some((token) => SIZING_CLASS.test(stripVariantPrefix(token))); +} + +const WHITESPACE = /\s+/; + +// NativeWind/Tailwind variant prefixes are colon-separated and may stack (dark:ios:text-sm). +const VARIANT_PREFIX = /^(?:[a-z][a-z0-9-]*:)+/; + +function stripVariantPrefix(token: string): string { + return token.replace(VARIANT_PREFIX, ''); +} + +const PLATFORM_VARIANTS = new Set(['ios', 'android', 'web']); + +/** + * True when a token is scoped to a platform we are not running on (`ios:text-foreground` on + * Android). Those must not be reported as styling this text. + * + * `Text` no longer renders through `Host`; it is a plain RN `Text`, and it uses this parser only + * to detect *which* properties `className` already sets so its variant/color defaults fill the + * gaps. Resolving a foreign-platform class therefore makes `Text` suppress its own themed color + * and hand responsibility to NativeWind — which correctly declines to apply an `ios:` class on + * Android. Nothing sets a color, RN falls back to black, and the label is invisible on a dark + * background. That is the "Continue with Google" label on the auth screen, measured on-device at + * standard deviation 0 across its own text rect. + */ +function isForeignPlatformToken(token: string): boolean { + const prefix = VARIANT_PREFIX.exec(token)?.[0]; + if (!prefix) return false; + return prefix + .slice(0, -1) + .split(':') + .some((variant) => PLATFORM_VARIANTS.has(variant) && variant !== Platform.OS); +} + +type ParsedValueToken = + | { kind: 'color'; value: string } + | { kind: 'fontSize'; value: number } + | { kind: 'lineHeight'; value: number } + | { kind: 'letterSpacing'; value: number }; + +/** + * Handles the value-carrying utilities that can't live in a lookup table: the Tailwind palette + * (`text-red-500`), arbitrary values (`text-[15px]`, `leading-[14px]`), opacity modifiers + * (`text-foreground/70`), and the numeric leading scale (`leading-6`). + */ +function parseValueToken({ + token, + themeColors, +}: { + token: string; + themeColors: ThemeColors; +}): ParsedValueToken | undefined { + // Opacity modifier: text-foreground/70, text-red-500/50. RN colors accept 8-digit hex, so the + // alpha is folded into the resolved color rather than dropped with the whole class. + const slash = token.lastIndexOf('/'); + if (slash > 0) { + const base = token.slice(0, slash); + const opacity = Number(token.slice(slash + 1)); + if (Number.isFinite(opacity) && opacity >= 0 && opacity <= 100) { + const resolved = resolveColorToken({ token: base, themeColors }); + if (resolved) + return { kind: 'color', value: withAlpha({ color: resolved, alpha: opacity / 100 }) }; + } + return undefined; + } + + if (token.startsWith('text-')) { + const value = token.slice('text-'.length); + const px = value.match(ARBITRARY_PX); + // text-[...] is ambiguous: a length is a font size, anything else (#fff, rgb(...)) is a color. + if (px?.[1]) return { kind: 'fontSize', value: Number(px[1]) }; + if (value.startsWith('[') && value.endsWith(']')) { + return { kind: 'color', value: value.slice(1, -1) }; + } + const palette = resolveTailwindPaletteColor(token); + if (palette) return { kind: 'color', value: palette }; + return undefined; + } + + if (token.startsWith('leading-')) { + const value = token.slice('leading-'.length); + const px = value.match(ARBITRARY_PX); + if (px?.[1]) return { kind: 'lineHeight', value: Number(px[1]) }; + const step = token.match(LEADING_STEP); + if (step?.[1]) return { kind: 'lineHeight', value: Number(step[1]) * 0.25 * REM_PX }; + return undefined; + } + + if (token.startsWith('tracking-')) { + const px = token.slice('tracking-'.length).match(ARBITRARY_PX); + if (px?.[1]) return { kind: 'letterSpacing', value: Number(px[1]) }; + return undefined; + } + + return undefined; +} + +function resolveColorToken({ + token, + themeColors, +}: { + token: string; + themeColors: ThemeColors; +}): string | undefined { + const themeKey = THEME_COLOR_KEY[token]; + if (themeKey) return themeColors[themeKey]; + if (token in FIXED_COLOR) return FIXED_COLOR[token]; + return resolveTailwindPaletteColor(token); +} + +const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** Folds an alpha into a #rgb/#rrggbb color as 8-digit hex; passes anything else through. */ +function withAlpha({ color, alpha }: { color: string; alpha: number }): string { + const hex = color.match(HEX_COLOR); + if (!hex?.[1]) return color; + const full = + hex[1].length === 3 + ? hex[1] + .split('') + .map((c) => c + c) + .join('') + : hex[1]; + const alphaHex = Math.round(alpha * 255) + .toString(16) + .padStart(2, '0'); + return `#${full}${alphaHex}`; +} + +type HostMatchContents = boolean | { vertical?: boolean; horizontal?: boolean }; + +/** + * For components with no textStyle escape hatch (e.g. Button) — matches both axes to content + * when there's no explicit sizing class. A button should size to its label/icon by default, + * not stretch to fill an arbitrary parent width. + */ +function shouldMatchContents(className: string | undefined): boolean { + if (!className) return true; + return !hasExplicitSizing(className.split(WHITESPACE).filter(Boolean)); +} + +/** + * For Text. Two conflicting default needs, disambiguated by the caller's explicit `wrap` flag: + * - `wrap: false` (default) — matches both axes to content, so short labels/badges/headings + * shrink-wrap to their own text width, same as the old NativeWindUI Text's default behavior. + * - `wrap: true` — matches only the vertical axis, so the Host box keeps the parent's width + * constraint and paragraph/note text wraps at that width instead of overflowing unwrapped. + * An explicit sizing class (flex-1, w-*, ...) always wins over either default. + * + * `alignsText` (text-center/text-left/text-right) behaves like `wrap` for sizing purposes: + * aligning text inside a box that has been shrink-wrapped to that same text is a no-op, so a + * centered heading rendered flush-left. Such a Text needs the parent's width to align within. + */ +function textMatchContents({ + hostClassName, + wrap, + alignsText, +}: { + hostClassName: string | undefined; + wrap: boolean; + alignsText: boolean; +}): HostMatchContents { + const hasSizing = hostClassName + ? hasExplicitSizing(hostClassName.split(WHITESPACE).filter(Boolean)) + : false; + // An explicit sizing class always wins — Yoga sizes the box, nothing matches to content. + if (hasSizing) return false; + return wrap || alignsText ? { vertical: true } : true; +} + +/** + * Splits a NativeWind className string into text-only styling (font weight/size/color/align — + * applied to the @expo/ui Host's textStyle, since Host's className interop only reaches the + * box, never the native-bridged text inside) and everything else (kept as className on Host). + */ +function splitTextClassName({ + className, + themeColors, + wrap, + baseFontSize, +}: { + className: string | undefined; + themeColors: ThemeColors; + wrap: boolean; + /** + * The font size in effect before classes are applied (i.e. the variant's). The `tracking-` + * and `leading-` scales are relative units, so they need this to resolve; an explicit + * `text-` class in the same className wins over it. + */ + baseFontSize: number; +}): { + textStyle: ParsedTextStyle; + hostClassName: string | undefined; + matchContents: HostMatchContents; + /** wrap:true with no explicit sizing class needs a fallback width — Host has nothing to wrap + * text against otherwise, since the parent may not stretch it (e.g. `items-center`). */ + needsExplicitWidth: boolean; +} { + if (!className) { + return { + textStyle: {}, + hostClassName: undefined, + matchContents: textMatchContents({ hostClassName: undefined, wrap, alignsText: false }), + needsExplicitWidth: wrap, + }; + } + + const textStyle: ParsedTextStyle = {}; + const hostTokens: string[] = []; + // tracking-*/leading-* are relative to the font size, which may be set by a later token — + // resolve them after the whole class list has been walked. + let pendingLetterSpacingEm: number | undefined; + let pendingLineHeightRatio: number | undefined; + + for (const rawToken of className.split(WHITESPACE).filter(Boolean)) { + // NativeWind variant prefixes (dark:, ios:, android:, web:, active:, ...). Non-platform + // variants are still applied unconditionally: NativeWind resolves them for real, and + // over-reporting a `dark:` colour only costs us a default we did not need. A *platform* + // variant for another platform is different — see isForeignPlatformToken. + if (isForeignPlatformToken(rawToken)) continue; + const token = stripVariantPrefix(rawToken); + if (token in FONT_WEIGHT) { + textStyle.fontWeight = FONT_WEIGHT[token]; + } else if (token in FONT_SIZE) { + textStyle.fontSize = FONT_SIZE[token]; + } else if (token in TEXT_ALIGN) { + textStyle.textAlign = TEXT_ALIGN[token]; + } else if (token in THEME_COLOR_KEY) { + const themeKey = THEME_COLOR_KEY[token]; + if (themeKey) textStyle.color = themeColors[themeKey]; + } else if (token in FIXED_COLOR) { + textStyle.color = FIXED_COLOR[token]; + } else if (token in LETTER_SPACING_EM) { + pendingLetterSpacingEm = LETTER_SPACING_EM[token]; + } else if (token in LINE_HEIGHT_RATIO) { + pendingLineHeightRatio = LINE_HEIGHT_RATIO[token]; + } else { + const parsed = parseValueToken({ token, themeColors }); + if (parsed === undefined) { + hostTokens.push(rawToken); + } else if (parsed.kind === 'color') { + textStyle.color = parsed.value; + } else if (parsed.kind === 'fontSize') { + textStyle.fontSize = parsed.value; + } else if (parsed.kind === 'lineHeight') { + textStyle.lineHeight = parsed.value; + } else { + textStyle.letterSpacing = parsed.value; + } + } + } + + const resolvedFontSize = textStyle.fontSize ?? baseFontSize; + if (pendingLetterSpacingEm !== undefined) { + textStyle.letterSpacing = pendingLetterSpacingEm * resolvedFontSize; + } + if (pendingLineHeightRatio !== undefined) { + textStyle.lineHeight = pendingLineHeightRatio * resolvedFontSize; + } + + const hostClassName = hostTokens.length > 0 ? hostTokens.join(' ') : undefined; + const alignsText = textStyle.textAlign !== undefined; + return { + textStyle, + hostClassName, + matchContents: textMatchContents({ hostClassName, wrap, alignsText }), + // Both wrap and text-align need a definite width: matchContents:{vertical:true} only stops + // the Host shrink-wrapping, it doesn't hand SwiftUI/Compose a width to wrap or align against + // (a parent using items-center never stretches it). + needsExplicitWidth: (wrap || alignsText) && !hasExplicitSizing(hostTokens), + }; +} + +export { shouldMatchContents, splitTextClassName }; +export type { HostMatchContents, ParsedTextStyle }; diff --git a/packages/ui/src/list.tsx b/packages/ui/src/list.tsx new file mode 100644 index 0000000000..a8fdabb303 --- /dev/null +++ b/packages/ui/src/list.tsx @@ -0,0 +1,386 @@ +import { isString } from '@packrat/guards'; +import { + FlashList, + type FlashListProps, + type FlashListRef, + type ListRenderItem as FlashListRenderItem, + type ListRenderItemInfo, +} from '@shopify/flash-list'; +import { cva } from 'class-variance-authority'; +import { cn } from 'expo-app/lib/cn'; +import { cssInterop } from 'nativewind'; +import type * as React from 'react'; +import { + Platform, + Pressable, + type PressableProps, + type StyleProp, + StyleSheet, + type TextStyle, + View, + type ViewProps, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Text } from './text'; + +// Plain RN composition — List/ListItem never needed a native Host bridge (FlashList + View/ +// Pressable + Text), so it's ported directly rather than routed through @expo/ui. ListItem +// specifically uses Pressable (not the migrated Button) because it renders multiple Text +// children (title + optional subtitle) — nesting a Host-bridged Button around Host-bridged +// Text children reproduces the Button-collapse bug fixed in an earlier commit. + +cssInterop(FlashList, { + className: 'style', + contentContainerClassName: 'contentContainerStyle', +}); + +type ListDataItem = string | { title: string; subTitle?: string }; +type ListVariant = 'insets' | 'full-width'; + +type ListRef = React.Ref>; + +type ListRenderItemProps = ListRenderItemInfo & { + variant?: ListVariant; + isFirstInSection?: boolean; + isLastInSection?: boolean; + sectionHeaderAsGap?: boolean; +}; + +type ListProps = Omit, 'renderItem'> & { + ref?: ListRef; + renderItem?: ListRenderItem; + variant?: ListVariant; + sectionHeaderAsGap?: boolean; +}; +type ListRenderItem = ( + props: ListRenderItemProps, +) => ReturnType>; + +function List({ + variant = 'full-width', + contentContainerClassName, + contentContainerStyle, + renderItem, + data, + sectionHeaderAsGap = false, + contentInsetAdjustmentBehavior = 'automatic', + ...props +}: ListProps) { + const insets = useSafeAreaInsets(); + return ( + + ); +} + +function getItemType(item: T) { + return isString(item) ? 'sectionHeader' : 'row'; +} + +function renderItemWithVariant({ + renderItem, + variant, + data, + sectionHeaderAsGap, +}: { + renderItem: ListRenderItem | null | undefined; + variant: ListVariant; + data: readonly T[] | null | undefined; + sectionHeaderAsGap?: boolean; +}) { + return (args: ListRenderItemProps) => { + const previousItem = data?.[args.index - 1]; + const nextItem = data?.[args.index + 1]; + return renderItem + ? renderItem({ + ...args, + variant, + isFirstInSection: !previousItem || isString(previousItem), + isLastInSection: !nextItem || isString(nextItem), + sectionHeaderAsGap, + }) + : null; + }; +} + +function isPressable(props: PressableProps) { + return ( + ('onPress' in props && props.onPress) || + ('onLongPress' in props && props.onLongPress) || + ('onPressIn' in props && props.onPressIn) || + ('onPressOut' in props && props.onPressOut) || + ('onLongPress' in props && props.onLongPress) + ); +} + +type ListItemProps = PressableProps & + ListRenderItemProps & { + androidRootClassName?: string; + titleClassName?: string; + // Text is a plain RN Text again, so these reach the text itself and are TextStyle — matching + // the original NativeWindUI API. (They were narrowed to ViewStyle while Text rendered through + // an @expo/ui Host, where `style` only sized the bridging box.) + titleStyle?: StyleProp; + textNumberOfLines?: number; + subTitleClassName?: string; + subTitleStyle?: StyleProp; + subTitleNumberOfLines?: number; + textContentClassName?: string; + leftView?: React.ReactNode; + rightView?: React.ReactNode; + removeSeparator?: boolean; + bottomView?: React.ReactNode; + }; + +const itemVariants = cva('ios:gap-0 flex-row gap-0 bg-card', { + variants: { + variant: { + insets: 'ios:bg-card bg-card/70', + 'full-width': 'bg-card dark:bg-background', + }, + sectionHeaderAsGap: { true: '', false: '' }, + isFirstItem: { true: '', false: '' }, + isFirstInSection: { true: '', false: '' }, + removeSeparator: { true: '', false: '' }, + isLastInSection: { true: '', false: '' }, + disabled: { true: 'opacity-70', false: 'opacity-100' }, + }, + compoundVariants: [ + { variant: 'insets', sectionHeaderAsGap: true, className: 'ios:dark:bg-card dark:bg-card/70' }, + { variant: 'insets', isFirstInSection: true, className: 'ios:rounded-t-[10px]' }, + { variant: 'insets', isLastInSection: true, className: 'ios:rounded-b-[10px]' }, + { + removeSeparator: false, + isLastInSection: true, + className: 'ios:border-b-0 border-b border-border/25 dark:border-border/80', + }, + { variant: 'insets', isFirstItem: true, className: 'border-border/40 border-t' }, + ], + defaultVariants: { + variant: 'insets', + sectionHeaderAsGap: false, + isFirstInSection: false, + isLastInSection: false, + disabled: false, + }, +}); + +function ListItem({ + item, + isFirstInSection, + isLastInSection, + variant, + className, + titleClassName, + titleStyle, + textNumberOfLines, + subTitleStyle, + subTitleClassName, + subTitleNumberOfLines, + textContentClassName, + sectionHeaderAsGap, + removeSeparator = false, + leftView, + rightView, + bottomView, + disabled, + ...props +}: ListItemProps) { + if (isString(item)) { + console.log( + 'list.tsx', + 'ListItem', + "Invalid item of type 'string' was provided. Use ListSectionHeader instead.", + ); + return null; + } + return ( + <> + ...}`: NativeWind owns the + // `style` prop on a `className`'d component and drops the function form Pressable needs, + // so the press feedback silently never rendered. Same fix as @packrat/ui's Button. + className={cn( + 'active:opacity-70', + itemVariants({ + variant, + sectionHeaderAsGap, + isFirstInSection, + isLastInSection, + disabled, + removeSeparator, + }), + className, + )} + {...props} + > + {/* justify-center (column axis) keeps the icon/avatar centered against the text block. + The row is `flex-row` with no `items-center`, so these wrappers stretch to the full + row height and their content would otherwise sit pinned to the top — visible as soon + as the text column is more than one line tall. */} + {!!leftView && {leftView}} + + + + {item.title} + + {!!item.subTitle && ( + 1} + > + {item.subTitle} + + )} + {!!bottomView && bottomView} + + {!!rightView && {rightView}} + + + {!removeSeparator && Platform.OS !== 'ios' && !isLastInSection && ( + + + + )} + + ); +} + +type ListSectionHeaderProps = ViewProps & + ListRenderItemProps & { + textClassName?: string; + ref?: React.Ref; + }; + +function ListSectionHeader({ + ref, + item, + variant, + className, + textClassName, + sectionHeaderAsGap, + ...props +}: ListSectionHeaderProps) { + if (!isString(item)) { + console.log( + 'list.tsx', + 'ListSectionHeader', + "Invalid item provided. Expected type 'string'. Use ListItem instead.", + ); + return null; + } + + if (sectionHeaderAsGap) { + return ( + + + + ); + } + return ( + + + {item} + + + ); +} + +function getStickyHeaderIndices(data: T[]) { + if (!data) return []; + const indices: number[] = []; + for (let i = 0; i < data.length; i++) { + if (isString(data[i])) { + indices.push(i); + } + } + return indices; +} + +export { getStickyHeaderIndices, List, ListItem, ListSectionHeader }; +export type { + ListDataItem, + ListItemProps, + ListProps, + ListRef, + ListRenderItemInfo, + ListSectionHeaderProps, +}; diff --git a/packages/ui/src/loading-indicator.android.tsx b/packages/ui/src/loading-indicator.android.tsx new file mode 100644 index 0000000000..4b18c24c64 --- /dev/null +++ b/packages/ui/src/loading-indicator.android.tsx @@ -0,0 +1,48 @@ +import { Host as JCHost, LoadingIndicator as JCLoadingIndicator } from '@expo/ui/jetpack-compose'; +import { isString } from '@packrat/guards'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; + +cssInterop(JCHost, { className: 'style' }); + +// jetpack-compose's Host prop type doesn't extend RN's ViewProps (unlike the universal Host), +// so NativeWind's cssInterop className→style augmentation — applied globally to ViewProps — +// never reaches it. cssInterop(Host, { className: 'style' }) above genuinely makes className +// work at runtime; this widened type just tells TS the truth. +type HostProps = ComponentProps & { className?: string }; +const Host = JCHost as (props: HostProps) => ReturnType; + +type ActivityIndicatorSize = 'small' | 'large' | number; + +// Material 3 LoadingIndicator has no size prop/modifier — approximate the old +// react-native ActivityIndicator's size classes via the Host box dimensions instead. +const SIZE_PX: Record<'small' | 'large', number> = { + small: 20, + large: 36, +}; + +function resolveSizePx(size: ActivityIndicatorSize): number { + return isString(size) ? SIZE_PX[size] : size; +} + +type ActivityIndicatorProps = { + size?: ActivityIndicatorSize; + color?: string; + className?: string; + style?: StyleProp; +}; + +function ActivityIndicator({ size = 'small', color, className, style }: ActivityIndicatorProps) { + const { colors } = useColorScheme(); + const px = resolveSizePx(size); + return ( + + + + ); +} + +export { ActivityIndicator }; +export type { ActivityIndicatorProps, ActivityIndicatorSize }; diff --git a/packages/ui/src/loading-indicator.ios.tsx b/packages/ui/src/loading-indicator.ios.tsx new file mode 100644 index 0000000000..d4cc25b448 --- /dev/null +++ b/packages/ui/src/loading-indicator.ios.tsx @@ -0,0 +1,52 @@ +import { ProgressView, Host as SwiftUIHost } from '@expo/ui/swift-ui'; +import { controlSize, tint } from '@expo/ui/swift-ui/modifiers'; +import { isString } from '@packrat/guards'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; + +cssInterop(SwiftUIHost, { className: 'style' }); + +// swift-ui's Host prop type doesn't extend RN's ViewProps (unlike the universal Host), so +// NativeWind's cssInterop className→style augmentation — which is applied globally to +// ViewProps — never reaches it. cssInterop(Host, { className: 'style' }) above genuinely +// makes className work at runtime; this widened type just tells TS the truth. +type HostProps = ComponentProps & { className?: string }; +const Host = SwiftUIHost as (props: HostProps) => ReturnType; + +type ActivityIndicatorSize = 'small' | 'large' | number; + +const SIZE_TO_CONTROL_SIZE: Record<'small' | 'large', 'small' | 'large'> = { + small: 'small', + large: 'large', +}; + +type ActivityIndicatorProps = { + size?: ActivityIndicatorSize; + color?: string; + className?: string; + style?: StyleProp; +}; + +function resolveControlSize( + size: ActivityIndicatorSize, +): 'mini' | 'small' | 'regular' | 'large' | 'extraLarge' { + if (isString(size)) return SIZE_TO_CONTROL_SIZE[size]; + if (size <= 16) return 'mini'; + if (size <= 20) return 'small'; + return 'regular'; +} + +function ActivityIndicator({ size = 'small', color, className, style }: ActivityIndicatorProps) { + const { colors } = useColorScheme(); + const resolvedControlSize = resolveControlSize(size); + return ( + + + + ); +} + +export { ActivityIndicator }; +export type { ActivityIndicatorProps, ActivityIndicatorSize }; diff --git a/packages/ui/src/loading-indicator.tsx b/packages/ui/src/loading-indicator.tsx new file mode 100644 index 0000000000..491535893f --- /dev/null +++ b/packages/ui/src/loading-indicator.tsx @@ -0,0 +1,6 @@ +// Platform dispatch only — Metro resolves loading-indicator.ios.tsx / .android.tsx at bundle +// time. This file exists purely so TypeScript (which has no platform-suffix resolution) has a +// base module to resolve `@packrat/ui/src/loading-indicator` against. + +export type { ActivityIndicatorProps, ActivityIndicatorSize } from './loading-indicator.ios'; +export { ActivityIndicator } from './loading-indicator.ios'; diff --git a/packages/ui/src/search-input-types.ts b/packages/ui/src/search-input-types.ts new file mode 100644 index 0000000000..ba2f591b84 --- /dev/null +++ b/packages/ui/src/search-input-types.ts @@ -0,0 +1,17 @@ +import type { TextInput, TextInputProps } from 'react-native'; + +type SearchInputRef = React.Ref; + +type SearchInputProps = TextInputProps & { + ref?: SearchInputRef; + containerClassName?: string; + iconContainerClassName?: string; + cancelText?: string; + iconColor?: string; + /** testID applied to the outer container (the accessibility leaf). Use for Maestro/XCTest targeting instead of testID, which only reaches the inner TextInput. */ + containerTestID?: string; + /** accessibilityLabel applied to the outer container. */ + containerAccessibilityLabel?: string; +}; + +export type { SearchInputProps, SearchInputRef }; diff --git a/packages/ui/src/search-input.ios.tsx b/packages/ui/src/search-input.ios.tsx new file mode 100644 index 0000000000..38883b11fc --- /dev/null +++ b/packages/ui/src/search-input.ios.tsx @@ -0,0 +1,151 @@ +import { useAugmentedRef, useControllableState } from '@rn-primitives/hooks'; +import { Icon } from 'expo-app/components/Icon'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import * as React from 'react'; +import { type FocusEvent, Pressable, TextInput, View, type ViewStyle } from 'react-native'; +import Animated, { + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import type { SearchInputProps } from './search-input-types'; +import { Text } from './text'; + +// Plain RN composition — SearchInput never needed a Host bridge on iOS either. This is the +// animated cancel-button design (search-input.tsx is the Android/default variant). + +// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522 +const BORDER_CURVE: ViewStyle = { borderCurve: 'continuous' }; + +function SearchInput({ + ref, + value: valueProp, + onChangeText: onChangeTextProp, + onFocus: onFocusProp, + placeholder = 'Search...', + cancelText = 'Cancel', + containerClassName, + iconContainerClassName, + containerTestID, + containerAccessibilityLabel, + className, + iconColor, + ...props +}: SearchInputProps) { + const { colors } = useColorScheme(); + const inputRef = useAugmentedRef({ ref: ref ?? null, methods: { focus, blur, clear } }); + const [showCancel, setShowCancel] = React.useState(false); + const showCancelDerivedValue = useDerivedValue(() => showCancel, [showCancel]); + const animatedRef = useAnimatedRef(); + + const [value = '', onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: valueProp ?? '', + onChange: onChangeTextProp, + }); + + // Estimated width only — never the live-measured Cancel Pressable width. @expo/ui's + // Host-wrapped Text reports a narrower intrinsic size to Yoga than a plain RN Text did (the + // old nativewindui version of this file used `measure(animatedRef)` here), which under-reserved + // paddingRight and closed the gap between the search box and the Cancel button. + const rootStyle = useAnimatedStyle(() => { + return { + paddingRight: showCancelDerivedValue.value + ? withTiming(cancelText.length * 10) + : withTiming(0), + }; + }); + // Same estimated-width-only rule as rootStyle above — kept in sync with it so the button + // slides to exactly where paddingRight reserved space for it. + const cancelButtonStyle = useAnimatedStyle(() => { + return { + position: 'absolute', + right: 0, + opacity: showCancelDerivedValue.value ? withTiming(1) : withTiming(0), + transform: [ + { + translateX: showCancelDerivedValue.value + ? withTiming(0) + : withTiming(cancelText.length * 10), + }, + ], + }; + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onChangeText(''); + } + + function onFocus(e: FocusEvent) { + setShowCancel(true); + onFocusProp?.(e); + } + + return ( + + + + + + + + + { + onChangeText(''); + inputRef.current?.blur(); + setShowCancel(false); + }} + disabled={!showCancel} + pointerEvents={!showCancel ? 'none' : 'auto'} + className="flex-1 justify-center active:opacity-50" + > + {cancelText} + + + + ); +} + +export { SearchInput }; diff --git a/packages/ui/src/search-input.tsx b/packages/ui/src/search-input.tsx new file mode 100644 index 0000000000..a75e9ebd4b --- /dev/null +++ b/packages/ui/src/search-input.tsx @@ -0,0 +1,85 @@ +import { useAugmentedRef, useControllableState } from '@rn-primitives/hooks'; +import { Icon } from 'expo-app/components/Icon'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { Pressable, TextInput, View } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { Button } from './button'; +import type { SearchInputProps } from './search-input-types'; + +// Plain RN composition — SearchInput never needed a Host bridge, ported directly. This is the +// Android/default design (search-input.ios.tsx has iOS's animated cancel-button variant). + +function SearchInput({ + ref, + value: valueProp, + onChangeText: onChangeTextProp, + placeholder = 'Search...', + cancelText: _cancelText = 'Cancel', + containerClassName, + iconContainerClassName, + containerTestID, + containerAccessibilityLabel, + className, + iconColor, + ...props +}: SearchInputProps) { + const { colors } = useColorScheme(); + const inputRef = useAugmentedRef({ ref: ref ?? null, methods: { focus, blur, clear } }); + const [value = '', onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: valueProp ?? '', + onChange: onChangeTextProp, + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onChangeText(''); + } + + return ( + + ); +} + +export { SearchInput }; diff --git a/packages/ui/src/segmented-control.tsx b/packages/ui/src/segmented-control.tsx new file mode 100644 index 0000000000..3ae9d10f36 --- /dev/null +++ b/packages/ui/src/segmented-control.tsx @@ -0,0 +1,42 @@ +import { SegmentedControl as ExpoSegmentedControl } from '@expo/ui/community/segmented-control'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; + +type SegmentedControlProps = { + values: string[]; + selectedIndex?: number; + enabled?: boolean; + onIndexChange?: (index: number) => void; + onValueChange?: (value: string) => void; + tintColor?: string; + testID?: string; +}; + +function SegmentedControl({ + values, + selectedIndex, + enabled, + onIndexChange, + onValueChange, + tintColor, + testID, +}: SegmentedControlProps) { + const { colors } = useColorScheme(); + // This is one of the two components still backed by @expo/ui, so it paints from the *platform* + // palette rather than the app's. Left untinted on Android it picks up Material You's dynamic + // colour, which on a device themed brown rendered a brown selected segment in an app whose + // accent is blue everywhere else. Defaulting to the theme's primary keeps it on-brand; call + // sites can still override. + return ( + onIndexChange?.(event.nativeEvent.selectedSegmentIndex)} + onValueChange={onValueChange} + /> + ); +} + +export { SegmentedControl }; diff --git a/packages/ui/src/selectable-text.tsx b/packages/ui/src/selectable-text.tsx new file mode 100644 index 0000000000..d81ba730b2 --- /dev/null +++ b/packages/ui/src/selectable-text.tsx @@ -0,0 +1,22 @@ +import { cn } from 'expo-app/lib/cn'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import { UITextView } from 'react-native-uitextview'; + +// selectable/uiTextView text-selection has no @expo/ui equivalent on any platform (Universal, +// SwiftUI, and Jetpack Compose Text all lack a selection-mode prop) — this wraps +// react-native-uitextview directly, the same underlying native module the old nativewindui +// package's Text used for this one feature. Kept minimal: real call sites pass no variant/color, +// just plain selectable text, so this doesn't replicate Text's full variant system. + +cssInterop(UITextView, { className: 'style' }); + +type SelectableTextProps = ComponentProps; + +function SelectableText({ className, ...props }: SelectableTextProps) { + return ( + + ); +} + +export { SelectableText }; diff --git a/packages/ui/src/text-field.ios.tsx b/packages/ui/src/text-field.ios.tsx new file mode 100644 index 0000000000..bbf20cd21d --- /dev/null +++ b/packages/ui/src/text-field.ios.tsx @@ -0,0 +1,106 @@ +import { useAugmentedRef, useControllableState } from '@rn-primitives/hooks'; +import { cn } from 'expo-app/lib/cn'; +import type * as React from 'react'; +import { Pressable, TextInput, type TextInputProps, View } from 'react-native'; +import { Text } from './text'; + +// Plain RN composition — TextField never needed a native Host bridge. iOS keeps the old +// package's simple (non-Material, no floating-label animation) design — matches +// text-field.tsx's prop surface so callers see one API regardless of platform. + +type TextFieldRef = React.Ref; + +type TextFieldProps = TextInputProps & { + ref?: TextFieldRef; + children?: React.ReactNode; + leftView?: React.ReactNode; + rightView?: React.ReactNode; + label?: string; + labelClassName?: string; + containerClassName?: string; + containerTestID?: string; + containerAccessibilityLabel?: string; + errorMessage?: string; + // Accepted for prop-surface parity with text-field.tsx (Android) — no effect on iOS, the + // old package's iOS TextField never had a Material variant either. + materialVariant?: 'outlined' | 'filled'; + materialRingColor?: string; + materialHideActionIcons?: boolean; +}; + +function TextField({ + ref, + value: valueProp, + onChangeText: onChangeTextProp, + defaultValue: defaultValueProp, + editable, + className, + leftView, + rightView, + label, + labelClassName, + containerClassName, + containerTestID, + containerAccessibilityLabel, + accessibilityHint, + errorMessage, + ...props +}: TextFieldProps) { + const inputRef = useAugmentedRef({ ref: ref ?? null, methods: { focus, blur, clear } }); + + const [value = '', onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: defaultValueProp ?? valueProp ?? '', + onChange: onChangeTextProp, + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onChangeText(''); + } + + return ( + + {!!label && ( + + {leftView} + + {label} + + + )} + + {!!leftView && !label && leftView} + + {rightView} + + + ); +} + +export { TextField }; +export type { TextFieldProps, TextFieldRef }; diff --git a/packages/ui/src/text-field.tsx b/packages/ui/src/text-field.tsx new file mode 100644 index 0000000000..9a6465cdef --- /dev/null +++ b/packages/ui/src/text-field.tsx @@ -0,0 +1,324 @@ +import { useAugmentedRef, useControllableState } from '@rn-primitives/hooks'; +import { cva } from 'class-variance-authority'; +import { Icon } from 'expo-app/components/Icon'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import * as React from 'react'; +import { + type BlurEvent, + type FocusEvent, + Pressable, + TextInput, + type TextInputProps, + View, + type ViewProps, + type ViewStyle, +} from 'react-native'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; + +// Plain RN composition — TextField never needed a native Host bridge (RN TextInput + +// Pressable/View/Reanimated), so it's ported directly rather than routed through @expo/ui. +// This is the base/default file (Android + web) — Metro picks text-field.ios.tsx on iOS, +// matching the old package's platform split exactly (no visual change on either platform). +// This file's Material-style floating label (materialVariant/materialRingColor/ +// materialHideActionIcons) only ever applied here, never on iOS. + +type TextFieldRef = React.Ref; + +type TextFieldProps = TextInputProps & { + ref?: TextFieldRef; + children?: React.ReactNode; + leftView?: React.ReactNode; + rightView?: React.ReactNode; + label?: string; + labelClassName?: string; + containerClassName?: string; + containerTestID?: string; + containerAccessibilityLabel?: string; + errorMessage?: string; + materialVariant?: 'outlined' | 'filled'; + materialRingColor?: string; + materialHideActionIcons?: boolean; +}; + +function TextField({ + ref, + value: valueProp, + defaultValue: defaultValueProp, + onChangeText: onChangeTextProp, + onFocus: onFocusProp, + onBlur: onBlurProp, + placeholder, + editable, + className, + leftView, + rightView, + label, + labelClassName, + containerClassName, + containerTestID, + containerAccessibilityLabel, + accessibilityHint, + errorMessage, + materialVariant = 'outlined', + materialRingColor, + materialHideActionIcons, + ...props +}: TextFieldProps) { + const inputRef = useAugmentedRef({ ref: ref ?? null, methods: { focus, blur, clear } }); + const [isFocused, setIsFocused] = React.useState(false); + + const [value = '', onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: defaultValueProp ?? valueProp ?? '', + onChange: onChangeTextProp, + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onChangeText(''); + } + + function onFocus(e: FocusEvent) { + setIsFocused(true); + onFocusProp?.(e); + } + + function onBlur(e: BlurEvent) { + setIsFocused(false); + onBlurProp?.(e); + } + + return ( + + + {leftView} + + {!!label && ( + + )} + + + {!materialHideActionIcons && ( + <> + {errorMessage ? ( + + ) : ( + !!value && isFocused && + )} + + )} + {rightView} + + + ); +} + +type InputState = 'idle' | 'focused' | 'error' | 'disabled'; + +function getInputState(args: { + isFocused: boolean; + hasError?: boolean; + editable?: boolean; +}): InputState { + if (args.editable === false) return 'disabled'; + if (args.hasError) return 'error'; + if (args.isFocused) return 'focused'; + return 'idle'; +} + +const rootVariants = cva('relative rounded-[5px]', { + variants: { + variant: { outlined: 'border', filled: 'border-b rounded-b-none' }, + state: { + idle: 'border-transparent', + error: 'border-destructive', + focused: 'border-primary', + disabled: 'opacity-50', + }, + }, + defaultVariants: { variant: 'outlined', state: 'idle' }, +}); + +const innerRootVariants = cva('flex-row rounded', { + variants: { + variant: { outlined: 'border border-border', filled: 'border-b bg-border rounded-b-none' }, + state: { + idle: 'border-foreground/30', + error: 'border-destructive', + focused: 'border-primary', + disabled: 'border-foreground/30', + }, + }, + defaultVariants: { variant: 'outlined', state: 'idle' }, +}); + +function FilledWrapper(props: ViewProps) { + return ; +} + +type MaterialLabelProps = { + isFocused: boolean; + value: string; + materialLabel: string; + hasLeftView: boolean; + hasError?: boolean; + className?: string; + materialVariant: 'outlined' | 'filled'; +}; + +const DEFAULT_TEXT_FIELD_HEIGHT = 56; + +function MaterialLabel(props: MaterialLabelProps) { + const { colors } = useColorScheme(); + const isLifted = props.isFocused || !!props.value; + const isLiftedDerived = useDerivedValue(() => isLifted); + const hasLeftViewDerived = useDerivedValue(() => props.hasLeftView); + const variantDerived = useDerivedValue(() => props.materialVariant); + const animatedRootStyle = useAnimatedStyle(() => { + const style: ViewStyle = { position: 'absolute', alignSelf: 'center' }; + if (variantDerived.value === 'outlined') { + style.paddingLeft = withTiming(hasLeftViewDerived.value && isLiftedDerived.value ? 0 : 12, { + duration: 200, + }); + style.transform = [ + { + translateY: withTiming(isLiftedDerived.value ? -DEFAULT_TEXT_FIELD_HEIGHT / 2.2 : 0, { + duration: 200, + }), + }, + { + translateX: withTiming(hasLeftViewDerived.value && isLiftedDerived.value ? -12 : 0, { + duration: 200, + }), + }, + ]; + } + if (variantDerived.value === 'filled') { + style.paddingLeft = 8; + style.transform = [ + { + translateY: withTiming(isLiftedDerived.value ? -DEFAULT_TEXT_FIELD_HEIGHT / 3.75 : 0, { + duration: 200, + }), + }, + { translateX: 0 }, + ]; + } + return style; + }); + const animatedTextStyle = useAnimatedStyle(() => { + return { + fontSize: withTiming(isLiftedDerived.value ? 12 : 17, { duration: 200 }), + backgroundColor: + variantDerived.value === 'outlined' + ? withTiming(colors.background, { duration: 200 }) + : // Old theme had a dedicated `border` color; this app's theme doesn't — `card` is the + // closest surface color used for filled-variant containers elsewhere. + colors.card, + }; + }); + return ( + + + {props.materialLabel} + + + ); +} + +function MaterialClearIcon(props: { editable?: boolean; clearText: () => void }) { + const { colors } = useColorScheme(); + return ( + + + + + + ); +} + +function MaterialErrorIcon() { + return ( + + + + ); +} + +export { TextField }; +export type { TextFieldProps, TextFieldRef }; diff --git a/packages/ui/src/text.tsx b/packages/ui/src/text.tsx new file mode 100644 index 0000000000..8c3f3005ad --- /dev/null +++ b/packages/ui/src/text.tsx @@ -0,0 +1,139 @@ +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { + Text as RNText, + type TextProps as RNTextProps, + type StyleProp, + type TextStyle, +} from 'react-native'; +import { splitTextClassName } from './lib/text-class-parser'; + +type TextVariant = + | 'largeTitle' + | 'title1' + | 'title2' + | 'title3' + | 'heading' + | 'body' + | 'callout' + | 'subhead' + | 'footnote' + | 'caption1' + | 'caption2'; + +type TextColor = 'primary' | 'secondary' | 'tertiary' | 'quarternary'; + +const VARIANT_FONT_SIZE: Record = { + largeTitle: 34, + title1: 24, + title2: 22, + title3: 20, + heading: 17, + body: 17, + callout: 16, + subhead: 15, + footnote: 13, + caption1: 12, + caption2: 11, +}; + +const VARIANT_LINE_HEIGHT: Partial> = { + title2: 28, + heading: 24, + body: 24, + subhead: 24, + footnote: 20, + caption2: 16, +}; + +const VARIANT_WEIGHT: Partial> = { + heading: 'bold', +}; + +const COLOR_KEY: Record = { + primary: 'foreground', + secondary: 'grey', + tertiary: 'grey2', + quarternary: 'grey3', +}; + +type TextProps = { + children?: React.ReactNode; + variant?: TextVariant; + color?: TextColor; + /** Overrides the resolved theme/variant color (e.g. a fixed brand/status hex). */ + textColor?: string; + /** Escape hatch for arbitrary text styling not covered by variant/color/className. */ + textStyle?: StyleProp; + /** + * @deprecated No longer does anything and can be deleted from call sites. + * + * This existed because the old `@expo/ui`-backed implementation rendered through `Host`, a + * native bridge with no React Native-side intrinsic size: it could either shrink-wrap to its + * text (overflowing a narrow parent) or stretch to its parent, never `min(content, parent)` + * like real text. `wrap` picked between those. A plain RN `Text` just does the right thing, so + * the flag is inert — kept only so the ~40 call sites that pass it still compile. + */ + wrap?: boolean; + className?: string; + style?: StyleProp; +} & Omit; + +/** + * A plain React Native `Text`, deliberately NOT `@expo/ui`'s Text. + * + * The `@expo/ui` version rendered through `Host` (a bridge to a native SwiftUI/Compose surface), + * which caused a long tail of layout bugs on-device because `Host` has no RN-side intrinsic size: + * centered headings rendered flush-left, `numberOfLines={2}` could never wrap, and prose + * overflowed its container unless the call site remembered an explicit `wrap`. It also could not + * render nested children (`children` was typed `string`), so inline links and styled segments — + * the consent screen's Terms/Privacy links, the OTP screen's email — were silently deleted. + * + * Plain RN `Text` fixes all of that structurally: Yoga sizes it as `min(content, parent)`, nested + * `` composes natively, and NativeWind applies `className` directly (including `dark:` + * variants, opacity modifiers and arbitrary values, which the bespoke class parser could not). + * `variant`/`color` remain as defaults that any conflicting `className` overrides. + */ +function Text({ + children, + variant = 'body', + color = 'primary', + textColor, + textStyle, + wrap: _wrap, + className, + style, + ...rest +}: TextProps) { + const { colors } = useColorScheme(); + // NativeWind merges className-derived styles before the `style` prop, so anything put in + // `style` would silently beat the call site's own classes. The parser is used here purely to + // see *which* text properties `className` already sets, so the variant/color defaults only + // fill in the gaps instead of overriding them. + const { textStyle: fromClassName } = splitTextClassName({ + className, + themeColors: colors, + wrap: false, + baseFontSize: VARIANT_FONT_SIZE[variant], + }); + const defaults: TextStyle = { + ...(fromClassName.fontSize === undefined && { fontSize: VARIANT_FONT_SIZE[variant] }), + // The variant's line height is only meaningful for the variant's own font size. If className + // resizes the text (e.g. `text-3xl` on a default `body`), keeping it would leave a line box + // shorter than the glyphs and visibly clip their tops and bottoms — seen on the iOS auth + // headline. Falling back to the platform's natural line height is correct there. + ...(fromClassName.lineHeight === undefined && + fromClassName.fontSize === undefined && { lineHeight: VARIANT_LINE_HEIGHT[variant] }), + ...(fromClassName.fontWeight === undefined && { fontWeight: VARIANT_WEIGHT[variant] }), + ...((textColor !== undefined || fromClassName.color === undefined) && { + color: textColor ?? colors[COLOR_KEY[color]], + }), + }; + return ( + + {children} + + ); +} + +export { Text }; +export type { TextColor, TextProps, TextVariant }; diff --git a/packages/ui/src/toggle-props.ts b/packages/ui/src/toggle-props.ts new file mode 100644 index 0000000000..8ff4c223db --- /dev/null +++ b/packages/ui/src/toggle-props.ts @@ -0,0 +1,27 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +/** + * Shared surface for the three `toggle.*` implementations, kept in its own module because a + * platform file cannot import from its own fallback sibling (`toggle.ios.tsx` importing + * `./toggle` resolves back to itself). + * + * Deliberately the React Native `Switch` subset the call sites already use, so swapping the + * implementation underneath needs no call-site changes. + */ +export type ToggleProps = { + value?: boolean; + onValueChange?: (value: boolean) => void; + disabled?: boolean; + className?: string; + style?: StyleProp; + /** + * `Host` itself takes no `testID`, but the native control does: on Android via the + * `testID` compose modifier (moved to modifiers in expo/expo#39155), on iOS via the + * `testID` prop that SwiftUI views have accepted since expo/expo#37919. + * + * Verified on-device on Android: it emits a real node carrying the id with + * `checkable`/`checked`/`clickable`/`focusable` set, and `checked` tracks state — so it is + * both a usable E2E selector and correct accessibility semantics. + */ + testID?: string; +}; diff --git a/packages/ui/src/toggle.android.tsx b/packages/ui/src/toggle.android.tsx new file mode 100644 index 0000000000..5c884c61ab --- /dev/null +++ b/packages/ui/src/toggle.android.tsx @@ -0,0 +1,51 @@ +import { Host as JCHost, Switch as JCSwitch } from '@expo/ui/jetpack-compose'; +import { testID as testIDModifier } from '@expo/ui/jetpack-compose/modifiers'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import type { ToggleProps } from './toggle-props'; + +cssInterop(JCHost, { className: 'style' }); + +// jetpack-compose's Host prop type doesn't extend RN's ViewProps (unlike the universal Host), so +// NativeWind's global className→style augmentation never reaches it. The cssInterop call above +// makes className work at runtime; this widened type just tells TS the truth. Same shape as +// loading-indicator.android.tsx, which is the proven recipe in this package. +type HostProps = ComponentProps & { className?: string }; +const Host = JCHost as (props: HostProps) => ReturnType; + +/** + * Material 3 `Switch`, replacing React Native core's. + * + * This is the *leaf control* shape of @expo/ui adoption: one `Host` around a self-contained native + * control with no RN children, sized by `matchContents`. It composes into the existing RN layouts + * unchanged — no screen rewrite — because the surrounding rows are still React Native. + * + * Unlike RN's `Switch` (which exposes only `trackColor`/`thumbColor`) M3's exposes the full colour + * set, so the app's own accent survives instead of the platform's dynamic palette. + */ +function Toggle({ value, onValueChange, disabled, className, style, testID }: ToggleProps) { + const { colors } = useColorScheme(); + return ( + + + + ); +} + +export { Toggle }; +export type { ToggleProps }; diff --git a/packages/ui/src/toggle.ios.tsx b/packages/ui/src/toggle.ios.tsx new file mode 100644 index 0000000000..e60593f450 --- /dev/null +++ b/packages/ui/src/toggle.ios.tsx @@ -0,0 +1,38 @@ +import { Host as SwiftUIHost, Toggle as SwiftUIToggle } from '@expo/ui/swift-ui'; +import { tint } from '@expo/ui/swift-ui/modifiers'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { cssInterop } from 'nativewind'; +import type { ComponentProps } from 'react'; +import type { ToggleProps } from './toggle-props'; + +cssInterop(SwiftUIHost, { className: 'style' }); + +// See toggle.android.tsx — swift-ui's Host prop type doesn't extend RN's ViewProps, so the +// cssInterop call is what actually makes className work and this cast tells TS so. +type HostProps = ComponentProps & { className?: string }; +const Host = SwiftUIHost as (props: HostProps) => ReturnType; + +/** + * SwiftUI `Toggle`, replacing React Native core's `Switch`. See toggle.android.tsx for why this + * leaf-control shape works where wrapping containers does not. + * + * No `label` is passed: the call sites render their own RN `Text` beside the control, and a + * SwiftUI label here would double it up. + */ +function Toggle({ value, onValueChange, disabled, className, style, testID }: ToggleProps) { + const { colors } = useColorScheme(); + return ( + + + + ); +} + +export { Toggle }; +export type { ToggleProps }; diff --git a/packages/ui/src/toggle.tsx b/packages/ui/src/toggle.tsx new file mode 100644 index 0000000000..f21119e40b --- /dev/null +++ b/packages/ui/src/toggle.tsx @@ -0,0 +1,30 @@ +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { Switch } from 'react-native'; +import type { ToggleProps } from './toggle-props'; + +/** + * Web/fallback implementation — the platform builds use `toggle.ios.tsx` (SwiftUI `Toggle`) and + * `toggle.android.tsx` (Material 3 `Switch`). + * + * `@expo/ui` has no web target, and `apps/expo` does build for web (react-native-web, the `web` + * script, several `.web.tsx` files), so every natively-implemented component needs a real RN + * fallback rather than a stub. For a leaf control that is one small file shared by every screen — + * which is exactly why leaf-level adoption is cheap where screen-level adoption is not. + */ +function Toggle({ value, onValueChange, disabled, style, testID }: ToggleProps) { + const { colors } = useColorScheme(); + return ( + + ); +} + +export { Toggle }; +export type { ToggleProps }; diff --git a/packages/ui/src/toolbar.tsx b/packages/ui/src/toolbar.tsx new file mode 100644 index 0000000000..2227e08659 --- /dev/null +++ b/packages/ui/src/toolbar.tsx @@ -0,0 +1,109 @@ +import { Icon } from 'expo-app/components/Icon'; +import type { IconProps as ExpoIconProps } from 'expo-app/components/Icon/types'; +import { cn } from 'expo-app/lib/cn'; +import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { BlurView } from 'expo-blur'; +import { cssInterop } from 'nativewind'; +import type * as React from 'react'; +import { Platform, View, type ViewProps } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button, type ButtonProps } from './button'; +import { Text } from './text'; + +// Plain RN composition — Toolbar never needed a Host bridge, it already wrapped expo-blur's +// BlurView (an already-installed dependency), not @expo/ui. Ported directly. + +cssInterop(BlurView, { className: 'style' }); + +type ToolbarProps = Omit & { + leftView?: React.ReactNode; + rightView?: React.ReactNode; + iosHint?: string; + iosBlurIntensity?: number; +}; + +function Toolbar({ + leftView, + rightView, + iosHint, + className, + iosBlurIntensity = 60, + ...props +}: ToolbarProps) { + const insets = useSafeAreaInsets(); + + return ( + + {Platform.OS === 'ios' && !iosHint ? ( + <> + {leftView} + {rightView} + + ) : ( + <> + {leftView} + {Platform.OS === 'ios' && !!iosHint && ( + + {iosHint} + + )} + {rightView} + + )} + + ); +} + +function ToolbarIcon({ + icon, + className, + androidRootClassName, + ...props +}: ButtonProps & { icon: ExpoIconProps }) { + const { colors } = useColorScheme(); + return ( + + ); +} + +function ToolbarCTA({ + icon, + className, + androidRootClassName, + ...props +}: ButtonProps & { icon: ExpoIconProps }) { + const { colors } = useColorScheme(); + return ( + + ); +} + +export { Toolbar, ToolbarCTA, ToolbarIcon }; diff --git a/scripts/lint/nativewindui-migration.ts b/scripts/lint/nativewindui-migration.ts index 4b995c0862..e42903ef0f 100644 --- a/scripts/lint/nativewindui-migration.ts +++ b/scripts/lint/nativewindui-migration.ts @@ -10,7 +10,7 @@ // 0 — clean (no violations; progress printed to stdout) // 1 — violations found (direct imports bypassing adapter) -import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const ROOT = join(import.meta.dir, '..', '..'); @@ -62,16 +62,30 @@ console.log('────────────────────── // ── 2. Scan for direct @packrat-ai/nativewindui imports (adapter bypass) ─── -const EXCLUDED = new Set(['node_modules', 'dist', 'build', '.expo', '.wrangler']); +// `ios`/`android`/`Pods` hold generated native projects with no TS worth scanning, and Pods in +// particular contains symlinked .xcframework directories that make a naive recursive walk loop +// forever. Skipping them is both faster and what stops the stack overflow. +const EXCLUDED = new Set([ + 'node_modules', + 'dist', + 'build', + '.expo', + '.wrangler', + 'ios', + 'android', + 'Pods', +]); function walk(dir: string): string[] { const results: string[] = []; - for (const entry of readdirSync(dir)) { - if (EXCLUDED.has(entry)) continue; - const full = join(dir, entry); - if (statSync(full).isDirectory()) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (EXCLUDED.has(entry.name)) continue; + // Never traverse a symlink: following them can revisit an ancestor and recurse without end. + if (entry.isSymbolicLink()) continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { results.push(...walk(full)); - } else if (/\.(ts|tsx)$/.test(entry)) { + } else if (/\.(ts|tsx)$/.test(entry.name)) { results.push(full); } }