@mrmeg/expo-ui 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,92 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.26.0]
9
+
10
+ ### Changed
11
+
12
+ - **`Icon` renders Lucide.** `<Icon name>` draws `lucide-react-native` SVGs
13
+ instead of the `@expo/vector-icons` Feather font. Lucide is a maintained
14
+ superset of Feather in the same 24px, 2px round-stroke style, so glyphs look
15
+ the same, but the set is ten times larger and nothing is loaded as a font.
16
+ `IconName` is now the union of kebab-case Lucide names in the package
17
+ registry (`src/components/icon-names.json`, generated into
18
+ `iconRegistry.generated.ts` by `bun run ui:icons`); only those icons ship in
19
+ the bundle. The `component` escape hatch is unchanged and accepts any
20
+ `LucideIcon` (`CustomIconComponentProps.style` is a view style). Names Lucide
21
+ renamed, which consumers must update: `alert-circle` → `circle-alert`,
22
+ `check-circle` → `circle-check-big`, `x-circle` → `circle-x`,
23
+ `alert-triangle` → `triangle-alert`, `help-circle` → `circle-question-mark`,
24
+ `edit` → `square-pen`, `edit-2` → `pen`, `edit-3` → `pencil`,
25
+ `sliders` → `sliders-horizontal`, `grid` → `layout-grid`, `home` → `house`,
26
+ `unlock` → `lock-open`, `trash-2` → `trash`, `tool` → `wrench`,
27
+ `layout` → `panels-top-left`, `bar-chart-2` → `chart-no-axes-column`,
28
+ `plus-circle` → `circle-plus`, `stop-circle` → `circle-stop`,
29
+ `more-vertical` → `ellipsis-vertical`, `more-horizontal` → `ellipsis`,
30
+ `sidebar` → `panel-left`, `smile` → `face-slightly-smiling`. Brand glyphs
31
+ (`github`, `chrome`, `facebook`, `instagram`, `linkedin`, `twitter`) have
32
+ no Lucide equivalent; pass your own SVG through `component`. Every other
33
+ Feather name resolves unchanged. A name outside the registry is a type error.
34
+
35
+ ### Removed
36
+
37
+ - **`@expo/vector-icons` is no longer a dependency**, and `useResources` no
38
+ longer loads or registers an icon font. `ensureIconFontRegistered` is gone:
39
+ SVG icons render in export-time HTML as `<svg>`, so the hydration fix it
40
+ existed for has nothing left to fix.
41
+
42
+ ### Added
43
+
44
+ - **Peers `lucide-react-native` (`>=1.46 <2`) and `react-native-svg`
45
+ (`>=15 <16`).** Install `react-native-svg` with `npx expo install` so it
46
+ matches your SDK. They are peers, not dependencies, so an app that imports
47
+ Lucide components for `<Icon component>` shares one copy.
48
+ - **`icons.names` in `dist/design-system.json`**, the registry's name list, so
49
+ `@mrmeg/eslint-plugin-expo-ui` can validate icon names against an installed
50
+ release (a lint rule is a follow-up). `schemaVersion` stays `1`.
51
+
52
+ ### Fixed
53
+
54
+ - **Popover, DropdownMenu, Select, and Tooltip content receives Android
55
+ touches.** The fade wrapper between each overlay and its absolutely
56
+ positioned content now fills the overlay with `pointerEvents="box-none"`, so
57
+ the card is inside its parent's bounds. Android dispatches `ACTION_DOWN` and
58
+ accessibility traversal only to children inside their parent's bounds, so
59
+ native controls (`Switch`, `TextInput`) inside a `PopoverContent` ignored
60
+ taps and the content was missing from the accessibility tree. Tap-away still
61
+ closes.
62
+ - **Sheet taps reach controls while the keyboard is up.** `BottomSheet.Content`
63
+ no longer mounts an absolute-fill "Dismiss keyboard" overlay that swallowed
64
+ the first tap on every button, tab and field inside the sheet. It spreads the
65
+ same release-based tap-away boundary `DismissKeyboard` uses onto its content
66
+ column: the boundary never claims the touch, an unclaimed dead-space tap
67
+ dismisses on release only, drags and multitouch do not dismiss, and presence
68
+ still comes from the focus registry so it works in the sheet's isolated
69
+ native window.
70
+ - **`dismissKeyboard()` clears the focus registry immediately.**
71
+ `dismissKeyboardFocusedInput()` blurs the registered field and drops its
72
+ registration in the same call instead of waiting for a native blur callback
73
+ that may never arrive (submit handlers, isolated sheet window, iOS
74
+ secure/plain handoff), so no stale focus presence survives a dismissal. A
75
+ field that takes focus during the blur keeps its registration.
76
+ - **Android sheet bodies fill the rendered sheet.** The content column's
77
+ `maxHeight` cap (last snap point as a window percentage) now applies only
78
+ off Android, where the iOS SwiftUI host needs it; Material's
79
+ `ModalBottomSheet` ignores percentage snap points and its host already bounds
80
+ the column, so a `55%` sheet no longer shows a blank strip below a capped
81
+ `BottomSheet.Body`.
82
+ - **Android secure fields declare a password keyboard.** `TextInput` with
83
+ `secureTextEntry` now reports `textPassword` to the IME (or `numberPassword`
84
+ for the `number-pad`, `decimal-pad`, `numeric` and `phone-pad` keyboards), so
85
+ Gboard shows no suggestion strip and learns nothing typed; autocorrect is off
86
+ and capitalization is `none` unless the props say otherwise. Revealing the
87
+ text with the eye toggle keeps the password keyboard and only drops the
88
+ masking, so focus, selection and the input connection survive exactly as in
89
+ 0.25.1. The Android field is now package-owned — a port of `@expo/ui` 58.0.2's
90
+ universal Android `TextInput` on top of `@expo/ui/jetpack-compose`'s
91
+ `BasicTextField` — because the universal field offers no way to set Compose's
92
+ password keyboard type. iOS keeps `@expo/ui`'s universal field.
93
+
8
94
  ## [0.25.1]
