@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.
- package/CHANGELOG.md +712 -0
- package/LLM_USAGE.md +260 -32
- package/README.md +558 -101
- package/dist/components/Accordion.js +12 -6
- package/dist/components/AnimatedView.d.ts +1 -1
- package/dist/components/AnimatedView.js +5 -2
- package/dist/components/Avatar.d.ts +1 -1
- package/dist/components/Avatar.js +2 -2
- package/dist/components/Badge.js +5 -1
- package/dist/components/BottomSheet.d.ts +66 -9
- package/dist/components/BottomSheet.js +143 -39
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +64 -43
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +24 -4
- package/dist/components/Carousel.js +2 -2
- package/dist/components/Checkbox.js +51 -44
- package/dist/components/Collapsible.js +7 -3
- package/dist/components/Dialog.js +178 -29
- package/dist/components/Drawer.d.ts +1 -1
- package/dist/components/Drawer.js +65 -55
- package/dist/components/DropdownMenu.js +11 -7
- package/dist/components/EmptyState.d.ts +2 -2
- package/dist/components/EmptyState.js +5 -2
- package/dist/components/Hydrated.d.ts +31 -0
- package/dist/components/Hydrated.js +30 -0
- package/dist/components/Icon.d.ts +17 -5
- package/dist/components/Icon.js +31 -10
- package/dist/components/InputOTP.js +7 -3
- package/dist/components/Item.d.ts +103 -4
- package/dist/components/Item.js +168 -15
- package/dist/components/KeyboardAvoidingView.d.ts +1 -1
- package/dist/components/KeyboardAvoidingView.js +4 -1
- package/dist/components/Label.js +3 -2
- package/dist/components/MaxWidthContainer.js +1 -1
- package/dist/components/Notification.js +45 -27
- package/dist/components/Popover.d.ts +18 -5
- package/dist/components/Popover.js +72 -8
- package/dist/components/Progress.js +8 -6
- package/dist/components/RadioGroup.js +52 -49
- package/dist/components/Screen.d.ts +61 -0
- package/dist/components/Screen.js +62 -0
- package/dist/components/SectionHeader.d.ts +1 -1
- package/dist/components/SegmentedControl.d.ts +12 -4
- package/dist/components/SegmentedControl.js +94 -4
- package/dist/components/Select.js +21 -26
- package/dist/components/Skeleton.js +24 -10
- package/dist/components/Slider.d.ts +11 -3
- package/dist/components/Slider.js +74 -6
- package/dist/components/StatCard.d.ts +4 -2
- package/dist/components/StatCard.js +3 -1
- package/dist/components/StyledText.d.ts +7 -1
- package/dist/components/StyledText.js +5 -3
- package/dist/components/Switch.d.ts +1 -1
- package/dist/components/Switch.js +23 -32
- package/dist/components/Tabs.d.ts +2 -2
- package/dist/components/Tabs.js +13 -24
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +73 -27
- package/dist/components/Toggle.d.ts +2 -42
- package/dist/components/Toggle.js +38 -27
- package/dist/components/ToggleGroup.d.ts +1 -1
- package/dist/components/ToggleGroup.js +37 -32
- package/dist/components/Tooltip.d.ts +1 -1
- package/dist/components/Tooltip.js +9 -3
- package/dist/components/UIProvider.d.ts +22 -2
- package/dist/components/UIProvider.js +15 -3
- package/dist/components/bottomSheetDismiss.d.ts +19 -0
- package/dist/components/bottomSheetDismiss.js +44 -0
- package/dist/components/bottomSheetHostSupport.d.ts +46 -0
- package/dist/components/bottomSheetHostSupport.js +67 -0
- package/dist/components/index.d.ts +46 -44
- package/dist/components/index.js +2 -0
- package/dist/components/keyboardDismiss.d.ts +25 -0
- package/dist/components/keyboardDismiss.js +60 -3
- package/dist/components/nativeTextField.android.js +13 -9
- package/dist/constants/colors.d.ts +40 -5
- package/dist/constants/colors.js +26 -8
- package/dist/constants/fonts.d.ts +41 -2
- package/dist/constants/fonts.js +46 -7
- package/dist/constants/index.d.ts +5 -4
- package/dist/constants/index.js +1 -0
- package/dist/constants/interaction.d.ts +18 -0
- package/dist/constants/interaction.js +18 -0
- package/dist/constants/spacing.d.ts +2 -1
- package/dist/constants/spacing.js +2 -1
- package/dist/design-system.json +154 -2
- package/dist/hooks/index.d.ts +12 -7
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/useDimensions.d.ts +13 -8
- package/dist/hooks/useDimensions.js +123 -48
- package/dist/hooks/useFocusVisible.d.ts +30 -0
- package/dist/hooks/useFocusVisible.js +45 -0
- package/dist/hooks/useFontStyle.d.ts +2 -2
- package/dist/hooks/useFontStyle.js +3 -2
- package/dist/hooks/useHydrated.d.ts +13 -0
- package/dist/hooks/useHydrated.js +19 -0
- package/dist/hooks/useNotificationOffset.d.ts +20 -0
- package/dist/hooks/useNotificationOffset.js +34 -0
- package/dist/hooks/useResources.d.ts +21 -20
- package/dist/hooks/useResources.js +72 -68
- package/dist/hooks/useScalePress.d.ts +8 -5
- package/dist/hooks/useScalePress.js +16 -9
- package/dist/hooks/useShape.d.ts +24 -0
- package/dist/hooks/useShape.js +24 -0
- package/dist/hooks/useStaggeredEntrance.js +9 -7
- package/dist/hooks/useTheme.d.ts +3 -3
- package/dist/hooks/useTheme.js +177 -66
- package/dist/hooks/useWindowInsets.d.ts +13 -0
- package/dist/hooks/useWindowInsets.js +23 -0
- package/dist/index.d.ts +5 -5
- package/dist/lib/composeRefs.d.ts +8 -0
- package/dist/lib/composeRefs.js +20 -0
- package/dist/lib/haptics.d.ts +12 -0
- package/dist/lib/haptics.js +21 -0
- package/dist/lib/index.d.ts +6 -4
- package/dist/lib/index.js +2 -0
- package/dist/lib/interFonts.d.ts +15 -0
- package/dist/lib/interFonts.js +1 -0
- package/dist/lib/interFonts.native.d.ts +3 -0
- package/dist/lib/interFonts.native.js +20 -0
- package/dist/lib/popoverPlacement.d.ts +36 -0
- package/dist/lib/popoverPlacement.js +29 -0
- package/dist/lib/stateSurface.d.ts +28 -0
- package/dist/lib/stateSurface.js +30 -0
- package/dist/lib/themedStyles.d.ts +1 -1
- package/dist/lib/useAnimatedValue.d.ts +15 -0
- package/dist/lib/useAnimatedValue.js +19 -0
- package/dist/lib/webHitTarget.d.ts +40 -0
- package/dist/lib/webHitTarget.js +37 -0
- package/dist/state/feedbackStore.d.ts +24 -0
- package/dist/state/feedbackStore.js +16 -0
- package/dist/state/globalUIStore.d.ts +27 -0
- package/dist/state/globalUIStore.js +23 -0
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +2 -0
- package/dist/state/initialScheme.d.ts +27 -0
- package/dist/state/initialScheme.js +29 -0
- package/dist/state/notify.d.ts +1 -1
- package/dist/state/themeColorScope.d.ts +1 -1
- package/dist/state/themeStore.d.ts +69 -16
- package/dist/state/themeStore.js +65 -38
- package/llms-full.md +56 -8
- package/llms.txt +10 -1
- 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
|