@mrmeg/expo-ui 0.25.1 → 0.27.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,146 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.27.0]
9
+
10
+ ### Changed
11
+
12
+ - **`StyledText` is non-selectable by default on Android.** RN implements
13
+ `selectable` with `TextView.setTextIsSelectable(true)`, which makes the label
14
+ focusable-in-touch-mode, so a tap on any default `StyledText` next to a
15
+ focused `TextInput` moved view focus to the label and hid the keyboard
16
+ (tractor-tools-direct #19, mindmap #38 worked around it per label). The
17
+ Android default is now `false`; pass `selectable` to opt copyable content in.
18
+ iOS and web keep `true`, an explicit `selectable` prop still wins everywhere,
19
+ and `TextSelectabilityContext` still overrides the platform default. Consumers
20
+ can drop `selectable={false}` added only to keep the Android keyboard up;
21
+ keeping it on control chrome is harmless and still recommended for iOS/web.
22
+ - **`BottomSheet.Content avoidKeyboard` is documented as platform-owned, with
23
+ the iOS behavior verified on device.** The prop stays accepted-and-ignored,
24
+ like `swipeEnabled` and `dismissKeyboardOnDrag`. On iOS (iPhone 17 Pro, iOS
25
+ 27, `@expo/ui` 58.0.2) UIKit's sheet presentation keeps the hosted RN column
26
+ above the keyboard by itself: a 45% sheet is lifted whole (its column shrinks
27
+ 393 → 363 pt), a 92% sheet is shrunk in place (700 → 468 pt), and in both
28
+ cases the column's bottom edge lands at the keyboard's top, so `Footer` and
29
+ the tail of `Body` sit 50 pt (`spacing.md` + home-indicator inset) above the
30
+ keyboard and a long `Body` scrolls. The package therefore adds no inset, and
31
+ none could be correct from JS: `measureInWindow` inside the `layoutRoot`
32
+ sheet host reports host-relative coordinates (the 45% column read bottom 409
33
+ while sitting at screen y 546), and a window-height estimate would lift a
34
+ lifted sheet twice. Android: Material3's `ModalBottomSheet` owns avoidance
35
+ (no JS keyboard signal exists inside the Compose dialog window: RN reads IME
36
+ insets from the main root view, and keyboard-controller watches only the
37
+ main window and RN `Modal` dialogs). Web: none. Never nest a
38
+ `KeyboardAvoidingView` inside a sheet.
39
+
40
+ ### Fixed
41
+
42
+ - **`TextInput` no longer forwards the Android mount blur.** Compose reports
43
+ `isFocused=false` when the field is first composed and the Android field
44
+ forwarded it as `onBlur`, so forms validating on blur showed "required"
45
+ errors before the user typed anything (tractor-tools-direct #28 gated it in
46
+ app code). `onBlur` now fires only after the field has reported focus, and a
47
+ repeated blur with no focus in between is dropped too. Android only; iOS and
48
+ web are unchanged. Consumers can drop touched-field guards added for this.
49
+ - **`BottomSheet.Body` no longer swallows the first tap on its controls while a
50
+ sheet field is focused on iOS.** `Body`'s `ScrollView` now sets
51
+ `keyboardShouldPersistTaps="always"` (before `{...props}`, so an explicit
52
+ consumer value still wins). Without it RN's default `never` policy claimed the
53
+ tap in the capture phase whenever a registered field was focused and the
54
+ keyboard was visible, then blurred the field on release, so chips, buttons,
55
+ tabs and field-to-field handoff inside `Body` needed a second tap. The
56
+ `BottomSheet.Content` column boundary already owns tap-away dismissal, so
57
+ dead-space taps inside `Body` still dismiss on release and a short drag still
58
+ does not. Android >= 30 was already unaffected: RN reads keyboard insets from
59
+ the main root view, and the Material sheet is its own window, so RN's policy
60
+ never armed there.
61
+
62
+ ## [0.26.0]
63
+
64
+ ### Changed
65
+
66
+ - **`Icon` renders Lucide.** `<Icon name>` draws `lucide-react-native` SVGs
67
+ instead of the `@expo/vector-icons` Feather font. Lucide is a maintained
68
+ superset of Feather in the same 24px, 2px round-stroke style, so glyphs look
69
+ the same, but the set is ten times larger and nothing is loaded as a font.
70
+ `IconName` is now the union of kebab-case Lucide names in the package
71
+ registry (`src/components/icon-names.json`, generated into
72
+ `iconRegistry.generated.ts` by `bun run ui:icons`); only those icons ship in
73
+ the bundle. The `component` escape hatch is unchanged and accepts any
74
+ `LucideIcon` (`CustomIconComponentProps.style` is a view style). Names Lucide
75
+ renamed, which consumers must update: `alert-circle` → `circle-alert`,
76
+ `check-circle` → `circle-check-big`, `x-circle` → `circle-x`,
77
+ `alert-triangle` → `triangle-alert`, `help-circle` → `circle-question-mark`,
78
+ `edit` → `square-pen`, `edit-2` → `pen`, `edit-3` → `pencil`,
79
+ `sliders` → `sliders-horizontal`, `grid` → `layout-grid`, `home` → `house`,
80
+ `unlock` → `lock-open`, `trash-2` → `trash`, `tool` → `wrench`,
81
+ `layout` → `panels-top-left`, `bar-chart-2` → `chart-no-axes-column`,
82
+ `plus-circle` → `circle-plus`, `stop-circle` → `circle-stop`,
83
+ `more-vertical` → `ellipsis-vertical`, `more-horizontal` → `ellipsis`,
84
+ `sidebar` → `panel-left`, `smile` → `face-slightly-smiling`. Brand glyphs
85
+ (`github`, `chrome`, `facebook`, `instagram`, `linkedin`, `twitter`) have
86
+ no Lucide equivalent; pass your own SVG through `component`. Every other
87
+ Feather name resolves unchanged. A name outside the registry is a type error.
88
+
89
+ ### Removed
90
+
91
+ - **`@expo/vector-icons` is no longer a dependency**, and `useResources` no
92
+ longer loads or registers an icon font. `ensureIconFontRegistered` is gone:
93
+ SVG icons render in export-time HTML as `<svg>`, so the hydration fix it
94
+ existed for has nothing left to fix.
95
+
96
+ ### Added
97
+
98
+ - **Peers `lucide-react-native` (`>=1.46 <2`) and `react-native-svg`
99
+ (`>=15 <16`).** Install `react-native-svg` with `npx expo install` so it
100
+ matches your SDK. They are peers, not dependencies, so an app that imports
101
+ Lucide components for `<Icon component>` shares one copy.
102
+ - **`icons.names` in `dist/design-system.json`**, the registry's name list, so
103
+ `@mrmeg/eslint-plugin-expo-ui` can validate icon names against an installed
104
+ release (a lint rule is a follow-up). `schemaVersion` stays `1`.
105
+
106
+ ### Fixed
107
+
108
+ - **Popover, DropdownMenu, Select, and Tooltip content receives Android
109
+ touches.** The fade wrapper between each overlay and its absolutely
110
+ positioned content now fills the overlay with `pointerEvents="box-none"`, so
111
+ the card is inside its parent's bounds. Android dispatches `ACTION_DOWN` and
112
+ accessibility traversal only to children inside their parent's bounds, so
113
+ native controls (`Switch`, `TextInput`) inside a `PopoverContent` ignored
114
+ taps and the content was missing from the accessibility tree. Tap-away still
115
+ closes.
116
+ - **Sheet taps reach controls while the keyboard is up.** `BottomSheet.Content`
117
+ no longer mounts an absolute-fill "Dismiss keyboard" overlay that swallowed
118
+ the first tap on every button, tab and field inside the sheet. It spreads the
119
+ same release-based tap-away boundary `DismissKeyboard` uses onto its content
120
+ column: the boundary never claims the touch, an unclaimed dead-space tap
121
+ dismisses on release only, drags and multitouch do not dismiss, and presence
122
+ still comes from the focus registry so it works in the sheet's isolated
123
+ native window.
124
+ - **`dismissKeyboard()` clears the focus registry immediately.**
125
+ `dismissKeyboardFocusedInput()` blurs the registered field and drops its
126
+ registration in the same call instead of waiting for a native blur callback
127
+ that may never arrive (submit handlers, isolated sheet window, iOS
128
+ secure/plain handoff), so no stale focus presence survives a dismissal. A
129
+ field that takes focus during the blur keeps its registration.
130
+ - **Android sheet bodies fill the rendered sheet.** The content column's
131
+ `maxHeight` cap (last snap point as a window percentage) now applies only
132
+ off Android, where the iOS SwiftUI host needs it; Material's
133
+ `ModalBottomSheet` ignores percentage snap points and its host already bounds
134
+ the column, so a `55%` sheet no longer shows a blank strip below a capped
135
+ `BottomSheet.Body`.
136
+ - **Android secure fields declare a password keyboard.** `TextInput` with
137
+ `secureTextEntry` now reports `textPassword` to the IME (or `numberPassword`
138
+ for the `number-pad`, `decimal-pad`, `numeric` and `phone-pad` keyboards), so
139
+ Gboard shows no suggestion strip and learns nothing typed; autocorrect is off
140
+ and capitalization is `none` unless the props say otherwise. Revealing the
141
+ text with the eye toggle keeps the password keyboard and only drops the
142
+ masking, so focus, selection and the input connection survive exactly as in
143
+ 0.25.1. The Android field is now package-owned — a port of `@expo/ui` 58.0.2's
144
+ universal Android `TextInput` on top of `@expo/ui/jetpack-compose`'s
145
+ `BasicTextField` — because the universal field offers no way to set Compose's
146
+ password keyboard type. iOS keeps `@expo/ui`'s universal field.
147
+
8
148
  ## [0.25.1]
9
149
 
10
150
  ### Fixed
package/LLM_USAGE.md CHANGED
@@ -85,7 +85,13 @@ call `dismissKeyboard()` explicitly.
85
85
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`). The
86
86
  platform owns gestures and keyboard avoidance: `swipeEnabled`, `avoidKeyboard`,
87
87
  and `dismissKeyboardOnDrag` are accepted for call-site ergonomics but have no
88
- effect. `Slider` and `SegmentedControl` are also `@expo/ui`-backed.
88
+ effect. On iOS the sheet presentation lifts or shrinks the hosted content so
89
+ `Footer` and the tail of `Body` end at the keyboard's top (device-verified);
90
+ Android avoidance is Material3's; do not nest a `KeyboardAvoidingView` inside a
91
+ sheet. `BottomSheet.Content` mounts the tap-away keyboard-dismiss boundary on
92
+ its column, and `BottomSheet.Body` sets `keyboardShouldPersistTaps="always"` on
93
+ its ScrollView so that boundary owns dismissal; do not pass `never`. `Slider`
94
+ and `SegmentedControl` are also `@expo/ui`-backed.
89
95
 
90
96
  `BottomSheet.Content` themes the native sheet surface with the card color. Pass
91
97
  `backgroundStyle={{ backgroundColor: "transparent" }}`, plus a `style` clearing
@@ -228,7 +234,7 @@ Check this before creating a new app-local primitive. All components come from
228
234
  | `DropdownMenu` | Menus and command lists | Homemade popover menus |
229
235
  | `EmptyState` | No-data or recoverable error regions | One-off empty placeholders |
230
236
  | `ErrorBoundary` | React render error fallback | Unhandled screen crashes |
231
- | `Icon` | Feather or custom icons with theme tokens | Raw vector icons with hardcoded colors |
237
+ | `Icon` | Lucide or custom icons with theme tokens | Raw vector icons with hardcoded colors |
232
238
  | `InputOTP` | Verification code entry | Several manually managed text inputs |
233
239
  | `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions` | List / settings rows with density tokens | Hand-rolled row `View`s |
234
240
  | `KeyboardAvoidingView` | Native keyboard-aware layout root | Repeated app-local keyboard wrappers |
@@ -258,14 +264,15 @@ Check this before creating a new app-local primitive. All components come from
258
264
  `caption`, `label`, `eyebrow`), `size` (`xs`, `sm`, `base`, `body`, `lg`, `xl`,
259
265
  `xxl`, `display`), `fontWeight` (`light`–`bold`), `variant` (`sansSerif`,
260
266
  `serif`, `mono`), `align`, `text`, `tx`, `txOptions`, `selectable` (default
261
- `true`). Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
267
+ `true` on iOS and web, `false` on Android since 0.27.0). Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
262
268
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText`, `SerifText`,
263
269
  `SansSerifText`, `SerifBoldText`, `SansSerifBoldText`.
264
270
 
265
- Set `selectable={false}` on text labels inside app-owned `Pressable`s. Selectable
266
- Android text can take input focus even if the press handler never dismisses the
267
- keyboard. Package controls already scope label selectability; ordinary text
268
- should remain selectable.
271
+ Set `selectable={false}` on text labels inside app-owned `Pressable`s on every
272
+ platform (control chrome shows no selection cursor). On Android `StyledText` is
273
+ non-selectable by default because a selectable `Text` takes input focus and hides
274
+ the keyboard; pass `selectable` to opt copyable content in. Package controls
275
+ already scope label selectability; ordinary iOS/web text stays selectable.
269
276
 
270
277
  ## Component Selection Rules
271
278
 
@@ -275,6 +282,7 @@ should remain selectable.
275
282
  - `Carousel` for a horizontal snap row of a known, small set of slides. It renders every child (no virtualization), so slides survive into the exported HTML shell and the first client frame; use `FlatList` for large or unbounded data. Dots are pressable and jump to their slide. A fractional `itemWidth` (default `0.85`) measures the viewport until the first layout, so pass absolute pixels (`> 1`) when the parent is narrower than the window and the first frame matters.
276
283
  - `Avatar` with both `source` and `name` whenever both exist: `name` supplies the initials shown when the image is absent, still loading, or failed, plus the default accessibility label. Inside `AvatarGroup`, set `size`/`shape` on the group — children inherit them and gain the ring; the group's count is a hidden summary node, so each member stays individually announceable and the group needs no `accessible` wrapper.
277
284
  - Pair a standalone `Label` with its control using two DISTINCT ids: `nativeID` is the label's own id, `htmlFor` is the input's id (`<Label nativeID="email-label" htmlFor="email-input">` + `<TextInput nativeID="email-input" />`). One id on both renders duplicate ids on web and associates nothing. Prefer `TextInput`'s own `label` prop when no separate label element is needed.
285
+ - `TextInput` `onBlur` fires only after a real focus on Android (the native field's first-composition blur is dropped in the package); validate on blur directly and do not add a touched-fields guard in app code.
278
286
  - `Drawer.Header` takes `icon`, `title`, and `action` slots for a compact app-brand row; put `Drawer.ToggleCollapse` in `action` for a trailing rail control. `Drawer.Content` owns safe-area top/bottom padding — do not duplicate it in children.
279
287
 
280
288
  ## Minimal Examples
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
@@ -311,12 +314,17 @@ import { BodyText, CaptionText, HeadingText, StyledText } from "@mrmeg/expo-ui/c
311
314
  - `fontWeight`: `light`, `regular`, `medium`, `semibold`, `bold`
312
315
  - `variant`: `sansSerif`, `serif`, `mono`
313
316
  - `align`, `text`, `tx`, `txOptions`
314
- - `selectable`: defaults to `true`; package controls disable it for labels and
315
- interactive chrome where accidental drag selection would feel broken.
316
-
317
- For app-owned `Pressable` labels, explicitly set `selectable={false}` on the
318
- nested `StyledText`. On Android selectable label text can take focus and the IME
319
- connection from an active input. Preserve selection for ordinary readable text.
317
+ - `selectable`: defaults to `true` on iOS and web and to `false` on Android
318
+ (0.27.0), where a selectable `Text` is focusable and takes focus and the IME
319
+ from an active input. Opt copyable content in per element with `selectable`
320
+ (codes, addresses, chat bubbles). Package controls disable it on every
321
+ platform for labels and interactive chrome where accidental drag selection
322
+ would feel broken.
323
+
324
+ For app-owned `Pressable` labels, still set `selectable={false}` on the nested
325
+ `StyledText` so iOS and web show no selection cursor on control chrome. Ordinary
326
+ readable text keeps selection on iOS and web; on Android it selects only when
327
+ opted in.
320
328
 
321
329
  Aliases: `DisplayText`, `TitleText`, `HeadingText`, `SubheadingText`,
322
330
  `BodyText`, `CaptionText`, `LabelText`, `EyebrowText`, `MonoText` (code, IDs,
@@ -343,9 +351,10 @@ Inter is the sans face on every platform, in four static weights
343
351
  from the bundled `@expo-google-fonts/inter`. Serif is Georgia; mono is the
344
352
  platform system monospace.
345
353
 
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
354
+ `useResources()` loads those four weights on native, so `StyledText`'s
355
+ `light`–`bold` range resolves to real files instead of a faked OS bold. Icons
356
+ are SVG (`lucide-react-native`), so there is no icon font to load on any
357
+ platform. On web
349
358
  it injects one Google Fonts Inter stylesheet (all four weights) after hydration
350
359
  unless the app already added it; web weight differentiation then comes from a
351
360
  numeric `fontWeight` on the shared `"Inter"` family. For better first paint in
@@ -391,7 +400,7 @@ When `sansSerif` is overridden, `useResources` skips downloading the packaged
391
400
  Inter faces entirely (native `.ttf`s and the web stylesheet). For the skip to
392
401
  apply, call `setFonts` before the hook mounts (module scope, or ahead of
393
402
  rendering the root); a later call still re-skins all text, it just doesn't
394
- prevent the download. Feather always loads.
403
+ prevent the download.
395
404
 
396
405
  | `webWeightStrategy` | Use when | Effect |
397
406
  |---|---|---|
@@ -432,7 +441,7 @@ building a new primitive.
432
441
  | `DropdownMenu` | Menus and command lists | Row actions, account menu, sort menu, checkbox/radio groups |
433
442
  | `EmptyState` | No-data or recoverable error regions | Empty inbox, no results, failed list load |
434
443
  | `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 |
444
+ | `Icon` | Lucide or custom icons on theme tokens | Button accessories, menu icons, status glyphs |
436
445
  | `InputOTP` | Verification code entry | Email/SMS codes, MFA, invite codes |
437
446
  | `Item` | List and settings rows on the density tokens | Settings lists, inbox rows, pickers, detail rows |
438
447
  | `KeyboardAvoidingView` | Native keyboard-aware layout | Screen roots, composer footers, form-heavy subtrees |
@@ -458,6 +467,43 @@ building a new primitive.
458
467
  | `ToggleGroup` | Single or multi toggle groups | Alignment, formatting toolbar, filter chips |
459
468
  | `Tooltip` | Short hover/focus help | Icon button labels, field hints, disabled-action reasons |
460
469
 
470
+ ### Icons
471
+
472
+ `Icon` renders `lucide-react-native` — Lucide is a maintained superset of
473
+ Feather in the same 24px, 2px round-stroke style. `name` is typed by
474
+ `IconName`, the union of kebab-case Lucide names in the package registry
475
+ (`src/components/icon-names.json`, about 150 names), so only the icons the package
476
+ and its consumers name ship in the bundle: the root `lucide-react-native`
477
+ entry (1,800+ icons) is never imported. `color` takes a theme color name or a
478
+ literal; `decorative` hides the glyph from assistive tech.
479
+
480
+ ```tsx
481
+ import { Icon } from "@mrmeg/expo-ui/components";
482
+ import Rocket from "lucide-react-native/icons/rocket";
483
+
484
+ <Icon name="circle-check-big" color="success" size={16} />
485
+ <Icon component={Rocket} color="accent" />
486
+ ```
487
+
488
+ Adding an icon: in this repo, add the kebab-case Lucide name to
489
+ `packages/ui/src/components/icon-names.json` (keep it sorted), run
490
+ `bun run ui:icons`, and commit both files — an unknown name fails with the
491
+ alias lines from Lucide's root entry that point at its current name
492
+ (`bun run ui:icons:check` guards freshness in CI). From a consuming app, pass
493
+ any Lucide component through `component` instead; it needs no registry entry.
494
+ Feather-era names that Lucide renamed: `alert-circle` → `circle-alert`,
495
+ `check-circle` → `circle-check-big`, `x-circle` → `circle-x`,
496
+ `alert-triangle` → `triangle-alert`, `help-circle` → `circle-question-mark`,
497
+ `edit` → `square-pen`, `edit-2` → `pen`, `edit-3` → `pencil`,
498
+ `sliders` → `sliders-horizontal`, `grid` → `layout-grid`, `home` → `house`,
499
+ `unlock` → `lock-open`, `trash-2` → `trash`, `tool` → `wrench`,
500
+ `layout` → `panels-top-left`, `bar-chart-2` → `chart-no-axes-column`,
501
+ `plus-circle` → `circle-plus`, `stop-circle` → `circle-stop`,
502
+ `more-vertical` → `ellipsis-vertical`, `more-horizontal` → `ellipsis`,
503
+ `sidebar` → `panel-left`, `smile` → `face-slightly-smiling`. Brand glyphs
504
+ (`github`, `chrome`, `facebook`, `instagram`, `linkedin`, `twitter`) have no
505
+ Lucide equivalent; use `component` with your own SVG.
506
+
461
507
  ### Compound Parts
462
508
 
463
509
  | Root | Exported Parts |
@@ -492,7 +538,7 @@ are named exports only.
492
538
 
493
539
  | Hook | Returns |
494
540
  |------|---------|
495
- | `useResources()` | `{ loaded, error }` — loads Feather plus the Inter faces |
541
+ | `useResources()` | `{ loaded, error }` — loads the Inter faces (icons are SVG, nothing to load) |
496
542
  | `useTheme()` | Active theme, scheme helpers, shadow/contrast/alpha helpers |
497
543
  | `useStyles(factory)` | Memoized theme-aware styles plus the `useTheme()` helpers |
498
544
  | `useDimensions()` | `{ width, height, orientation, isSmallScreen, isMediumScreen, isLargeScreen }` against `SCREEN_SIZES` (768 / 1000 / 1200) |
@@ -518,19 +564,43 @@ web, where the window cannot be read during export or hydration.
518
564
  nativeID="email-input" />`). One id on both renders duplicate ids on web and
519
565
  associates nothing. Prefer `TextInput`'s own `label` prop when no separate
520
566
  label element is needed.
567
+ - `TextInput` renders a native field on iOS and Android. On Android a
568
+ `secureTextEntry` field declares a password keyboard (`textPassword`, or
569
+ `numberPassword` for numeric keyboards) in both the masked and the revealed
570
+ eye-toggle state, with autocorrect off by default; that Android field is
571
+ package-owned on top of `@expo/ui/jetpack-compose`'s `BasicTextField`, while
572
+ iOS uses `@expo/ui`'s universal field (SwiftUI `SecureField`). On Android the
573
+ native field reports a blur when it is first composed; the package forwards
574
+ `onBlur` only after the field has reported focus, so blur validation never
575
+ flags an untouched field and app code needs no touched-fields guard.
521
576
  - `BottomSheet` renders the platform's native sheet through `@expo/ui`: iOS
522
577
  SwiftUI `.sheet()`, Android Material3 `ModalBottomSheet`, web `vaul`. The
523
578
  platform owns gestures and keyboard avoidance, so `swipeEnabled`,
524
579
  `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.
580
+ ergonomics but have no effect. On iOS the sheet presentation itself keeps the
581
+ hosted content above the keyboard — a short sheet is lifted whole, a tall one
582
+ is shrunk — so `Footer` and the tail of `Body` end at the keyboard's top with
583
+ only their home-indicator padding as clearance (device-verified on iOS 27 with
584
+ `@expo/ui` 58); on Android Material3's `ModalBottomSheet` owns it (no JS
585
+ keyboard signal exists inside the Compose dialog window); web has none. Do not
586
+ wrap sheet content in another `KeyboardAvoidingView`: it would lift twice.
587
+ The sheet hosts its content in a separate
588
+ native window outside the app's `DismissKeyboard`, so `BottomSheet.Content`
589
+ mounts its own tap-away keyboard-dismiss boundary on the content column: it
590
+ never claims the touch (buttons, tabs and other fields fire on the first tap
591
+ with the keyboard up) and dismisses an unclaimed dead-space tap on release,
592
+ matching `DismissKeyboard`. `BottomSheet.Body` sets
593
+ `keyboardShouldPersistTaps="always"` on its ScrollView so that column boundary
594
+ owns dismissal; do not pass `never`, which lets RN claim the first tap on a
595
+ body control and blur the field instead. Android has only two snap states
596
+ (partial / expanded) and maps extra snap points to the nearest; because
597
+ Material ignores percentage snap points, the Android body fills the rendered
598
+ sheet height rather than a window-percentage cap. `BottomSheet.Content` also
599
+ takes `backgroundStyle`, merged over the themed card default on the native
600
+ sheet surface (web panel, Android `containerColor`, iOS
601
+ `presentationBackground`) — pass `{ backgroundColor: "transparent" }`, plus a
602
+ `style` clearing the content column's card fill, to let custom chrome such as
603
+ a glass backdrop show through.
534
604
  - `Carousel` renders every child (no virtualization), so slides survive into
535
605
  the exported HTML shell and the first client frame; use `FlatList` for large
536
606
  or unbounded data. An `itemWidth` below 1 (default `0.85`) is a fraction of
@@ -691,8 +761,9 @@ packed package at the workspace's Expo version, without a custom Metro config.
691
761
  `ui:build` also writes `dist/design-system.json`
692
762
  ([`scripts/build-design-system-manifest.mjs`](../../scripts/build-design-system-manifest.mjs)):
693
763
  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
764
+ font variants, every component's variant and size values, and `icons.names` (the
765
+ `IconName` registry), serialized out of `src` by the same extractor the lint
766
+ rules use. It is exported as
696
767
  `@mrmeg/expo-ui/design-system.json`, and `@mrmeg/eslint-plugin-expo-ui` reads it
697
768
  in projects that install this package instead of checking out its sources — which
698
769
  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;
@@ -10,8 +10,18 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
10
10
  *
11
11
  * The compound surface (Trigger / Content / Handle / Header / Body / Footer /
12
12
  * Close), controlled + uncontrolled state, and theming match a hand-rolled
13
- * sheet, but the platform owns gestures and keyboard avoidance — so there's no
14
- * PanResponder, snap-physics, or keyboard lift-and-shrink code to maintain.
13
+ * sheet, but the platform owns gestures — so there's no PanResponder or
14
+ * snap-physics code to maintain. Keyboard avoidance is the platform's too.
15
+ * iOS (device-verified, iOS 27 / `@expo/ui` 58): UIKit's sheet presentation
16
+ * keeps the hosted RN column above the keyboard by itself — a short sheet is
17
+ * lifted whole, a tall one is shrunk — so the column's bottom edge lands at the
18
+ * keyboard's top and `Footer` / the tail of `Body` stay reachable with only
19
+ * their own home-indicator padding as clearance. The package adds no inset:
20
+ * nothing in JS can measure the column in screen space (`measureInWindow`
21
+ * inside the `layoutRoot` sheet host reports host-relative coordinates), and a
22
+ * window-height estimate would lift a lifted sheet twice. Android: Material3's
23
+ * `ModalBottomSheet` owns it (no JS keyboard signal exists inside the Compose
24
+ * dialog window). Web: none. Never nest a `KeyboardAvoidingView` in a sheet.
15
25
  *
16
26
  * Platform-owned behaviors (props accepted for ergonomics, but the platform
17
27
  * decides):
@@ -24,6 +34,10 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
24
34
  * platform's on native; theming reaches the content + background color.
25
35
  * - On Android only two snap states exist (partial / expanded); extra snap
26
36
  * points map to the nearest of those two.
37
+ * - `Body` sets `keyboardShouldPersistTaps="always"` on its ScrollView so RN's
38
+ * own tap-dismissal never claims the first tap on a chip, button or field
39
+ * while a sheet field is focused; the `Content` boundary owns tap-away
40
+ * dismissal instead.
27
41
  *
28
42
  * Scrollable bodies: the native sheet doesn't bound the hosted RN content to
29
43
  * the detent height, so a tall `Body` overflows and clips its footer/tail. When
@@ -113,7 +127,13 @@ interface BottomSheetTriggerProps {
113
127
  interface BottomSheetContentProps extends ViewProps {
114
128
  /** Accepted for call-site ergonomics; ignored (platform owns gestures). */
115
129
  swipeEnabled?: boolean;
116
- /** Accepted for call-site ergonomics; ignored (platform owns keyboard avoidance). */
130
+ /**
131
+ * Accepted for call-site ergonomics; ignored (platform owns keyboard
132
+ * avoidance). iOS: UIKit's sheet presentation lifts or shrinks the hosted
133
+ * content so its bottom edge sits at the keyboard's top (device-verified).
134
+ * Android: Material3's `ModalBottomSheet`. Web: none. `false` has no effect;
135
+ * do not wrap sheet content in another `KeyboardAvoidingView`.
136
+ */
117
137
  avoidKeyboard?: boolean;
118
138
  /** Accepted for call-site ergonomics; ignored (platform owns keyboard). */
119
139
  dismissKeyboardOnDrag?: boolean;
@@ -148,7 +168,7 @@ interface BottomSheetCloseProps {
148
168
  declare function useBottomSheetContext(): BottomSheetContextValue;
149
169
  declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
150
170
  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;
171
+ declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }: BottomSheetContentProps): React.JSX.Element;
152
172
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
153
173
  declare function BottomSheetHeader({ children, style, ...props }: BottomSheetHeaderProps): React.JSX.Element;
154
174
  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",
@@ -458,7 +435,15 @@ function BottomSheetBody({ children, style, contentContainerStyle, onContentSize
458
435
  paddingBottom: spacing.md + (hasFooter ? 0 : insets.bottom),
459
436
  },
460
437
  contentContainerStyle,
461
- ], showsVerticalScrollIndicator: false, onLayout: (e) => {
438
+ ],
439
+ // The `Content` column's keyboard-dismiss boundary owns tap-away
440
+ // dismissal (see DismissKeyboard). RN's default `never` claims the first
441
+ // tap on any non-input child while a registered field is focused and
442
+ // blurs it on release; `handled` can still blur independently after a
443
+ // non-scrolling drag. `always` leaves every tap to its target and lets
444
+ // dead-space taps bubble to the boundary. Placed before `{...props}` so an
445
+ // explicit consumer value still wins.
446
+ keyboardShouldPersistTaps: "always", showsVerticalScrollIndicator: false, onLayout: (e) => {
462
447
  viewportH.current = e.nativeEvent.layout.height;
463
448
  evaluate();
464
449
  onLayout?.(e);
@@ -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,