9
95
 
10
96
  ### Fixed
package/LLM_USAGE.md CHANGED
@@ -228,7 +228,7 @@ Check this before creating a new app-local primitive. All components come from
228
228
  | `DropdownMenu` | Menus and command lists | Homemade popover menus |
229
229
  | `EmptyState` | No-data or recoverable error regions | One-off empty placeholders |
230
230
  | `ErrorBoundary` | React render error fallback | Unhandled screen crashes |
231
- | `Icon` | Feather or custom icons with theme tokens | Raw vector icons with hardcoded colors |
231
+ | `Icon` | Lucide or custom icons with theme tokens | Raw vector icons with hardcoded colors |
232
232
  | `InputOTP` | Verification code entry | Several manually managed text inputs |
233
233
  | `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` | List / settings rows with density tokens | Hand-rolled row `View`s |
234
234
  | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers |
package/README.md CHANGED
@@ -32,6 +32,9 @@ Web 0.21. Install these peers at the versions your Expo SDK recommends: `expo`,
32
32
  `react-native-web`, `react-native-gesture-handler`,
33
33
  `react-native-keyboard-controller`, `react-native-safe-area-context`,
34
34
  `react-native-screens`, `@react-native-async-storage/async-storage`, `zustand`.
35
+ `Icon` renders [Lucide](https://lucide.dev/icons) SVGs, so also install its two
36
+ peers: `npx expo install react-native-svg` (a native module, pinned by your
37
+ SDK) and `bun add lucide-react-native` (`>=1.46 <2`).
35
38
 
36
39
  `@rn-primitives/*` and `@expo-google-fonts/inter` are package dependencies, not
37
40
  peers. Animations use React Native `Animated`. `BottomSheet`, `Slider`, and
@@ -343,9 +346,10 @@ Inter is the sans face on every platform, in four static weights
343
346
  from the bundled `@expo-google-fonts/inter`. Serif is Georgia; mono is the
344
347
  platform system monospace.
345
348
 
346
- `useResources()` loads those four weights plus `Feather.font` (from the
347
- package-managed `@expo/vector-icons`) on native, so `StyledText`'s
348
- `light`–`bold` range resolves to real files instead of a faked OS bold. On web
349
+ `useResources()` loads those four weights on native, so `StyledText`'s
350
+ `light`–`bold` range resolves to real files instead of a faked OS bold. Icons
351
+ are SVG (`lucide-react-native`), so there is no icon font to load on any
352
+ platform. On web
349
353
  it injects one Google Fonts Inter stylesheet (all four weights) after hydration
350
354
  unless the app already added it; web weight differentiation then comes from a
351
355
  numeric `fontWeight` on the shared `"Inter"` family. For better first paint in
@@ -391,7 +395,7 @@ When `sansSerif` is overridden, `useResources` skips downloading the packaged
391
395
  Inter faces entirely (native `.ttf`s and the web stylesheet). For the skip to
392
396
  apply, call `setFonts` before the hook mounts (module scope, or ahead of
393
397
  rendering the root); a later call still re-skins all text, it just doesn't
394
- prevent the download. Feather always loads.
398
+ prevent the download.
395
399
 
396
400
  | `webWeightStrategy` | Use when | Effect |
397
401
  |---|---|---|
@@ -432,7 +436,7 @@ building a new primitive.
432
436
  | `DropdownMenu` | Menus and command lists | Row actions, account menu, sort menu, checkbox/radio groups |
433
437
  | `EmptyState` | No-data or recoverable error regions | Empty inbox, no results, failed list load |
434
438
  | `ErrorBoundary` | React render error fallback | Route-level and feature-level fallbacks |
435
- | `Icon` | Feather or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
439
+ | `Icon` | Lucide or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
436
440
  | `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
437
441
  | `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
438
442
  | `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
@@ -458,6 +462,43 @@ building a new primitive.
458
462
  | `ToggleGroup` | Single or multi toggle groups | Alignment, formatting toolbar, filter chips |
459
463
  | `Tooltip` | Short hover/focus help | Icon button labels, field hints, disabled-action reasons |
460
464
 
465
+ ### Icons
466
+
467
+ `Icon` renders `lucide-react-native` — Lucide is a maintained superset of
468
+ Feather in the same 24px, 2px round-stroke style. `name` is typed by
469
+ `IconName`, the union of kebab-case Lucide names in the package registry
470
+ (`src/components/icon-names.json`, about 150 names), so only the icons the package
471
+ and its consumers name ship in the bundle: the root `lucide-react-native`
472
+ entry (1,800+ icons) is never imported. `color` takes a theme color name or a
473
+ literal; `decorative` hides the glyph from assistive tech.
474
+
475
+ ```tsx
476
+ import { Icon } from "@mrmeg/expo-ui/components";
477
+ import Rocket from "lucide-react-native/icons/rocket";
478
+
479
+ <Icon name="circle-check-big" color="success" size={16} />
480
+ <Icon component={Rocket} color="accent" />
481
+ ```
482
+
483
+ Adding an icon: in this repo, add the kebab-case Lucide name to
484
+ `packages/ui/src/components/icon-names.json` (keep it sorted), run
485
+ `bun run ui:icons`, and commit both files — an unknown name fails with the
486
+ alias lines from Lucide's root entry that point at its current name
487
+ (`bun run ui:icons:check` guards freshness in CI). From a consuming app, pass
488
+ any Lucide component through `component` instead; it needs no registry entry.
489
+ Feather-era names that Lucide renamed: `alert-circle` → `circle-alert`,
490
+ `check-circle` → `circle-check-big`, `x-circle` → `circle-x`,
491
+ `alert-triangle` → `triangle-alert`, `help-circle` → `circle-question-mark`,
492
+ `edit` → `square-pen`, `edit-2` → `pen`, `edit-3` → `pencil`,
493
+ `sliders` → `sliders-horizontal`, `grid` → `layout-grid`, `home` → `house`,
494
+ `unlock` → `lock-open`, `trash-2` → `trash`, `tool` → `wrench`,
495
+ `layout` → `panels-top-left`, `bar-chart-2` → `chart-no-axes-column`,
496
+ `plus-circle` → `circle-plus`, `stop-circle` → `circle-stop`,
497
+ `more-vertical` → `ellipsis-vertical`, `more-horizontal` → `ellipsis`,
498
+ `sidebar` → `panel-left`, `smile` → `face-slightly-smiling`. Brand glyphs
499
+ (`github`, `chrome`, `facebook`, `instagram`, `linkedin`, `twitter`) have no
500
+ Lucide equivalent; use `component` with your own SVG.
501
+
461
502
  ### Compound Parts
462
503
 
463
504
  | Root | Exported Parts |
@@ -492,7 +533,7 @@ are named exports only.
492
533
 
493
534
  | Hook | Returns |
494
535
  |------|---------|
495
- | `useResources()` | `{ loaded, error }` — loads Feather plus the Inter faces |
536
+ | `useResources()` | `{ loaded, error }` — loads the Inter faces (icons are SVG, nothing to load) |
496
537
  | `useTheme()` | Active theme, scheme helpers, shadow/contrast/alpha helpers |
497
538
  | `useStyles(factory)` | Memoized theme-aware styles plus the `useTheme()` helpers |
498
539
  | `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
@@ -518,19 +559,30 @@ web, where the window cannot be read during export or hydration.
518
559
  nativeID="email-input" />`). One id on both renders duplicate ids on web and
519
560
  associates nothing. Prefer `TextInput`'s own `label` prop when no separate
520
561
  label element is needed.
562
+ - `TextInput` renders a native field on iOS and Android. On Android a
563
+ `secureTextEntry` field declares a password keyboard (`textPassword`, or
564
+ `numberPassword` for numeric keyboards) in both the masked and the revealed
565
+ eye-toggle state, with autocorrect off by default; that Android field is
566
+ package-owned on top of `@expo/ui/jetpack-compose`'s `BasicTextField`, while
567
+ iOS uses `@expo/ui`'s universal field (SwiftUI `SecureField`).
521
568
  - `BottomSheet` renders the platform's native sheet through `@expo/ui`: iOS
522
569
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`. The
523
570
  platform owns gestures and keyboard avoidance, so `swipeEnabled`,
524
571
  `avoidKeyboard`, and `dismissKeyboardOnDrag` are accepted for call-site
525
- ergonomics but have no effect; `BottomSheet.Content` does mount its own
526
- tap-away keyboard-dismiss overlay while a field is focused. Android has only
527
- two snap states (partial / expanded) and maps extra snap points to the
528
- nearest. `BottomSheet.Content` also takes `backgroundStyle`, merged over the
529
- themed card default on the native sheet surface (web panel, Android
530
- `containerColor`, iOS `presentationBackground`) — pass
531
- `{ backgroundColor: "transparent" }`, plus a `style` clearing the content
532
- column's card fill, to let custom chrome such as a glass backdrop show
533
- through.
572
+ ergonomics but have no effect. The sheet hosts its content in a separate
573
+ native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
574
+ mounts its own tap-away keyboard-dismiss boundary on the content column: it
575
+ never claims the touch (buttons, tabs and other fields fire on the first tap
576
+ with the keyboard up) and dismisses an unclaimed dead-space tap on release,
577
+ matching `DismissKeyboard`. Android has only two snap states (partial /
578
+ expanded) and maps extra snap points to the nearest; because Material ignores
579
+ percentage snap points, the Android body fills the rendered sheet height
580
+ rather than a window-percentage cap. `BottomSheet.Content` also takes
581
+ `backgroundStyle`, merged over the themed card default on the native sheet
582
+ surface (web panel, Android `containerColor`, iOS `presentationBackground`) —
583
+ pass `{ backgroundColor: "transparent" }`, plus a `style` clearing the
584
+ content column's card fill, to let custom chrome such as a glass backdrop
585
+ show through.
534
586
  - `Carousel` renders every child (no virtualization), so slides survive into
535
587
  the exported HTML shell and the first client frame; use `FlatList` for large
536
588
  or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
@@ -691,8 +743,9 @@ packed package at the workspace's Expo version, without a custom Metro config.
691
743
  `ui:build` also writes `dist/design-system.json`
692
744
  ([`scripts/build-design-system-manifest.mjs`](../../scripts/build-design-system-manifest.mjs)):
693
745
  the spacing, radius and icon scales, the palette, the light and dark themes, the
694
- font variants, and every component's variant and size values, serialized out of
695
- `src` by the same extractor the lint rules use. It is exported as
746
+ font variants, every component's variant and size values, and `icons.names` (the
747
+ `IconName` registry), serialized out of `src` by the same extractor the lint
748
+ rules use. It is exported as
696
749
  `@mrmeg/expo-ui/design-system.json`, and `@mrmeg/eslint-plugin-expo-ui` reads it
