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