@mrmeg/expo-ui 0.27.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LLM_USAGE.md +241 -37
  3. package/README.md +591 -136
  4. package/bin/expo-ui-icons.mjs +509 -0
  5. package/dist/components/Accordion.js +12 -6
  6. package/dist/components/AnimatedView.d.ts +1 -1
  7. package/dist/components/AnimatedView.js +5 -2
  8. package/dist/components/Avatar.d.ts +1 -1
  9. package/dist/components/Avatar.js +2 -2
  10. package/dist/components/Badge.js +5 -1
  11. package/dist/components/BottomSheet.d.ts +29 -1
  12. package/dist/components/BottomSheet.js +107 -30
  13. package/dist/components/Button.d.ts +20 -4
  14. package/dist/components/Button.js +60 -43
  15. package/dist/components/Card.d.ts +1 -1
  16. package/dist/components/Card.js +24 -4
  17. package/dist/components/Carousel.js +2 -2
  18. package/dist/components/Checkbox.js +51 -44
  19. package/dist/components/Collapsible.js +7 -3
  20. package/dist/components/Dialog.js +87 -16
  21. package/dist/components/Drawer.d.ts +1 -1
  22. package/dist/components/Drawer.js +65 -55
  23. package/dist/components/DropdownMenu.js +11 -7
  24. package/dist/components/EmptyState.d.ts +2 -2
  25. package/dist/components/EmptyState.js +5 -2
  26. package/dist/components/Hydrated.d.ts +31 -0
  27. package/dist/components/Hydrated.js +30 -0
  28. package/dist/components/Icon.d.ts +71 -28
  29. package/dist/components/Icon.js +70 -28
  30. package/dist/components/IconGlyph.d.ts +15 -0
  31. package/dist/components/IconGlyph.js +32 -0
  32. package/dist/components/IconGlyph.native.d.ts +10 -0
  33. package/dist/components/IconGlyph.native.js +30 -0
  34. package/dist/components/InputOTP.js +5 -2
  35. package/dist/components/Item.d.ts +103 -4
  36. package/dist/components/Item.js +168 -15
  37. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  38. package/dist/components/KeyboardAvoidingView.js +4 -1
  39. package/dist/components/Label.js +3 -2
  40. package/dist/components/MaxWidthContainer.js +1 -1
  41. package/dist/components/Notification.js +45 -27
  42. package/dist/components/Popover.d.ts +18 -5
  43. package/dist/components/Popover.js +72 -8
  44. package/dist/components/Progress.js +8 -6
  45. package/dist/components/RadioGroup.js +52 -49
  46. package/dist/components/Screen.d.ts +61 -0
  47. package/dist/components/Screen.js +62 -0
  48. package/dist/components/SectionHeader.d.ts +1 -1
  49. package/dist/components/SegmentedControl.d.ts +12 -4
  50. package/dist/components/SegmentedControl.js +94 -4
  51. package/dist/components/Select.js +21 -26
  52. package/dist/components/Skeleton.js +24 -10
  53. package/dist/components/Slider.d.ts +11 -3
  54. package/dist/components/Slider.js +73 -6
  55. package/dist/components/StatCard.d.ts +4 -2
  56. package/dist/components/StatCard.js +3 -1
  57. package/dist/components/StyledText.d.ts +7 -1
  58. package/dist/components/StyledText.js +5 -3
  59. package/dist/components/Switch.d.ts +1 -1
  60. package/dist/components/Switch.js +20 -30
  61. package/dist/components/Tabs.d.ts +2 -2
  62. package/dist/components/Tabs.js +13 -24
  63. package/dist/components/TextInput.d.ts +1 -1
  64. package/dist/components/TextInput.js +71 -26
  65. package/dist/components/ThemeSelector.d.ts +52 -0
  66. package/dist/components/ThemeSelector.js +90 -0
  67. package/dist/components/Toggle.d.ts +5 -49
  68. package/dist/components/Toggle.js +53 -29
  69. package/dist/components/ToggleGroup.d.ts +4 -8
  70. package/dist/components/ToggleGroup.js +52 -34
  71. package/dist/components/Tooltip.d.ts +1 -1
  72. package/dist/components/Tooltip.js +9 -3
  73. package/dist/components/UIProvider.d.ts +22 -2
  74. package/dist/components/UIProvider.js +15 -3
  75. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  76. package/dist/components/bottomSheetDismiss.js +44 -0
  77. package/dist/components/iconBuiltins.generated.d.ts +143 -0
  78. package/dist/components/iconBuiltins.generated.js +25 -0
  79. package/dist/components/iconTypes.d.ts +37 -0
  80. package/dist/components/iconTypes.js +1 -0
  81. package/dist/components/index.d.ts +47 -44
  82. package/dist/components/index.js +3 -0
  83. package/dist/components/keyboardDismiss.js +12 -4
  84. package/dist/components/nativeTextField.android.js +13 -9
  85. package/dist/constants/colors.d.ts +40 -5
  86. package/dist/constants/colors.js +26 -8
  87. package/dist/constants/fonts.d.ts +41 -2
  88. package/dist/constants/fonts.js +46 -7
  89. package/dist/constants/index.d.ts +5 -4
  90. package/dist/constants/index.js +1 -0
  91. package/dist/constants/interaction.d.ts +18 -0
  92. package/dist/constants/interaction.js +18 -0
  93. package/dist/constants/spacing.d.ts +2 -1
  94. package/dist/constants/spacing.js +2 -1
  95. package/dist/design-system.json +429 -132
  96. package/dist/hooks/index.d.ts +12 -7
  97. package/dist/hooks/index.js +5 -0
  98. package/dist/hooks/useDimensions.d.ts +13 -8
  99. package/dist/hooks/useDimensions.js +123 -48
  100. package/dist/hooks/useFocusVisible.d.ts +30 -0
  101. package/dist/hooks/useFocusVisible.js +45 -0
  102. package/dist/hooks/useFontStyle.d.ts +2 -2
  103. package/dist/hooks/useFontStyle.js +3 -2
  104. package/dist/hooks/useHydrated.d.ts +13 -0
  105. package/dist/hooks/useHydrated.js +19 -0
  106. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  107. package/dist/hooks/useNotificationOffset.js +34 -0
  108. package/dist/hooks/useResources.d.ts +21 -20
  109. package/dist/hooks/useResources.js +72 -68
  110. package/dist/hooks/useScalePress.d.ts +8 -5
  111. package/dist/hooks/useScalePress.js +16 -9
  112. package/dist/hooks/useShape.d.ts +24 -0
  113. package/dist/hooks/useShape.js +24 -0
  114. package/dist/hooks/useStaggeredEntrance.js +9 -7
  115. package/dist/hooks/useTheme.d.ts +3 -3
  116. package/dist/hooks/useTheme.js +177 -66
  117. package/dist/hooks/useWindowInsets.d.ts +13 -0
  118. package/dist/hooks/useWindowInsets.js +23 -0
  119. package/dist/icons/LICENSE.lucide +43 -0
  120. package/dist/icons/lucide.json +2144 -0
  121. package/dist/index.d.ts +5 -5
  122. package/dist/lib/composeRefs.d.ts +8 -0
  123. package/dist/lib/composeRefs.js +20 -0
  124. package/dist/lib/haptics.d.ts +12 -0
  125. package/dist/lib/haptics.js +21 -0
  126. package/dist/lib/index.d.ts +6 -5
  127. package/dist/lib/index.js +1 -0
  128. package/dist/lib/interFonts.d.ts +15 -0
  129. package/dist/lib/interFonts.js +1 -0
  130. package/dist/lib/interFonts.native.d.ts +3 -0
  131. package/dist/lib/interFonts.native.js +20 -0
  132. package/dist/lib/popoverPlacement.d.ts +36 -0
  133. package/dist/lib/popoverPlacement.js +29 -0
  134. package/dist/lib/themedStyles.d.ts +1 -1
  135. package/dist/lib/useAnimatedValue.d.ts +15 -0
  136. package/dist/lib/useAnimatedValue.js +19 -0
  137. package/dist/lib/webHitTarget.d.ts +40 -0
  138. package/dist/lib/webHitTarget.js +37 -0
  139. package/dist/state/feedbackStore.d.ts +24 -0
  140. package/dist/state/feedbackStore.js +16 -0
  141. package/dist/state/globalUIStore.d.ts +27 -0
  142. package/dist/state/globalUIStore.js +23 -0
  143. package/dist/state/index.d.ts +7 -5
  144. package/dist/state/index.js +2 -0
  145. package/dist/state/initialScheme.d.ts +27 -0
  146. package/dist/state/initialScheme.js +29 -0
  147. package/dist/state/notify.d.ts +1 -1
  148. package/dist/state/themeColorScope.d.ts +1 -1
  149. package/dist/state/themeStore.d.ts +69 -16
  150. package/dist/state/themeStore.js +65 -38
  151. package/llms-full.md +59 -10
  152. package/llms.txt +10 -1
  153. package/package.json +79 -4
  154. package/dist/components/iconRegistry.generated.d.ts +0 -148
  155. package/dist/components/iconRegistry.generated.js +0 -296