697
750
  in projects that install this package instead of checking out its sources — which
698
751
  is what lets the design-system rules quote this release's presets and tokens.
@@ -26,7 +26,7 @@ export interface AvatarProps {
26
26
  source?: ImageSourcePropType;
27
27
  /** Display name; supplies the fallback initials and the default accessibility label. */
28
28
  name?: string;
29
- /** Feather icon rendered when there is no image and no derivable initials. @default "user" */
29
+ /** Icon (Lucide name) rendered when there is no image and no derivable initials. @default "user" */
30
30
  icon?: IconName;
31
31
  /** Size token or explicit pixel diameter. Inherited from `AvatarGroup` when unset. @default "md" */
32
32
  size?: AvatarSize;
@@ -148,7 +148,7 @@ interface BottomSheetCloseProps {
148
148
  declare function useBottomSheetContext(): BottomSheetContextValue;
149
149
  declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
150
150
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
151
- declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }: BottomSheetContentProps): React.JSX.Element;
151
+ declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }: BottomSheetContentProps): React.JSX.Element;
152
152
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
153
153
  declare function BottomSheetHeader({ children, style, ...props }: BottomSheetHeaderProps): React.JSX.Element;
154
154
  declare function BottomSheetBody({ children, style, contentContainerStyle, onContentSizeChange, onLayout, ...props }: BottomSheetBodyProps): React.JSX.Element;
@@ -1,15 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
2
+ import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef } from "react";
3
3
  import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
- import { KeyboardController } from "./keyboardController";
6
5
  import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
7
6
  import { useTheme } from "../hooks/useTheme.js";
8
7
  import { spacing } from "../constants/spacing.js";
9
8
  import { useScalePress } from "../hooks/useScalePress.js";
10
9
  import { TextColorContext, TextClassContext } from "./StyledText.context";
11
10
  import { Icon } from "./Icon.js";
12
- import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput, subscribeKeyboardFocus, } from "./keyboardFocusRegistry.js";
11
+ import { useKeyboardDismissResponder } from "./keyboardDismiss.js";
13
12
  const DEFAULT_SNAP_POINTS = ["50%"];
14
13
  function clampSnapIndex(index, maxSnapIndex) {
15
14
  return Math.min(Math.max(index, 0), maxSnapIndex);
@@ -162,48 +161,6 @@ function SheetCloseButton({ style }) {
162
161
  ], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }) }));
163
162
  }
164
163
  // ============================================================================
165
- // Keyboard dismiss overlay — tap-away dismissal inside the sheet
166
- // ============================================================================
167
- /**
168
- * Tap-away keyboard dismissal for sheet content.
169
- *
170
- * The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) hosts its
171
- * RN children in a separate native window via @expo/ui's `RNHostView`, OUTSIDE
172
- * the app's `KeyboardProvider` / `KeyboardDismissBoundary`. Two consequences,
173
- * both worked around here:
174
- *
175
- * 1. Detection: `react-native-keyboard-controller`'s `useKeyboardState` watches
176
- * the main window and does NOT see a keyboard raised inside the sheet's
177
- * window (the same isolation that makes `useSafeAreaInsets()` read zero in
178
- * here). So presence is read from the `keyboardFocusRegistry` instead, which
179
- * is fed by @expo/ui's own native focus events and is window-independent.
180
- * 2. Dispatch: RN's JS responder chain (onStartShouldSetResponder*) is NOT
181
- * dispatched across the `RNHostView` boundary on Android — only `Pressable`
182
- * hit-testing (native `measure()`) works inside the host. So this is a
183
- * transparent `Pressable` mounted ONLY while a field is focused, mirroring
184
- * a tap-to-dismiss overlay in the app. The tap blurs the focused field via
185
- * its own native ref (`dismissKeyboardFocusedInput`), which resigns the
186
- * responder regardless of window; `KeyboardController.dismiss()` is a
187
- * best-effort fallback for the (rare) case with no registered blur handle.
188
- *
189
- * iOS doesn't strictly need this (SwiftUI resigns first-responder on outside
190
- * taps for free), but the same path is harmless and keeps behavior identical.
191
- */
192
- function SheetKeyboardDismissOverlay() {
193
- const hasFocus = useSyncExternalStore(subscribeKeyboardFocus, hasKeyboardFocusedInput, () => false);
194
- if (Platform.OS === "web" || !hasFocus)
195
- return null;
196
- return (_jsx(Pressable, { style: [StyleSheet.absoluteFill, styles.keyboardDismissOverlay], onPressIn: () => {
197
- if (!dismissKeyboardFocusedInput())
198
- KeyboardController.dismiss();
199
- }, accessibilityLabel: "Dismiss keyboard", accessibilityRole: "button" }));
200
- }
201
- const styles = StyleSheet.create({
202
- keyboardDismissOverlay: {
203
- zIndex: 999,
204
- },
205
- });
206
- // ============================================================================
207
164
  // Root