package/CHANGELOG.md CHANGED
@@ -5,6 +5,672 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### Added
9
+
10
+ - **`ThemeSelector`** (`@mrmeg/expo-ui/components`): a compact System / Light /
11
+ Dark switch bound to `useThemeStore`, so a settings screen needs one row for
12
+ appearance instead of three radio rows. Icon-only by default (about 116 pt,
13
+ sized for `ItemActions`); `showLabels` stretches it with text. Translated
14
+ `labels` name each segment for screen readers. Drawn in RN on every platform,
15
+ with a 44 pt vertical hit area and a selection haptic; `value` /
16
+ `onValueChange` make it controlled.
17
+ - **`registerIcons(icons)`** and **`isIconName(value)`**
18
+ (`@mrmeg/expo-ui/components/Icon`): register an app's glyphs once at module
19
+ scope in the root layout, and narrow a runtime string to `IconName`. The
20
+ `IconRegistry` interface they back is what an app's generated module
21
+ augments. Also exported: the `IconGlyph`, `IconTag`, `IconComponent`,
22
+ `IconAccessibilityProps`, and `NamedIconProps` types, and a `testID` prop on
23
+ `Icon` (a named icon defaults to `icon-<name>` in development).
24
+ - **`expo-ui-icons` bin**: compiles `client/icons.json` against the shipped
25
+ Lucide snapshot (`dist/icons/lucide.json`, Lucide 1.55.0, ISC license in
26
+ `dist/icons/LICENSE.lucide`) into `client/icons.generated.ts` (`appIcons` plus
27
+ the `IconRegistry` augmentation). `init` seeds the manifest from the app's
28
+ `name="…"` / `icon="…"` literals, `add` inserts names (an alias becomes its
29
+ canonical name), `--check` fails on a stale module, and `tabs` rasterizes the
30
+ `tabIcons` subset to 1x/2x/3x template PNGs for a native tab bar, with a
31
+ hash manifest so `tabs --check` needs no rasterizer.
32
+ - `design-system.json` `icons` gains `aliases` (Lucide's renamed names →
33
+ canonical) and `lucide` (the snapshot version).
34
+ - Exact `./components/iconTypes` (the glyph types) and
35
+ `./components/iconBuiltins.generated` (`BUILTIN_ICONS`) subpaths. `Icon`
36
+ re-exports everything an app needs; these exist so every `.ts` module the
37
+ `./components/*` pattern exposes also resolves through the repo's source
38
+ condition.
39
+
40
+ ### Changed
41
+
42
+ - **`Icon` renders package-owned glyph data instead of `lucide-react-native`.**
43
+ Glyphs are plain `[tag, attrs]` data drawn by the package: an inline `<svg>`
44
+ on web and in server HTML (stroked `currentColor`, so theme changes recolor
45
+ through CSS), `react-native-svg` on native with Lucide's own geometry and
46
+ stroke handling. The web bundle no longer carries `react-native-svg` or the
47
+ Lucide runtime, and Jest needs no icon mocks.
48
+ - **`IconName` is the 19 builtins plus app-registered names.** The package
49
+ registers only what its components draw (`check`, `x`, the chevrons, the
50
+ status and theme-mode glyphs, `eye`/`eye-off`, `user`, …); every other name
51
+ comes from the app's `client/icons.json`. `<Icon name>` call sites do not
52
+ change once the names are registered.
53
+ - `ToggleIcon` and `ToggleGroupIcon` take `IconProps` (`name` or `component`,
54
+ plus `size`, `style`, `accessibilityLabel`, …) like `Button.Icon`: color from
55
+ the control's text color and decorative by default.
56
+ - `Icon`'s `component` prop is typed `IconComponent` (`size`, `color`, `style`,
57
+ accessibility props); any component taking `size` and `color` still fits.
58
+
59
+ Upgrading from 0.28: run `bunx expo-ui-icons init` once, typecheck, and
60
+ `bunx expo-ui-icons add` each name it reports (names built at runtime are not
61
+ found by the scan); add `registerIcons(appIcons)` at module scope in the root
62
+ layout (`import { appIcons } from "@/client/icons.generated"`); register
63
+ `appIcons` in the Jest setup too; delete the Lucide Jest module mapping or
64
+ `transformIgnorePatterns` entry and any `iconRegistry.generated` mock; and
65
+ remove `lucide-react-native` from `package.json` once nothing imports it.
66
+ Optionally list `tabIcons` and run `bunx expo-ui-icons tabs` to replace
67
+ `@expo/vector-icons` tab glyphs with template PNGs. Add
68
+ `bunx expo-ui-icons --check` to CI.
69
+
70
+ ### Removed
71
+
72
+ - The `lucide-react-native` peer dependency.
73
+ - The `./components/iconRegistry.generated` export (`ICONS`); glyphs are
74
+ registered data now, and `IconName` comes from `@mrmeg/expo-ui/components/Icon`.
75
+
76
+ ## [0.28.0]
77
+
78
+ ### Added
79
+
80
+ - **`Hydrated`** (`@mrmeg/expo-ui/components`): renders `fallback` on the
81
+ server and through the hydration pass, then its children — the gate for
82
+ markup the server cannot reproduce. Under Expo Router's SSR the server renders
83
+ inside `+html.tsx` while the client hydrates `#root`, so every `useId()`
84
+ differs; the kit defers its own ids, but a Radix-backed component (`Tabs`,
85
+ `Accordion`, `Collapsible`, `Select`, `DropdownMenu`, `Popover`) inside a
86
+ server-rendered route logs "A tree hydrated but some attributes … didn't
87
+ match". Wrap that subtree with a layout-holding fallback and keep the rest of
88
+ the page's server HTML. Built on `useHydrated()`; a no-op gate on native.
89
+ - **`useNotificationOffset()`** (`@mrmeg/expo-ui/hooks`) and
90
+ **`selectNotificationOffset`** / `setNotificationOffset` /
91
+ `clearNotificationOffset` on `globalUIStore`: a layout that owns a bar tells
92
+ global toasts where it ends. `Notification` sits `spacing.sm` past the largest
93
+ of the safe-area inset, the registered offsets and its 12 pt floor, so a
94
+ `notify(..., { position: "bottom" })` toast clears the tab bar instead of
95
+ covering its labels. An offset is measured from the window edge and includes
96
+ the inset there, so a JS tab layout passes `useBottomTabBarHeight()` as-is
97
+ (`useNotificationOffset({ bottom: useBottomTabBarHeight() })` in a child of
98
+ `<Tabs>`); a `NativeTabs` layout passes the platform bar height plus
99
+ `useWindowInsets().bottom` (the template: `client/features/navigation/tabBarMetrics.ts`).
100
+ Pass `null` while the bar is hidden. Several layouts may register; the
101
+ largest value per edge wins; nothing registered keeps today's positions.
102
+ - **`InitialSchemeProvider` / `useInitialScheme()`** (`@mrmeg/expo-ui/state`)
103
+ and **`<UIProvider initialScheme>`**: a first-render scheme hint. `useTheme()`
104
+ renders the hinted `"light"` / `"dark"` until the store's `hasLoadedTheme`
105
+ is true, then the persisted preference and OS scheme as before. For web SSR:
106
+ an app that knows the visitor's scheme on both sides (the template mirrors
107
+ it into a `color-scheme` cookie the server reads from the request and the
108
+ browser from `document.cookie`) paints dark HTML for dark visitors and
109
+ hydrates without a flash or a scheme-dependent mismatch. Mount it above the
110
+ first `useTheme()` caller — the root layout usually reads the theme for the
111
+ navigation `ThemeProvider`, so wrap the layout, not only `UIProvider`. Native
112
+ never hydrates and ignores it; without a hint nothing changes.
113
+ - **`Screen`, the page container.** `<Screen edges={["bottom"]} scroll padded>`
114
+ pads the safe-area insets of the edges it owns (`edges` is required, so a
115
+ screen never double-pads under a navigator header or above a tab bar), adds
116
+ the one 16 pt horizontal inset (`padded={false}` for full-bleed `ItemGroup`
117
+ rows), paints the theme background, and either renders a `View` or, with
118
+ `scroll`, a `ScrollView` whose content container carries the insets so
119
+ content scrolls under the Dynamic Island and home indicator. Its
120
+ `contentContainerStyle` padding is added to the insets, never replaced.
121
+ `ScreenEdge` / `ScreenEdges` types are exported.
122
+ - **`useWindowInsets()`** (`@mrmeg/expo-ui/hooks`): the safe-area insets from
123
+ the `SafeAreaProvider` context with an `initialWindowMetrics` fallback per
124
+ edge, so content the system presents outside the provider (an iOS `Modal`, a
125
+ native bottom sheet, a `UIProvider` mounted above the provider) still clears
126
+ the island and home indicator. `BottomSheet`, `Notification` and `Dialog`
127
+ read it.
128
+ - **Rows name their controls: `useItemLabel()` / `useItemControlLabel(props)`**
129
+ (from `components/Item` and the barrels). An `Item` exposes `{ title,
130
+ titleId }` to the row: a plain-text `ItemTitle` inside `ItemContent` becomes
131
+ the `accessibilityLabel` (`aria-label` on web) of a `Switch`, `Checkbox` (no
132
+ `label`) or `Toggle` that has no label of its own, so a settings row's switch
133
+ is announced as "Public profile, switch, on" instead of "switch, on" — in the
134
+ server HTML too. A composed title (elements inside `ItemTitle`) has no text to
135
+ copy, so the title renders a `nativeID` and the control links to it
136
+ (`aria-labelledby` / `accessibilityLabelledBy`) — on web only after hydration
137
+ (`useHydrated`, below), because `useId()` values do not survive Expo Router's
138
+ streamed hydration. Explicit `accessibilityLabel` / `aria-label` /
139
+ `aria-labelledby` props always win. Apps composing their own controls can
140
+ spread `useItemControlLabel(props)` the same way.
141
+ - **`useHydrated()`** (`@mrmeg/expo-ui/hooks`): `false` during SSR and the
142
+ hydration pass, `true` once the client tree is live (always `true` on
143
+ native). For DOM output the server cannot reproduce, such as `useId()`-based
144
+ `id`/`aria-labelledby` pairs.
145
+ - **`ToggleGroup` web roles.** A `type="single"` group renders
146
+ `role="radiogroup"` with `role="radio"` items (`aria-checked` is valid there);
147
+ a `type="multiple"` group stays a `group` of `aria-pressed` buttons. Native
148
+ already used these roles through the primitive.
149
+ - **`spacing.minTarget` (24)** — the smallest pointer target the kit draws on
150
+ web (WCAG 2.5.8); `touchTarget` (44) stays the native minimum.
151
+ - **`Toggle` warns once in development** when an `iconOnly` toggle has no
152
+ accessible name (`accessibilityLabel`, `aria-label(ledby)`, or a row title).
153
+
154
+ - **`italic` on `StyledText`** and `useFontStyle(weight, variant, { italic })`.
155
+ `resolveFontStyle` takes `{ italic, serifPreset }`: an italic face wins and
156
+ emits no `fontStyle` — the app's `setFonts` `italic` map (new, per family
157
+ group: `italic: { regular, bold, … }`, missing weights fall back to the italic
158
+ `regular`), or the serif preset's italic file — otherwise `fontStyle:
159
+ "italic"` and the platform synthesizes. Native Inter stays four upright files;
160
+ the web Inter stylesheet now includes the 400 italic
161
+ (`Inter:ital,wght@0,400;0,500;0,600;0,700;1,400`).
162
+ - **Newsreader serif preset.** `useResources({ serif: "newsreader", serifFonts })`
163
+ loads a real serif — `Newsreader_400Regular`, `500Medium`, `600SemiBold`,
164
+ `700Bold` and `400Regular_Italic` on native from the map the app passes
165
+ (its own `@expo-google-fonts/newsreader` dependency; the package adds none),
166
+ one Google Fonts stylesheet on web (`id="mrmeg-expo-ui-newsreader"`) — and
167
+ sets the new theme-store field `serifPreset` (`"georgia"` | `"newsreader"`,
168
+ `setSerifPreset`) that `resolveFontStyle` consults for the serif variant.
169
+ Georgia stays the default; a `setFonts` serif override wins over the preset.
170
+ `newsreaderFamilies` and `SerifPreset` are exported from `constants`.
171
+
172
+ - **`setShape` slots for inputs, cards, sheets, badges and dialogs.**
173
+ `ShapeOverrides` gains `input`, `card`, `sheet`, `badge` and `dialog`, each
174
+ `{ borderRadius?: number }` (Button keeps `withShadow`). `input` reaches
175
+ TextInput (not `underlined`), the Select trigger and InputOTP cells; `card`
176
+ reaches Card (surface and pressable ring), StatCard, `bordered` EmptyState
177
+ and SkeletonCard; `sheet` the BottomSheet's top corners where the platform
178
+ draws them (web, Android); `badge` Badge; `dialog` Dialog and AlertDialog
179
+ content. Layered after the static radius and before the caller's `style`, so
180
+ precedence stays caller → app → package. `useShape(slot)` and
181
+ `shapeRadius(override)` in `@mrmeg/expo-ui/hooks` are how a component reads
182
+ its slot.
183
+ - **`ThemeColorExtensions`** in `@mrmeg/expo-ui/constants`: an empty interface
184
+ `ThemeColors` extends, for apps to augment with their own tokens
185
+ (`declare module "@mrmeg/expo-ui/constants" { interface ThemeColorExtensions
186
+ { brandGold: string } }`). The keys type through `setColors`,
187
+ `ThemeColorScope` and `useTheme().theme.colors`, and
188
+ `getThemeCssVariables(overrides)` emits `--c-<kebab>` (and `-rgb`) for any
189
+ extension key the overrides name. Values are literals per scheme; the
190
+ package ships none.
191
+
192
+ - **`ItemGroup`, a flat grouped list** (from `components/Item` and the
193
+ barrels). `<ItemGroup title? description? footer? style? testID?>` stacks
194
+ `Item` rows edge to edge and draws an inset hairline under every row but the
195
+ last. The title is an uppercase muted eyebrow announced as a header (an h2 on
196
+ web), the description and footer are caption text, and the group paints no
197
+ border, radius, shadow, or fill and adds no horizontal padding: the rows and
198
+ the header carry the 16 pt screen inset (`spacing.rowPaddingX`) themselves.
199
+ It replaces the bordered, shadowed box apps wrapped around settings rows,
200
+ which put row content 33 pt from each edge of a 390 pt phone (16 screen + 1
201
+ border + 16 row, 324 pt wide); in a group it sits 16 pt in (358 pt wide).
202
+ Pass rows as direct children (a mapped array works; a Fragment is one row).
203
+
204
+ - **`PopoverContent` `scrollable` and a typed `PopoverTrigger` ref.**
205
+ `scrollable` (default `true`) wraps the children in the scroll body that
206
+ keeps a tall popover inside its room; pass `false` for content that brings
207
+ its own `FlatList` (the cap still applies; the popover then stays on `side`).
208
+ `PopoverTrigger` now types its `ref` and the new `PopoverTriggerRef` type
209
+ names what it holds (`open()` / `close()`), so apps drop their cast.
210
+
211
+ - **One haptics setting for the kit: `<UIProvider haptics>` / `setHaptics()`.**
212
+ `"off"`, `"selection"` (default) or `"all"`, stored in the new
213
+ `useFeedbackStore` (`@mrmeg/expo-ui/state`) and read at event time.
214
+ `"selection"` matches what shipped: Switch, Checkbox and SegmentedControl tap
215
+ on a state change; Toggle and ToggleGroup now join them (see Changed). `"all"`
216
+ adds a light tap on press-in for Button, pressable Card and Item and every
217
+ `useScalePress` consumer. Web never vibrates. `lib/haptics` gains
218
+ `hapticSelection()` and `hapticPress()`, the gated forms controls use;
219
+ `hapticLight/Medium/Success` stay unconditional. `Button` gains `haptic`
220
+ (`true` always, `false` never, omitted follows the setting) and
221
+ `useScalePress`'s `haptic` option accepts `"setting"` (its new default) next
222
+ to `true`/`false`.
223
+ - **`interaction` tokens** in `@mrmeg/expo-ui/constants`: `pressedOpacity` 0.85,
224
+ `disabledOpacity` 0.5, `pressedScale` 0.97, `controlPressedScale` 0.92 — the
225
+ values every pressable now shares, for app code that builds its own.
226
+ - **`Icon` `accessibilityLabel`** for an icon that stands alone: `aria-label`
227
+ on web, `accessibilityLabel` on native.
228
+ - **`useFocusVisible()`** (`@mrmeg/expo-ui/hooks`): the `:focus-visible` gate
229
+ every kit control used to copy, as one hook — `{ focused, onFocus, onBlur }`
230
+ to spread onto a `Pressable`, with `getFocusRingStyle()` layered while
231
+ `focused`. Button, Checkbox, RadioGroup, Select, Switch, Tabs, Toggle and
232
+ ToggleGroup use it (no behavior change), and pressable `Card`, `Item`,
233
+ `AccordionTrigger` and `CollapsibleTrigger` gain a keyboard focus ring on web
234
+ through it (see Changed).
235
+
236
+ - **`BottomSheet` `onDismissed`: fires once per close after the sheet has fully
237
+ dismissed on iOS, Android and web.** Closing a sheet and presenting a `Dialog`
238
+ (an RN `Modal` on iOS since 0.27.1) from the same handler failed on iOS: the
239
+ `UISheetPresentationController` was still dismissing when the `Modal` asked the
240
+ same presenter to present, UIKit rejected it ("already presenting") and RN does
241
+ not retry, so fieldnest #42 opened its Start Trip dialog on a 500 ms timer.
242
+ `onOpenChange(false)` fires when the close is requested, not when the sheet is
243
+ gone; `onDismissed` fires when it is. iOS: `@expo/ui` raises its close callback
244
+ from the native `onDismiss` event of SwiftUI `.sheet(isPresented:onDismiss:)`,
245
+ which runs after the dismissal transition, and the package forwards it.
246
+ Android: `@expo/ui` raises that callback after Material's hide animation for a
247
+ swipe / back / scrim dismissal, and with the removal of the Compose sheet for a
248
+ prop-driven close, which has no exit animation; forwarded the same way. Web:
249
+ `@expo/ui` raises it before its exit animation, so the package instead waits
250
+ for the `close` event of the HTML `<dialog>` that hosts the sheet, which
251
+ `@expo/ui` closes when the animation ends (at once under reduced motion). Fires
252
+ once per close (re-armed on reopen) and always after `onOpenChange(false)` for
253
+ a user dismissal. Present the next modal from `onDismissed`; drop timers.
254
+
255
+ ### Changed
256
+
257
+ - **An `Icon` with no `accessibilityLabel` is hidden from assistive tech**, on
258
+ web (`aria-hidden`) and native (`accessible={false}`), exactly like
259
+ `decorative`. It used to render as an unnamed `role="img"` — about 77 per
260
+ gallery page, one per glyph beside its own text, each a WCAG 1.1.1 failure.
261
+ A standalone icon still needs `accessibilityLabel`, which keeps `role="img"`
262
+ + `aria-label` / `accessible` + `accessibilityLabel`; `decorative` now only
263
+ matters to silence a labelled icon. Consumers that relied on an unlabeled
264
+ icon being focusable by a screen reader must add the label.
265
+ - **`Checkbox` and `RadioGroup.Item` are one control each.** With a `label`,
266
+ the wrapper that extends the tap area to the text is no longer a second
267
+ checkbox/radio (`accessible={false}`, no role): the primitive control is the
268
+ only one announced and it carries the label text as its `accessibilityLabel`
269
+ (`aria-label` on web). On web
270
+ the control's hit box grows to `spacing.minTarget` (24) around the 16/20 px
271
+ drawn box — `hitSlop` is inert on react-native-web — and takes the extra back
272
+ in negative margins, so rows keep their layout; the drawn box, border, focus
273
+ ring and any caller `style` now sit on a child `View` inside the control.
274
+
275
+ - **`design-system.json` is `schemaVersion` 2.** The lint manifest the build
276
+ writes to `dist/` gains `tokens.typography` (`StyledText`'s sizes with their
277
+ line heights) and `fonts.families` (`constants/fonts.ts`'s families per
278
+ variant and weight) for `@mrmeg/eslint-plugin-expo-ui`'s `no-raw-typography`.
279
+ Nothing else in the package changes.
280
+
281
+ - **`Item`'s separator starts under the title.** The hairline now insets past
282
+ the row's `ItemMedia` at its real `size` (it assumed a 40 pt slot, so a
283
+ 36 pt tile's line started 4 pt past the title) and to the row padding when
284
+ the row has no media (it started 68 pt in, under nothing). Rows with the
285
+ default 40 pt `ItemMedia` are unchanged. `separator` no longer defaults to
286
+ `false`: inside an `ItemGroup` the group sets it for every row but the last,
287
+ and an explicit `true` or `false` still wins.
288
+ - **Docs teach flat screens; `Card` is for collection items and single
289
+ tappable objects.** `README.md` gains a Screen layout section, and
290
+ `LLM_USAGE.md` a Screen Layout section whose rules are one 16 pt horizontal
291
+ inset per screen, no bordered/shadowed/tinted panels as layout, lists and
292
+ settings as `ItemGroup` + `Item`, forms spanning the column, media sized to
293
+ the width, and a capped column on wide screens. The first `LLM_USAGE.md`
294
+ Minimal Example is now a flat settings screen, and `llms-full.md` and
295
+ `llms.txt` carry the same rules. `Card` and `StatCard` behave exactly as
296
+ before.
297
+ - **Skeleton reads on a white card.** The fill moves from `muted` (a 1.05:1
298
+ step on white; invisible in light, faint in dark) to `borderStrong`, the
299
+ pulse bottoms out at 0.55 instead of 0.3, and under reduce motion it holds a
300
+ static 0.8. `SkeletonText`, `SkeletonAvatar` and `SkeletonCard` inherit.
301
+ - **SegmentedControl on web is the kit's own control.** `@expo/ui`'s vendored
302
+ web control paints every label white once a `tintColor` is passed, so the
303
+ unselected labels vanished on the light track and the disabled state was
304
+ unreadable. Web now draws a `muted` track, an accent pill (sliding, still
305
+ under reduce motion), `accentForeground` / `mutedForeground` labels, the
306
+ shared disabled opacity and the kit focus ring per segment. `appearance` is
307
+ ignored on web (the theme decides); iOS and Android are unchanged.
308
+ - **Slider's unfilled track follows the theme on web.** The `<input
309
+ type="range">` only took `accent-color`, and Chromium then paints the
310
+ unfilled track dark in light mode. The kit now styles the input itself
311
+ (`appearance: none`, track/fill/thumb from CSS variables, one hoisted
312
+ `<style href="expo-ui-slider">`, scoped to `[data-expo-ui-slider]`). Light
313
+ inactive track is `border` on every platform (was `muted`); dark unchanged.
314
+ - **Keyboard focus rings on pressable `Card`, `Item`, `AccordionTrigger` and
315
+ `CollapsibleTrigger` (web).** They had none; they now show the same
316
+ `getFocusRingStyle()` ring as Button on `:focus-visible`, with the browser
317
+ outline off. Pressable Card and Item also carry their radius on the pressable
318
+ wrapper so the ring follows the corners.
319
+ - **Icon on web emits ARIA only.** The RN-only props (`accessible`,
320
+ `importantForAccessibility`, `accessibilityElementsHidden`) reached the SVG
321
+ DOM element and React warned on every page ("Received `true` for a
322
+ non-boolean attribute `accessible`", "React does not recognize the
323
+ `importantForAccessibility` prop"); web now gets `aria-hidden` (decorative)
324
+ or `role="img"` (+ `aria-label`). Native is unchanged.
325
+ - **Radii on scale.** The BottomSheet close button and the DropdownMenu radio
326
+ indicator use `spacing.radiusFull` for their circles (they were `spacing.xl
327
+ / 2` and a literal `4`; same render). `setShape`'s docs and the README said
328
+ the default button radius is 12; it is 10 (`spacing.radiusMd`) and the docs
329
+ now say so.
330
+ - **Pressed and disabled states are consistent across the kit.** Button's
331
+ pressed opacity goes 0.9 → 0.85 and its label no longer dims a second time
332
+ on top of the container; pressable Card and Item dim to the same 0.85 while
333
+ pressed (they only scaled before). Disabled opacity is 0.5 everywhere it was
334
+ 0.6 (Button, TextInput, Label) and stays 0.5 where it already was (Select,
335
+ RadioGroup, Checkbox, DropdownMenu items, Toggle). Under reduce motion,
336
+ `useScalePress` keeps the scale at 1 instead of jumping to the pressed scale.
337
+ - **Toggle and ToggleGroup tap on a state change** (selection haptic), like
338
+ Switch and Checkbox. `useScalePress` consumers that never passed `haptic`
339
+ (the BottomSheet close button) now follow the setting and are silent by
340
+ default instead of tapping on every press; pass `haptic: true` to keep the
341
+ old behavior, or set `haptics="all"`.
342
+ - **Native bundles ship four Inter files instead of 18, and web bundles ship
343
+ none.** `useResources` imported the `@expo-google-fonts/inter` root entry,
344
+ which `require`s every weight and italic, and a bundler ships every file a
345
+ bundle requires, so each app carried 14 faces nothing referenced (4,844,592
346
+ bytes) on native and all 18 (6,217,596 bytes) as web export assets. Native
347
+ now imports `Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, and
348
+ `Inter_700Bold` from their per-weight subpaths (1,373,004 bytes), and web
349
+ imports no TTF: it keeps loading Inter as one CSS family from Google Fonts.
350
+ `useResources` keeps its export and `{ loaded, error }` result, and the
351
+ registered family names are unchanged. An app that rendered another Inter
352
+ weight or an italic by family name (`Inter_300Light`, `Inter_400Regular_Italic`)
353
+ was relying on a file the package never loaded; it must load that face
354
+ itself, as before.
355
+ - **`package.json` declares `sideEffects`, so barrel imports tree-shake.**
356
+ Without the field a bundler has to assume every module does work when
357
+ imported, and keeps it. The field lists the one module that does,
358
+ `state/themeStore` (its native branch loads the saved theme and starts the
359
+ OS color-scheme listener at module load), as `./src/state/themeStore.ts` and
360
+ `./dist/state/themeStore.js`; everything else is side-effect free, and
361
+ module-scope `StyleSheet.create` / `createThemedStyles` calls are marked
362
+ `/*#__PURE__*/`. A package test fails if a module gains an import-time
363
+ statement the field does not list. Measured with esbuild (web, peers
364
+ external), `import { Button } from "@mrmeg/expo-ui"` went from 199 modules
365
+ (71 of this package's) and 281,990 bytes minified to 20 modules (19) and
366
+ 30,076 bytes, about what the `components/Button` deep import costs. Metro
367
+ bundles everything reachable unless Expo's tree shaking is on
368
+ (`EXPO_UNSTABLE_TREE_SHAKING=1`), which was already dropping unused
369
+ re-exports and still does. Nothing to change in apps.
370
+ - **`Button` measures itself only when it can show a spinner.** Every button
371
+ attached an `onLayout` to record its resting width, which cost a layout
372
+ callback (a `ResizeObserver` on web) and a second render on every mount,
373
+ but the width is only read while `loading` is true, to keep the button's
374
+ size when its label changes under the spinner. A button that never
375
+ receives a `loading` prop no longer measures; one that does (including
376
+ `loading={false}`) and isn't `fullWidth` measures as before, so the loading
377
+ state still keeps the resting width. Nothing to change in apps; a button
378
+ whose `loading` toggles between `undefined` and `true` should pass a
379
+ boolean to keep the width lock.
380
+ - **Web: a theme switch no longer churns every `useTheme()` consumer.** The
381
+ two schemes' `colors` hold the same `var(--c-*)` references but were
382
+ separate objects; they are now one shared object on web
383
+ (`colors.light.colors === colors.dark.colors`), so `theme.colors` keeps its
384
+ identity across a switch. `colors.light` and `colors.dark` stay distinct
385
+ (`dark`, `navigation`, and `fonts` differ). `useTheme()` reads the theme
386
+ store through one subscription (`useShallow`) instead of four, and the
387
+ `<html data-theme>` / `color-scheme` write that ran as an effect in every
388
+ consumer on every switch is one app-wide store subscription, started by the
389
+ first consumer to mount and writing only when the resolved scheme changes.
390
+ `useTheme()`'s return shape and values are unchanged. Consumer note: code
391
+ that mutated `colors.light.colors` or `colors.dark.colors` on web now
392
+ changes both schemes; brand through `setColors` instead.
393
+ - **Web `useDimensions()` shares one window store.** Every consumer added its
394
+ own `resize` listener, set state on every resize event (a new object even
395
+ when nothing changed, so every consumer re-rendered), and wrote the
396
+ `mrmeg-vw` SSR cookie on mount and on every resize event. One listener now
397
+ serves all consumers while any is mounted, consumers re-render only when the
398
+ width or height changes, and the cookie is written once per page view and
399
+ then 250 ms after resizing settles (a pending write is flushed when the last
400
+ consumer unmounts). The returned fields and values are unchanged, and the
401
+ server render and hydration pass still seed from `SsrViewportContext` (or
402
+ the 1280 × 800 default); the real viewport now arrives in a synchronous
403
+ re-render right after hydration instead of a passive effect. One visible
404
+ difference, a fix: a component that mounts after hydration now reads the
405
+ current viewport on its first render instead of rendering one frame at the
406
+ seed width. Native still follows `useWindowDimensions`.
407
+ - `exports` entries list a repo-only `@mrmeg/source` condition first, pointing
408
+ at `src`, and gain exact keys for the nine modules the `components/*`,
409
+ `hooks/*`, and `state/*` patterns cannot map to a source file (a `.ts` module
410
+ under a `*.tsx` pattern, and the reverse). Without the condition — every
411
+ consumer toolchain — each key resolves to the same `dist` files as before.
412
+ - **`getShadowStyle` is edge-lit in dark and stronger in light.** Dark mode
413
+ read flat: the old presets tripled a 0.04 alpha, and black at that alpha is
414
+ invisible on a near-black base, so cards, popovers and sheets did not feel
415
+ raised. The presets now have their own layers per scheme. In dark the raised
416
+ presets (`subtle`, `soft`, `elevated`, `card`, `cardSubtle`, `cardHover`,
417
+ `glass`) start with a 1px inset white top highlight (5 to 6 %), the "lit from
418
+ above" cue, followed by black layers at real alpha, deeper and larger than
419
+ before (`subtle` is `inset 0 1px 0 rgba(255,255,255,0.05), 0 1px 2px
420
+ rgba(0,0,0,0.4), 0 8px 24px rgba(0,0,0,0.35)`). In light the layers keep
421
+ their shape at about double the alpha. `base` has no highlight, and `sharp`
422
+ and `glow` render as before. The dark boost multiplier is gone. Surface
423
+ colors are unchanged.
424
+ - **`Button`, `Toggle` and `ToggleGroup` items have a 44pt pointer target on
425
+ web.** react-native-web ignores `hitSlop`, so the target was the drawn box:
426
+ 28/32/40px for Button `sm`/`md`/`lg`, 32/36/40px for Toggle and ToggleGroup
427
+ sizes. A transparent, absolutely positioned child inside the drawn box now
428
+ extends the target vertically to `spacing.touchTarget` (44); the drawn box,
429
+ layout and the Pressable root's rect (popover and tooltip anchors) do not
430
+ change. On `Button` a caller `hitSlop` is honoured on web the same way
431
+ (numbers on all four sides, insets as given; `hitSlop={0}` opts out). Native
432
+ is unchanged.
433
+
434
+ ### Fixed
435
+
436
+ - **iOS `BottomSheet` no longer clips its last row at a snap point.** Two
437
+ causes, both fixed. The content inside the sheet padded the bottom safe-area
438
+ inset a second time: SwiftUI already lays the hosted column out inside the
439
+ sheet's safe area (34 pt above the home indicator on an iPhone), and inside a
440
+ tab screen the provider context even reported the tab bar (83 pt), so
441
+ `BottomSheet.Footer` sat up to 99 pt above the sheet's edge and the `Body`
442
+ above it was squeezed until its last row was cut. `Body` and `Footer` now pad
443
+ only their own spacing on iOS (Android: see the next entry). And the
444
+ column's iOS height cap was `fraction × window height`, while `@expo/ui`
445
+ maps `"50%"` to SwiftUI's `.fraction(0.5)` of the sheet's *maximum* height
446
+ (the window minus the top safe-area inset minus UIKit's 10 pt gap) and pads
447
+ the hosted column 16 pt under the native grabber; the cap now follows the
448
+ real detent (fraction or clamped fixed height of the available height, less
449
+ the grabber padding unless `BottomSheet.Handle` replaces the native
450
+ indicator), so a host that does not clamp the column no longer overshoots the
451
+ sheet by about 52 pt at 50 %.
452
+ Verified on an iPhone 17 Pro simulator (iOS 26): a 60 % sheet with header,
453
+ four rows and a footer shows every row and its footer 16 pt above the safe
454
+ area. Web is unchanged.
455
+ - **Android `BottomSheet.Footer` and a footer-less `Body` no longer pad the
456
+ navigation-bar inset a second time.** Material3's `ModalBottomSheet` already
457
+ keeps the hosted column above the navigation bar (measured on a Pixel 6a: the
458
+ column ends at the bar's top, and the footer sat 64 dp above the screen edge
459
+ instead of 40), so `useSheetInsets` now zeroes `bottom` on Android as it does
460
+ on iOS; web keeps the window inset. Footers sit 16 dp above the navigation
461
+ bar and bodies gain the inset. Apps that hand-padded an Android sheet footer
462
+ can drop it.
463
+ - **`Button` honours `aria-disabled` and `accessibilityState.disabled`.** A
464
+ button with either (and no `disabled`) is announced as disabled, drawn with
465
+ the disabled look (opacity, no shadow, no press scale or haptic), yet stays
466
+ focusable and fires `onPress`, so the app can explain why the action is
467
+ unavailable — ARIA's contract. Before, the kit's own `disabled={false}`
468
+ overwrote the consumer's state on every platform (screen readers heard an
469
+ enabled button) and apps faked the look with their own opacity. On web the
470
+ attribute is set on the host `<button>` after mount, because react-native-web
471
+ both overwrites `aria-disabled` from its `disabled` prop and turns a
472
+ prop-driven `aria-disabled` into the native `disabled` attribute (no clicks,
473
+ no focus); on a server-rendered page it appears at hydration. `disabled` and
474
+ `loading` block presses exactly as before, and a consumer `accessibilityState`
475
+ is merged (it no longer drops `busy`). `composeRefs` is exported from
476
+ `@mrmeg/expo-ui/lib`.
477
+ - **Toasts clear the Dynamic Island and home indicator.** `Notification` read
478
+ only the provider context and fell back to a fixed 20 pt when it was `null`
479
+ (a `UIProvider` above the app's `SafeAreaProvider`, a toast host in a
480
+ modal), putting top toasts under the island and bottom toasts over the home
481
+ indicator; with an inset it sat flush against it. It now uses
482
+ `useWindowInsets()` and sits `spacing.sm` (8) past the inset — 20 pt from the
483
+ edge when there is none, as before.
484
+ - **Dialogs stay inside the safe area.** `Dialog` / `AlertDialog` centered
485
+ their card in the full window, so the 85 % max height was 85 % of the screen;
486
+ the centered container now pads the window insets on native.
487
+ - **A quiet web console, and a clean first server render.** Every kit
488
+ animation passed `useNativeDriver: true`, which RN's Animated on web answers
489
+ with "`useNativeDriver` is not supported…" per animation; they now use
490
+ `shouldUseNativeDriver` (`@mrmeg/expo-ui/lib`, already `Platform.OS !==
491
+ "web"`). Overlay layers in `Popover`, `Tooltip`, `Select`, `DropdownMenu` and
492
+ the `SegmentedControl` pill passed the deprecated `pointerEvents` *prop*;
493
+ react-native-web logs "props.pointerEvents is deprecated" for it and lazily
494
+ `require`s its warning module inside `createDOMProps` — on the dev server's
495
+ first SSR render of a deep route (the template's `/` and `/showcase`) that
496
+ module load overflowed the stack and the page fell back to client rendering.
497
+ They now set `style.pointerEvents`. `AnimatedView` still accepts the
498
+ `pointerEvents` prop but folds it into `style`; a source-contract test
499
+ (`src/__tests__/webConsoleContracts.test.ts`) keeps both out of the kit.
500
+ - **`ToggleGroup` items no longer put `aria-checked` on a `button`** on web:
501
+ the rn-primitives web item hard-codes `role='button'` before spreading
502
+ props, so Radix's radio role was lost while its `aria-checked` survived. The
503
+ kit now passes the role explicitly (see Added).
504
+ - **`TextInput`'s password eye icon is decorative** on every platform — the
505
+ Show/Hide password button already carries the name — instead of an unnamed
506
+ image inside a button. The eye and clear buttons are also at least
507
+ `spacing.minTarget` (24 px) square instead of their 20 px glyph.
508
+ - **`Tabs` triggers were 16 px tall on web.** The trigger declared `height` but
509
+ also `flex: 1` inside its column-direction wrapper, whose 0% basis in a parent
510
+ of indefinite height collapsed it to the text; every `underline` tab list was
511
+ a 16 px target. The trigger now stretches across the wrapper instead.
512
+
513
+ - **`PopoverContent` keeps its surface under a caller `style`, opens where there
514
+ is room, and scrolls when tall.** Four defects mindmap patched around in its
515
+ graph control panels and search results:
516
+ - A `style` replaced the whole surface (the props spread came after the
517
+ package style), so `style={{ padding: 16 }}` left a see-through card. The
518
+ caller's style now merges over the background, border, radius and shadow.
519
+ - On native the primitive never flips `side`; it clamps an oversized card to
520
+ the raw screen edge (no insets), so a popover opening up from a trigger
521
+ near the top sat under the status bar and one opening down from a trigger
522
+ near the bottom covered its own trigger. `side` is now a preference: the
523
+ card opens on the other side when its content does not fit and there is
524
+ more room there, is capped to the room between the trigger and the safe
525
+ area, and scrolls inside the cap. `insets` default to the safe area.
526
+ - A ScrollView inside the card never dragged on iOS: the primitive's content
527
+ claims the JS responder so presses do not reach the close-on-press
528
+ `Overlay` that wrapped it, and RN iOS will not drag a ScrollView while an
529
+ ancestor is the JS responder (`RCTScrollViewComponentView`
530
+ `_shouldDisableScrollInteraction`). On native the `Overlay` now sits behind
531
+ the card instead of around it and the card claims nothing, so any
532
+ ScrollView inside scrolls; presses on blank space in the card still do not
533
+ close it. The native-driven fade wrapper is non-collapsable, since without
534
+ it the wrapper sometimes stayed at opacity 0 on iOS once the card relaid
535
+ out.
536
+ - On web the primitive's content has no size cap, so a wrapping row (two
537
+ columns of `width: "50%"` items) laid out at the sum of its items' widths,
538
+ 769 px in a 390 px viewport. The card is now capped to Radix's
539
+ `--radix-popover-content-available-width` / `-height`.
540
+ Verified with the showcase's new "Tall and wide content" row on the iPhone 17
541
+ Pro Max simulator (iOS 27): a `side="top"` trigger near the top opens below;
542
+ mid-screen, where neither side fits, it stays on top capped between the safe
543
+ area and the trigger; a drag that starts on a text row scrolls it (offset 0 →
544
+ 69%, it stayed at 0 before); the `Switch` inside toggles; a tap on blank space
545
+ keeps it open and an outside tap closes it; ten consecutive opens rendered.
546
+ Android emulator (Pixel 10, API 36): the tall card opens below capped to the
547
+ room and scrolls, the `Switch` toggles, an outside tap closes it, and the
548
+ wide row stays inside the screen. Web (`bun run build`, 390×844 and
549
+ 1280×800): the wide row stays inside the viewport, the tall card is capped
550
+ and scrolls to its last row, the surface is opaque with its border, and no
551
+ console errors.
552
+
553
+ - **`Dialog` and `AlertDialog` keep their fields and footer above the keyboard
554
+ on Android.** 0.27.1 gave the dialog its own keyboard avoidance on iOS only:
555
+ Android renders dialog content through the primitive `Portal` into
556
+ `UIProvider`'s `PortalHost`, a sibling of the root `KeyboardAvoidingView`, so
557
+ nothing padded it, and with `KeyboardProvider` above `UIProvider` the RN root
558
+ does not shrink for the keyboard either (keyboard-controller consumes the IME
559
+ insets), so a dialog with a focused field stayed centered behind the keyboard.
560
+ `DialogKeyboardAvoidance` now wraps the centered container in the package
561
+ `KeyboardAvoidingView` (`behavior="padding"`, `keyboardVerticalOffset={0}`)
562
+ on Android as well as iOS: keyboard-controller observes the main window's IME,
563
+ which is where the portal-hosted dialog lives, so the container shrinks by the
564
+ keyboard height and the card (capped at 85% of what is left) recenters above
565
+ it. `useKeyboardAvoidance()` is now `true` inside dialog content on Android,
566
+ so a `DismissKeyboard` there adds no second layer; do not wrap dialog content
567
+ in another `KeyboardAvoidingView`. Device-verified on a Pixel 6a (Android 16,
568
+ `@expo/ui` 58.0.2, `KeyboardProvider` above `UIProvider`) with the showcase
569
+ `Dialog` form variant: before, the card stayed centered and its footer sat
570
+ under the keyboard; after, focusing either field moves the card up (title
571
+ from 35.5% to 16.8% of the screen height), both fields and the Cancel /
572
+ Start footer stay above the keyboard, typing lands, and the card recenters
573
+ when the keyboard hides. Cancel and Start fire on the first tap with the
574
+ keyboard up, the fieldless `Dialog` and an `AlertDialog` render as before,
575
+ and a dialog opened from a screen wrapped in `DismissKeyboard` lands at the
576
+ same positions (one shift, not two). Web is unchanged (no software keyboard,
577
+ no avoidance owner).
578
+ - **A tap on dead space inside `Dialog` / `AlertDialog` content dismisses the
579
+ keyboard (iOS, Android).** With a dialog field focused, tapping the card's
580
+ padding, a label or the gap between fields and footer left the keyboard up
581
+ (fieldnest start-trip matrix B3): the iOS `Modal` and the Android portal tree
582
+ sit outside any app-level `DismissKeyboard`, and the dialog mounted no
583
+ boundary of its own. `DialogContent` and `AlertDialogContent` now carry the
584
+ package tap-away boundary (`useKeyboardDismissResponder`, the one
585
+ `DismissKeyboard` and `BottomSheet.Content` use). `Close`, `Action`,
586
+ `Cancel`, buttons and fields win the negotiation and fire on the first tap, a
587
+ tap that begins on a package `TextInput` is left to the field, and the
588
+ boundary dismisses on release of an otherwise unclaimed single-finger tap
589
+ within 10 logical units through the focused field's own blur handle, with a
590
+ `KeyboardController.dismiss()` fallback for RN or third-party inputs. Where it
591
+ sits follows the primitive: `@rn-primitives/dialog`'s native content claims
592
+ every touch inside the card (so the backdrop's close-on-press never fires
593
+ there), and that claim ends the negotiation before any ancestor is asked — a
594
+ boundary on the centered container never armed for a card tap (Pixel 6a, this
595
+ branch's first device check: dead-space taps left the keyboard up). `Dialog`
596
+ therefore carries the boundary on the card itself and keeps the primitive's
597
+ claim; `AlertDialog`, whose primitive content claims nothing, keeps the
598
+ never-claiming boundary on its centered container, which also covers its
599
+ backdrop. A `Dialog` backdrop tap still closes the dialog through the
600
+ primitive `Overlay` and drops the keyboard with it. Nothing new is added
601
+ inside the card, so its `gap` layout is unchanged. Device-verified on the
602
+ Pixel 6a (Android 16, `@expo/ui` 58.0.2): with the odometer field focused, a
603
+ tap on the description text hid the keyboard, the dialog stayed open and the
604
+ card recentered; Cancel closed the dialog on the first tap with the keyboard
605
+ up. Web is untouched (no software keyboard; the boundary returns no handlers
606
+ there).
607
+ - **`syncThemeFromEnvironment()` and `startSystemThemeListener()` are safe to
608
+ call more than once.** Every call after the first returned the same stop
609
+ function, so the first caller to clean up removed the OS color-scheme
610
+ listener for everyone, a cleanup run twice could orphan a later caller's
611
+ listener and let the next call stack a second one, and on native an app's
612
+ `useEffect(() => syncThemeFromEnvironment(), [])` cleanup (StrictMode's
613
+ double effects included) removed the listener the package installs at
614
+ startup. Calls now share one listener, each returns its own idempotent
615
+ release, and the listener is removed when the last holder releases; the
616
+ package's native startup hold is never released. Consumer note: a release
617
+ now drops only that call's hold, so code that relied on one stop function
618
+ ending OS tracking for every caller must release each call. The web setup
619
+ (`getThemeCssVariables()` in `+html.tsx`, `syncThemeFromEnvironment()` in a
620
+ root effect) is now documented in the README and `LLM_USAGE.md`; `UIProvider`
621
+ still does not call it, so apps that skip it keep their current appearance.
622
+ - **Every package component and hook now compiles under the React Compiler.**
623
+ The compiler skipped any function that read or wrote a ref during render,
624
+ mutated a hook result, or used syntax it can't lower, and 134
625
+ `react-hooks/refs` plus 4 `react-hooks/immutability` findings covered
626
+ `Drawer`, `TextInput` (native), `Notification`, `Progress`, `RadioGroup`,
627
+ `Skeleton`, `Switch`, `Tabs`, `Accordion`, `Checkbox`, `BottomSheet`, the
628
+ Android text field, `keyboardDismiss`'s hooks, `useStaggeredEntrance`, and
629
+ `useScalePress` (so every `Button`); computed default props,
630
+ `try`/`finally`, and a reassigned captured counter kept `Drawer`,
631
+ `KeyboardAvoidingView`, `UIProvider`, `Notification`, `ToggleGroup`, and
632
+ `useResources` out as well. Animated values are created once through a
633
+ lazy initializer instead of `useRef(new Animated.Value(x)).current` (which
634
+ also allocated and discarded a value every render), "latest value" refs are
635
+ synced in a layout effect, animations that started during render start in
636
+ a layout effect before the frame paints, and render-time latches are state.
637
+ `Notification` reads `globalUIStore` through zustand's `useStore`: the
638
+ compiler recognizes hooks only by a `use` prefix, so a bare
639
+ `globalUIStore()` call would be cached and skipped on the next render
640
+ (React error #311), and a package test now rejects such calls. Behavior is
641
+ unchanged; apps whose bundler runs the compiler over the package now get
642
+ memoized components. Apps compiling their own components should read
643
+ `globalUIStore` the same way.
644
+
645
+ - **Built output resolves under Node ESM and `moduleResolution: nodenext`.**
646
+ The build left 97 relative imports in the `.d.ts` files extension-less, which
647
+ NodeNext cannot resolve: `skipLibCheck` hid the errors and the symbols behind
648
+ them became `any`. They now carry `.js` or `/index.js`. In the JavaScript, the
649
+ 15 imports of dotted module names (`./StyledText.context`,
650
+ `./iconRegistry.generated`) kept no extension because `.context` read as one;
651
+ they now end in `.js`, so Node can load `components/*` outside a bundler.
652
+ Platform-split imports (`./keyboardController`, `./nativeTextField`) stay
653
+ extension-less, as Metro needs. Nothing to change for consumers.
654
+
655
+ - **`SegmentedControl` stops its sliding pill when it unmounts** (web, Android
656
+ JS driver): the slide started in an effect with no cleanup, so its animation
657
+ frames outlived the control and a Jest suite that rendered it logged "You are
658
+ trying to access a property or method of the Jest environment after it has
659
+ been torn down" 76 times, which fails `--runInBand` runs. The next selection
660
+ also stops the previous slide before starting its own; nothing visible changes.
661
+
662
+ ### Documentation
663
+
664
+ - **`Button.Icon` documents and tests its `component` prop.** `Icon` has taken
665
+ a component through `component` (any `LucideIcon`) since before 0.26, and
666
+ `Button.Icon` forwards every `Icon` prop, so `<Button.Icon component={House} />`
667
+ already rendered with the button's label color; the docs only showed `name`,
668
+ and two doc comments showed an `as` prop that never existed. The README,
669
+ `LLM_USAGE.md`, and those comments now use `component`, and tests pin that a
670
+ real Lucide export type-checks for both and renders sized, colored, and
671
+ decorative exactly like a named icon. No API change: `component` is the name
672
+ the package already used, so there is no `as` alias.
673
+
8
674
  ## [0.27.1]
9
675
 
10
676
  ### Fixed