208
165
  // ============================================================================
209
166
  function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
@@ -296,21 +253,41 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
296
253
  // ============================================================================
297
254
  function BottomSheetContent({
298
255
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
299
- swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, children, }) {
256
+ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }) {
300
257
  const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
301
258
  const { theme } = useTheme();
302
259
  const dismissDisabled = useDismissDisabled();
303
260
  const showClose = useShowClose();
304
261
  const { height: winH } = useWindowDimensions();
262
+ // Tap-away keyboard dismissal inside the sheet. The native sheet (SwiftUI
263
+ // `.sheet()` / Material `ModalBottomSheet`) hosts its RN children in a separate
264
+ // native window via @expo/ui's `RNHostView`, OUTSIDE the app's
265
+ // `KeyboardProvider` / `DismissKeyboard`, so an app-level boundary never sees
266
+ // these touches and the sheet mounts its own on the content column. It is the
267
+ // same boundary `DismissKeyboard` uses: presence comes from the
268
+ // `keyboardFocusRegistry` (keyboard-controller's `useKeyboardState` watches
269
+ // the main window and does not observe a keyboard raised in here), the
270
+ // boundary never claims the touch — buttons, tabs and other fields win the
271
+ // responder negotiation and fire on the first tap — and an unclaimed
272
+ // dead-space tap dismisses on release only, through the registered field's
273
+ // own window-independent blur handle.
274
+ const dismissResponderProps = useKeyboardDismissResponder();
305
275
  // Boolean open → native imperative index. The root resets snapIndex to the
306
276
  // highest point while closed; -1 keeps the native sheet closed.
307
277
  const index = open ? snapIndex : -1;
308
- // The native RN-in-SwiftUI host does NOT clamp our RN column to the sheet's
278
+ // iOS: the RN-in-SwiftUI host does NOT clamp our RN column to the sheet's
309
279
  // detent height — it lays the column out at its full intrinsic content height,
310
280
  // SwiftUI then clips it at the sheet edge (footer/tail fall off-screen), and
311
281
  // `flex:1` never gets a definite parent height so the ScrollView can't bound
312
282
  // and scroll. Cap the column to the expanded detent height (highest snap
313
- // point) so `flex:1` resolves and the Body becomes scrollable.
283
+ // point) so `flex:1` resolves and the Body becomes scrollable. Web (vaul)
284
+ // sizes the panel itself from the snap point, so the cap is inert there.
285
+ // Android: the Compose host wraps children in a `flexGrow: 1, height: 0` view
286
+ // that fills `RNHostView`'s measured height, so `flex:1` already resolves,
287
+ // and Material's `ModalBottomSheet` ignores percentage snap points
288
+ // (partial / expanded only) — a window-percentage cap would leave the Body
289
+ // short of the rendered sheet with a blank strip below it. Android therefore
290
+ // keeps `flex:1` alone (see the content column style).
314
291
  const expandedSnap = snapPoints[snapPoints.length - 1];
315
292
  const detentHeight = typeof expandedSnap === "number"
316
293
  ? expandedSnap
@@ -343,16 +320,16 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
343
320
  backgroundStyle: StyleSheet.flatten([
344
321
  { backgroundColor: theme.colors.card },
345
322
  backgroundStyleOverride,
346
- ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { style: [
323
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { testID: testID, style: [
347
324
  {
348
325
  flex: 1,
349
- maxHeight: detentHeight,
350
326
  // Themes the content surface across all platforms regardless of
351
327
  // native sheet chrome.
352
328
  backgroundColor: theme.colors.card,
353
329
  },
330
+ Platform.OS !== "android" && { maxHeight: detentHeight },
354
331
  styleOverride,
355
- ], children: [children, _jsx(SheetKeyboardDismissOverlay, {}), showFloatingClose && (_jsx(SheetCloseButton, { style: {
332
+ ], ...dismissResponderProps, children: [children, showFloatingClose && (_jsx(SheetCloseButton, { style: {
356
333
  // The sheet card sits below the status bar, so no top inset
357
334
  // is needed — just clear the sheet's own top edge / handle.
358
335
  position: "absolute",
@@ -76,7 +76,7 @@ function DropdownMenuContent({ side, align = "start", sideOffset = 4, portalHost
76
76
  return (_jsx(DropdownMenuPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DropdownMenuPrimitive.Overlay, { style: Platform.select({
77
77
  native: StyleSheet.absoluteFill,
78
78
  default: undefined,
79
- }), children: _jsx(AnimatedView, { type: "fade", children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(DropdownMenuPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
79
+ }), children: _jsx(AnimatedView, { type: "fade", style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(DropdownMenuPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
80
80
  backgroundColor: theme.colors.popover,
81
81
  borderWidth: 1,
82
82
  borderColor: theme.colors.border,
@@ -1,7 +1,14 @@
1
1
  import * as React from "react";
2
- import type { StyleProp, TextProps, TextStyle } from "react-native";
3
- import Feather from "@expo/vector-icons/Feather";
2
+ import type { StyleProp, TextStyle, ViewProps, ViewStyle } from "react-native";
4
3
  import type { ThemeColors } from "../constants/colors";
4
+ import { type IconName } from "./iconRegistry.generated";
5
+ /**
6
+ * Kebab-case Lucide icon names the package bundles, served from
7
+ * `icon-names.json` through the generated registry. Add a name there and run
8
+ * `bun run ui:icons` to extend the union; pass any other Lucide component
9
+ * through `component` without touching the registry.
10
+ */
11
+ export type { IconName };
5
12
  /**
6
13
  * Theme color names that can be used as shortcuts.
7
14
  *
@@ -9,7 +16,6 @@ import type { ThemeColors } from "../constants/colors";
9
16
  * (`foreground`, `accent`, `border`, …) and can never drift from the theme.
10
17
  */
11
18
  export type ThemeColorName = keyof ThemeColors;
12
- export type IconName = React.ComponentProps<typeof Feather>["name"];
13
19
  type IconBaseProps = {
14
20
  /** Size of the icon in pixels */
15
21
  size?: number;
@@ -20,33 +26,29 @@ type IconBaseProps = {
20
26
  /** When true, hides the icon from the accessibility tree. @default false */
21
27
  decorative?: boolean;
22
28
  };
23
- type IconAccessibilityProps = Pick<TextProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "aria-hidden">;
29
+ type IconAccessibilityProps = Pick<ViewProps, "accessible" | "importantForAccessibility" | "accessibilityElementsHidden" | "aria-hidden">;
30
+ /**
31
+ * Props handed to a `component`. Wide enough that any `lucide-react-native`
32
+ * component type-checks as-is (`LucideProps` extends `SvgProps`, whose `style`
33
+ * is a view style), and any component that accepts `size` and `color` works.
34
+ */
24
35
  type CustomIconComponentProps = {
25
36
  size: number;
26
37
  color: string;
27
- style?: StyleProp<TextStyle>;
38
+ style?: StyleProp<ViewStyle>;
28
39
  } & Partial<IconAccessibilityProps>;
29
- type FeatherIconProps = IconBaseProps & {
30
- /** The icon name to render (Feather icons) */
40
+ type RegistryIconProps = IconBaseProps & {
41
+ /** The icon name to render (a Lucide name from the package registry) */
31
42
  name: IconName;
32
43
  component?: never;
33
44
  };
34
45
  type CustomIconProps = IconBaseProps & {
35
46
  name?: never;
36
- /** Custom component to render instead of Feather. Receives size and color as props. */
47
+ /**
48
+ * Custom component to render instead of a registry icon. Receives size and
49
+ * color as props — any `lucide-react-native` icon import works here.
50
+ */
37
51
  component: React.ComponentType<CustomIconComponentProps>;
38
52
  };
39
- export type IconProps = FeatherIconProps | CustomIconProps;
40
- /**
41
- * Universal Icon Component
42
- * Renders @expo/vector-icons Feather with theme integration and style support
43
- *
44
- * Usage:
45
- * ```tsx
46
- * <Icon name="check" color="primary" size={16} />
47
- * <Icon name="calendar" color="#FF0000" size={24} />
48
- * <Icon name="terminal" style={{ marginRight: 8 }} />
49
- * ```
50
- */
51
- export declare function Icon(props: IconProps): React.JSX.Element;
52
- export {};
53
+ export type IconProps = RegistryIconProps | CustomIconProps;
54
+ export declare function Icon(props: IconProps): React.JSX.Element | null;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useTheme } from "../hooks/useTheme.js";
3
- import Feather from "@expo/vector-icons/Feather";
3
+ import { ICONS } from "./iconRegistry.generated";
4
4
  /**
5
5
  * Resolve an icon color against the active theme.
6
6
  *
@@ -20,15 +20,26 @@ function resolveIconColor(color, themeColors) {
20
20
  }
21
21
  /**
22
22
  * Universal Icon Component
23
- * Renders @expo/vector-icons Feather with theme integration and style support
23
+ * Renders `lucide-react-native` SVG icons with theme integration and style
24
+ * support. Icons are SVG on every platform: no font to load, and server markup
25
+ * carries the glyph.
24
26
  *
25
27
  * Usage:
26
28
  * ```tsx
27
29
  * <Icon name="check" color="primary" size={16} />
28
30
  * <Icon name="calendar" color="#FF0000" size={24} />
29
31
  * <Icon name="terminal" style={{ marginRight: 8 }} />
32
+ * <Icon component={Rocket} color="accent" /> // any Lucide import
30
33
  * ```
31
34
  */
35
+ const warnedUnknownIconNames = new Set();
36
+ function warnUnknownIconName(name) {
37
+ if (!__DEV__ || warnedUnknownIconNames.has(name))
38
+ return;
39
+ warnedUnknownIconNames.add(name);
40
+ console.warn(`Icon: "${name}" is not in the package icon registry, so nothing was rendered. ` +
41
+ "Add it to icon-names.json and run `bun run ui:icons`, or pass a Lucide component through `component`.");
42
+ }
32
43
  export function Icon(props) {
33
44
  const { size = 24, color, style, decorative = false } = props;
34
45
  const { theme } = useTheme();
@@ -42,9 +53,21 @@ export function Icon(props) {
42
53
  "aria-hidden": true,
43
54
  }
44
55
  : { accessible: true };
56
+ // `style` stays a text style on the public prop for source compatibility
57
+ // with the font-icon era; SVG roots take a view style, and every layout
58
+ // property callers actually pass (margins, transforms) belongs to both.
45
59
  const iconStyle = [style, { pointerEvents: "none" }];
46
60
  if (CustomComponent) {
47
61
  return (_jsx(CustomComponent, { size: size, color: iconColor, style: iconStyle, ...accessibilityProps }));
48
62
  }
49
- return (_jsx(Feather, { name: props.name, size: size, color: iconColor, style: iconStyle, ...accessibilityProps }));
63
+ const name = props.name;
64
+ const Lucide = ICONS[name];
65
+ // `IconName` makes an unknown name a type error, but names also arrive at
66
+ // runtime (a database field, a config file). Rendering `undefined` would
67
+ // throw; draw nothing and say so once per name in development instead.
68
+ if (!Lucide) {
69
+ warnUnknownIconName(name);
70
+ return null;
71
+ }
72
+ return (_jsx(Lucide, { size: size, color: iconColor, style: iconStyle, ...accessibilityProps }));
50
73
  }
@@ -156,19 +156,19 @@ export const Notification = () => {
156
156
  switch (alert?.type) {
157
157
  case "error":
158
158
  return {
159
- icon: "alert-circle",
159
+ icon: "circle-alert",
160
160
  color: theme.colors.destructive,
161
161
  bgColor: withAlpha(theme.colors.destructive, 0.08),
162
162
  };
163
163
  case "success":
164
164
  return {
165
- icon: "check-circle",
165
+ icon: "circle-check-big",
166
166
  color: theme.colors.success,
167
167
  bgColor: withAlpha(theme.colors.success, 0.08),
168
168
  };
169
169
  case "warning":
170
170
  return {
171
- icon: "alert-triangle",
171
+ icon: "triangle-alert",
172
172
  color: theme.colors.warning,
173
173
  bgColor: withAlpha(theme.colors.warning, 0.08),
174
174
  };
@@ -41,7 +41,7 @@ function PopoverContent({ side, align = "center", sideOffset = 4, portalHost, ..
41
41
  ...getShadowStyle("soft"),
42
42
  },
43
43
  ]) ?? undefined;
44
- return (_jsx(PopoverPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(PopoverPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(PopoverPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }));
44
+ return (_jsx(PopoverPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(PopoverPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(PopoverPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }));
45
45
  }
46
46
  function PopoverHeader({ children, style, ...props }) {
47
47
  const { theme } = useTheme();
@@ -100,7 +100,7 @@ function SelectContent({ side, align = "start", sideOffset = 4, portalHost, styl
100
100
  return (_jsx(SelectPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({
101
101
  native: StyleSheet.absoluteFill,
102
102
  default: undefined,
103
- }), children: _jsx(AnimatedView, { type: "fade", children: _jsx(TextColorContext.Provider, { value: theme.colors.popoverForeground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(SelectPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
103
+ }), children: _jsx(AnimatedView, { type: "fade", style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextColorContext.Provider, { value: theme.colors.popoverForeground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(SelectPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
104
104
  backgroundColor: theme.colors.popover,
105
105
  borderWidth: 1,
106
106
  borderColor: theme.colors.border,
@@ -17,7 +17,7 @@ export interface StatCardProps {
17
17
  unit?: string;
18
18
  /** Optional signed change line below the value. */
19
19
  change?: StatCardChange;
20
- /** Optional leading icon name (Feather), rendered beside the label. */
20
+ /** Optional leading icon name (Lucide), rendered beside the label. */
21
21
  icon?: IconName;
22
22
  /** Makes the card pressable with scale feedback (delegates to Card). */
23
23
  onPress?: () => void;