@thewhileloop/whileui 4.1.1 → 4.2.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 (36) hide show
  1. package/README.md +61 -17
  2. package/dist/blocks/datepicker/date-range-picker-modal.js +3 -2
  3. package/dist/blocks/datepicker/datepicker-modal.js +3 -2
  4. package/dist/blocks/datepicker/time-picker-modal.js +3 -2
  5. package/dist/blocks/layout/confirm-action-sheet.js +3 -2
  6. package/dist/blocks/layout/index.d.ts +1 -0
  7. package/dist/blocks/layout/index.js +1 -0
  8. package/dist/blocks/layout/loading-state.d.ts +8 -0
  9. package/dist/blocks/layout/loading-state.js +12 -0
  10. package/dist/blocks/layout/sheet.js +21 -2
  11. package/dist/blocks/navigation/drawer-menu.js +10 -55
  12. package/dist/blocks/navigation/header.d.ts +2 -0
  13. package/dist/blocks/navigation/header.js +1 -1
  14. package/dist/components/alert/alert.js +4 -1
  15. package/dist/components/alert-dialog/alert-dialog.js +3 -2
  16. package/dist/components/context-menu/context-menu.js +4 -3
  17. package/dist/components/dialog/dialog.js +4 -3
  18. package/dist/components/dropdown-menu/dropdown-menu.js +4 -3
  19. package/dist/components/hover-card/hover-card.js +5 -1
  20. package/dist/components/menubar/menubar.js +5 -4
  21. package/dist/components/popover/popover.js +5 -1
  22. package/dist/components/select/select.js +10 -1
  23. package/dist/components/text/text.d.ts +3 -0
  24. package/dist/components/text/text.js +2 -0
  25. package/dist/components/toggle/toggle.d.ts +1 -1
  26. package/dist/components/toggle/toggle.js +1 -1
  27. package/dist/components/tooltip/tooltip.js +3 -1
  28. package/dist/lib/overlay-host.d.ts +26 -0
  29. package/dist/lib/overlay-host.js +77 -0
  30. package/dist/lib/overlay-presence.d.ts +15 -0
  31. package/dist/lib/overlay-presence.js +55 -0
  32. package/dist/lib/recipes.d.ts +1 -1
  33. package/dist/lib/recipes.js +24 -5
  34. package/dist/metro.cjs +33 -0
  35. package/dist/metro.d.cts +21 -0
  36. package/package.json +6 -2
package/README.md CHANGED
@@ -38,23 +38,26 @@ Use `npm install` instead of `bun add` if you prefer npm.
38
38
 
39
39
  ### Setup Uniwind (required)
40
40
 
41
- 1. **metro.config.js** (wrap with withUniwindConfig):
41
+ 1. **metro.config.js** (wrap with `withWhileUIMetro`):
42
42
 
43
43
  ```js
44
44
  const { getDefaultConfig } = require('expo/metro-config');
45
- const { withUniwindConfig } = require('uniwind/metro');
45
+ const { withWhileUIMetro } = require('@thewhileloop/whileui/metro');
46
46
 
47
47
  /** @type {import('expo/metro-config').MetroConfig} */
48
48
  const config = getDefaultConfig(__dirname);
49
49
 
50
- module.exports = withUniwindConfig(config, {
50
+ module.exports = withWhileUIMetro(config, {
51
51
  cssEntryFile: './global.css',
52
52
  });
53
53
  ```
54
54
 
55
- > `withUniwindConfig(config, options)` takes the config first and returns the wrapped
56
- > config — it is not curried. It must wrap the outermost config object, and
57
- > `cssEntryFile` must be a relative path string.
55
+ > `withWhileUIMetro` calls Uniwind's `withUniwindConfig(config, options)` for you — same
56
+ > options, config first, `cssEntryFile` a relative path — and keeps `expo-asset`,
57
+ > `expo-font` and `@expo/vector-icons` on the real `react-native`. Without that, Uniwind's
58
+ > import rewrite leaves every icon glyph silently missing on iOS and Android while web
59
+ > renders fine. Pass `exemptFromUniwindRewrite: ['pkg']` for any other package that
60
+ > imports a non-component export from `react-native`. It must be the outermost wrapper.
58
61
 
59
62
  2. **global.css** at app root:
60
63
 
@@ -144,6 +147,12 @@ export default function App() {
144
147
 
145
148
  > **Note:** If you use Select, Popover, Tooltip, or HoverCard, add `<PortalHost />` at the root of your app (as the last child).
146
149
 
150
+ Inside a Dialog, AlertDialog, Sheet, ConfirmActionSheet, DrawerMenu, menu or picker, those
151
+ four render into that overlay's own layer automatically, so a Select in a sheet opens above
152
+ the sheet. Android back and Escape on web close the innermost open layer first. Pass
153
+ `portalHost` on their content only to target a `PortalHost` you placed yourself, such as
154
+ inside a navigation-presented modal screen that WhileUI does not host.
155
+
147
156
  ### Vite + React Native Web + WhileUI setup
148
157
 
149
158
  Use the WhileUI Vite compatibility helper when your app includes portal-based primitives.
@@ -237,11 +246,15 @@ function MyScreen() {
237
246
  - **Layout:** `Stack` (vertical), `Row` (horizontal) — both support `gap`, `align`, `justify`
238
247
  - **Auth callbacks:** Auth templates use objects: `onSubmit({ email, password })`, `onSubmit({ firstName, lastName, email, password })`, etc. Copy templates from `apps/showcase/templates/auth/`.
239
248
  - **PortalHost:** Add `<PortalHost />` at app root for Select, Popover, Tooltip, HoverCard.
249
+ Inside any WhileUI overlay they render into that overlay's layer without configuration.
240
250
  - **Motion:** Wrap the root in `<MotionProvider>`; use `useMotionPolicy()` for semantic
241
251
  durations, the responsive spring, and reduced-motion behavior.
242
- - **Uniwind:** `withUniwindConfig(config, options)` wraps metro config — config first, not
243
- curried. `global.css` at app root, imported in `App.tsx`. Installing from npm also needs
252
+ - **Metro:** `withWhileUIMetro(config, { cssEntryFile: './global.css' })` from
253
+ `@thewhileloop/whileui/metro` — wraps Uniwind and keeps icon fonts loading on device.
254
+ `global.css` at app root, imported in `App.tsx`. Installing from npm also needs
244
255
  `@source './node_modules/@thewhileloop/whileui/dist';` or every component renders unstyled.
256
+ - **States:** `LoadingState`, `EmptyState` and `ErrorState` share one shape; `ErrorState`
257
+ takes `onRetry`. `Text variant="eyebrow"` is the muted section label above a title.
245
258
  - **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/templates/` (auth, profile); flow patterns in README "Flow Patterns" section.
246
259
 
247
260
  ### Composition grammar
@@ -384,6 +397,7 @@ Copy from `apps/showcase/templates/auth/`:
384
397
  | **ErrorBoundary** | React ErrorBoundary that renders ErrorState by default |
385
398
  | **EmptyState** | Empty content placeholder |
386
399
  | **ErrorState** | Error display with retry |
400
+ | **LoadingState** | In-flow loading placeholder for a section still fetching |
387
401
  | **LoadingScreen** | Full-screen loading indicator |
388
402
  | **PullToRefreshScrollView** | Themed ScrollView with RefreshControl (useThemeTokens for colors) |
389
403
  | **KeyboardAwareScreen** | One screen-level keyboard policy for bottom-docked controls |
@@ -1046,10 +1060,10 @@ import { Button, ButtonText, ButtonIcon } from '@thewhileloop/whileui';
1046
1060
  classes are unnecessary for normal hierarchy. `heading` also sets
1047
1061
  `accessibilityRole="header"` unless the caller supplies another role.
1048
1062
 
1049
- | Prop | Type | Default |
1050
- | ------- | --------------------------------------------------------------------------------------------- | ----------- |
1051
- | variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'code'` | `'default'` |
1052
- | size | `'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | by variant |
1063
+ | Prop | Type | Default |
1064
+ | ------- | ---------------------------------------------------------------------------------------------------------- | ----------- |
1065
+ | variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'eyebrow' \| 'code'` | `'default'` |
1066
+ | size | `'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' \| '2xl' \| '3xl'` | by variant |
1053
1067
 
1054
1068
  ## Input
1055
1069
 
@@ -1394,9 +1408,12 @@ import {
1394
1408
 
1395
1409
  ### React Native: stacked modals
1396
1410
 
1397
- If `AlertDialog` is a **sibling** of another fullscreen `Modal`, iOS often fails to show a second `Modal` on top. Prefer **`presentation="overlay"`** and render `<AlertDialog>` **inside** that `Modal` (themed UI, one native modal host). Alternatively use **`presentation="native"`** for a system `Alert.alert` when themed UI is not required.
1398
-
1399
- **Manual QA (iOS + Android):** Fullscreen sheet + `presentation="overlay"` nested in the sheet: themed confirm appears, **Cancel** / **Delete** behave correctly. Optionally repeat with `presentation="native"` for system alert stacking.
1411
+ Render a confirmation **inside** the overlay it belongs to. An `AlertDialog` or `Dialog`
1412
+ nested in a `Sheet` opens above it, and Android back or Escape closes the confirmation
1413
+ before the sheet. If `AlertDialog` must be a **sibling** of another fullscreen `Modal`, iOS
1414
+ often fails to show a second `Modal` on top; use **`presentation="overlay"`** inside that
1415
+ `Modal`, or **`presentation="native"`** for a system `Alert.alert` when themed UI is not
1416
+ required.
1400
1417
 
1401
1418
  ## Checkbox
1402
1419
 
@@ -1472,6 +1489,11 @@ itself to the collision-available viewport, and wraps long option labels inside
1472
1489
  | blurIntensity | `number` | subtle preset |
1473
1490
  | blurTintToken | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | `'popover'` |
1474
1491
 
1492
+ `SelectContent` keeps its listbox inside the safe area by default; pass `insets` only to
1493
+ change that margin. Inside a Dialog, Sheet or other WhileUI overlay it renders in that
1494
+ overlay's layer automatically. Option values must be non-empty strings: the web
1495
+ primitive rejects `""`, so model "none" as an explicit value such as `'none'`.
1496
+
1475
1497
  ## Tabs
1476
1498
 
1477
1499
  ```tsx
@@ -2086,7 +2108,8 @@ keeps taps on inputs while the keyboard is open (`keyboardShouldPersistTaps="han
2086
2108
  Your `contentContainerStyle` is merged after the default padding. Tapping the backdrop
2087
2109
  closes the sheet; a drag across the backdrop does not, so a missed scroll never dismisses
2088
2110
  it. There is no drag-to-dismiss: close with the backdrop, Android back, Escape on web or
2089
- `SheetClose`.
2111
+ `SheetClose`. A Select, Popover or confirmation Dialog placed inside the sheet opens above
2112
+ it, and back or Escape closes that inner layer before the sheet.
2090
2113
 
2091
2114
  ## Header
2092
2115
 
@@ -2097,7 +2120,9 @@ import { Header, HeaderBackButton } from '@thewhileloop/whileui';
2097
2120
  title="Settings"
2098
2121
  subtitle="Manage preferences"
2099
2122
  leftAction={<HeaderBackButton onPress={() => {}} />}
2100
- rightActions={[{ key: 'search', icon: <Icon />, onPress: () => {} }]}
2123
+ rightActions={[
2124
+ { key: 'search', icon: <Icon />, onPress: () => {}, accessibilityLabel: 'Search' },
2125
+ ]}
2101
2126
  />;
2102
2127
  ```
2103
2128
 
@@ -2239,6 +2264,22 @@ import { EmptyState } from '@thewhileloop/whileui';
2239
2264
  />;
2240
2265
  ```
2241
2266
 
2267
+ ## LoadingState
2268
+
2269
+ ```tsx
2270
+ import { LoadingState } from '@thewhileloop/whileui';
2271
+
2272
+ <LoadingState label="Loading activity" />;
2273
+ ```
2274
+
2275
+ The in-flow counterpart of `EmptyState` and `ErrorState` for a section that is still
2276
+ fetching. The spinner is named for assistive technology by `label` (default "Loading").
2277
+
2278
+ | Prop | Type | Default |
2279
+ | ----- | --------------------------- | ----------- |
2280
+ | label | `string` | `'Loading'` |
2281
+ | size | `'sm' \| 'default' \| 'lg'` | `'default'` |
2282
+
2242
2283
  ## ProfileHeader
2243
2284
 
2244
2285
  Copy from `apps/showcase/templates/profile/profile-header.tsx`, then:
@@ -2438,6 +2479,9 @@ import { DrawerMenu } from '@thewhileloop/whileui';
2438
2479
  | `blurIntensity` | `number` | medium preset |
2439
2480
  | `blurTintToken` | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | `'surfaceTranslucent'` |
2440
2481
 
2482
+ Closes from the backdrop, an item, Android back or Escape on web. It is hosted in a modal
2483
+ on every platform, so keyboard focus stays inside it while it is open.
2484
+
2441
2485
  ## Roadmap
2442
2486
 
2443
2487
  Tracked work items for future releases.
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useState } from 'react';
3
- import { Modal, Pressable, View } from 'react-native';
3
+ import { Pressable, View } from 'react-native';
4
+ import { OverlayHost } from '../../lib/overlay-host';
4
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { useUniwind } from 'uniwind';
6
7
  import { Calendar } from 'react-native-calendars';
@@ -124,7 +125,7 @@ function DateRangePickerModalSession({ value, onValueChange, open, onOpenChange,
124
125
  setDraftStart(null);
125
126
  setDraftEnd(null);
126
127
  }, [onOpenChange]);
127
- return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: handleBackdropPress, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleBackdropPress, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
128
+ return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: handleBackdropPress, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleBackdropPress, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
128
129
  {
129
130
  borderTopLeftRadius: surfaceRadius(visual, 'xl'),
130
131
  borderTopRightRadius: surfaceRadius(visual, 'xl'),
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useState } from 'react';
3
- import { Modal, Pressable, View } from 'react-native';
3
+ import { Pressable, View } from 'react-native';
4
+ import { OverlayHost } from '../../lib/overlay-host';
4
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { useUniwind } from 'uniwind';
6
7
  import { Calendar } from 'react-native-calendars';
@@ -70,7 +71,7 @@ function DatePickerModalSession({ value, onValueChange, open, onOpenChange, minD
70
71
  setDraftDate(value ?? null);
71
72
  onOpenChange(true);
72
73
  }, [onOpenChange, value]);
73
- return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: handleDismiss, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleDismiss, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
74
+ return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: handleDismiss, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleDismiss, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
74
75
  {
75
76
  borderTopLeftRadius: surfaceRadius(visual, 'xl'),
76
77
  borderTopRightRadius: surfaceRadius(visual, 'xl'),
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
- import { Modal, Platform, Pressable, ScrollView, View, } from 'react-native';
3
+ import { Platform, Pressable, ScrollView, View, } from 'react-native';
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
5
  import { Text } from '../../components/text';
6
6
  import { Button, ButtonText } from '../../components/button';
7
7
  import { cn } from '../../lib/cn';
8
8
  import { focusRingInset } from '../../lib/focus-ring';
9
+ import { OverlayHost } from '../../lib/overlay-host';
9
10
  import { useThemeColors } from '../../lib/theme-colors';
10
11
  import { useVisualTokens } from '../../lib/visual-tokens';
11
12
  import { fieldRecipe, surfacePadding, surfaceRadius, typographyStyle } from '../../lib/recipes';
@@ -157,7 +158,7 @@ function TimePickerModalSession({ value, onValueChange, open, onOpenChange, minu
157
158
  }, [hour, minute, onOpenChange, onValueChange]);
158
159
  const handleDismiss = useCallback(() => onOpenChange(false), [onOpenChange]);
159
160
  const handleOpen = useCallback(() => onOpenChange(true), [onOpenChange]);
160
- return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: handleDismiss, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleDismiss, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
161
+ return (_jsxs(_Fragment, { children: [trigger ? (_jsx(Pressable, { accessibilityRole: "button", onPress: handleOpen, className: cn('w-full flex-row items-center border-border bg-muted', className), style: fieldRecipe(visual, 'default'), children: trigger })) : null, _jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: handleDismiss, children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, onPress: handleDismiss, children: _jsxs(Pressable, { accessible: false, className: cn('border border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background'), style: [
161
162
  {
162
163
  borderTopLeftRadius: surfaceRadius(visual, 'xl'),
163
164
  borderTopRightRadius: surfaceRadius(visual, 'xl'),
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from 'react';
3
- import { Modal, Pressable, View, } from 'react-native';
3
+ import { Pressable, View } from 'react-native';
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
5
  import { Text } from '../../components/text';
6
6
  import { cn } from '../../lib/cn';
7
+ import { OverlayHost } from '../../lib/overlay-host';
7
8
  import { useThemeColors } from '../../lib/theme-colors';
8
9
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
9
10
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -47,7 +48,7 @@ export function ConfirmActionSheet({ open, onOpenChange, title, description, act
47
48
  onOpenChange(false);
48
49
  }
49
50
  };
50
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "fade", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: handleClose, children: _jsxs(View, { className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, children: [_jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1", onPress: handleClose }), _jsxs(View, { className: cn('border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background', className), style: [
51
+ return (_jsx(OverlayHost, { open: open, animationType: "fade", onRequestClose: handleClose, children: _jsxs(View, { className: "flex-1 justify-end", style: { backgroundColor: colors.overlay }, children: [_jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1", onPress: handleClose }), _jsxs(View, { className: cn('border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-background', className), style: [
51
52
  {
52
53
  borderTopLeftRadius: surfaceRadius(visual, 'xl'),
53
54
  borderTopRightRadius: surfaceRadius(visual, 'xl'),
@@ -9,6 +9,7 @@ export { ScreenSkeleton, type ScreenSkeletonProps } from './screen-skeleton';
9
9
  export { ErrorBoundary, type ErrorBoundaryProps } from './error-boundary';
10
10
  export { EmptyState, type EmptyStateProps } from './empty-state';
11
11
  export { ErrorState, type ErrorStateProps } from './error-state';
12
+ export { LoadingState, type LoadingStateProps } from './loading-state';
12
13
  export { FormModalScreen, type FormModalScreenProps } from './form-modal-screen';
13
14
  export { LoadingScreen, type LoadingScreenProps } from './loading-screen';
14
15
  export { PullToRefreshScrollView, type PullToRefreshScrollViewProps, } from './pull-to-refresh-scroll-view';
@@ -9,6 +9,7 @@ export { ScreenSkeleton } from './screen-skeleton';
9
9
  export { ErrorBoundary } from './error-boundary';
10
10
  export { EmptyState } from './empty-state';
11
11
  export { ErrorState } from './error-state';
12
+ export { LoadingState } from './loading-state';
12
13
  export { FormModalScreen } from './form-modal-screen';
13
14
  export { LoadingScreen } from './loading-screen';
14
15
  export { PullToRefreshScrollView, } from './pull-to-refresh-scroll-view';
@@ -0,0 +1,8 @@
1
+ import { type ViewProps } from 'react-native';
2
+ export interface LoadingStateProps extends ViewProps {
3
+ /** Read by assistive technology as the spinner's name; shown under it when given. */
4
+ label?: string;
5
+ size?: 'sm' | 'default' | 'lg';
6
+ }
7
+ /** The in-flow sibling of `EmptyState` and `ErrorState`, for a section still fetching. */
8
+ export declare function LoadingState({ label, size, className, style, ...props }: LoadingStateProps): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { View } from 'react-native';
3
+ import { Spinner } from '../../components/spinner';
4
+ import { Text } from '../../components/text';
5
+ import { cn } from '../../lib/cn';
6
+ import { useVisualTokens } from '../../lib/visual-tokens';
7
+ import { surfacePadding } from '../../lib/recipes';
8
+ /** The in-flow sibling of `EmptyState` and `ErrorState`, for a section still fetching. */
9
+ export function LoadingState({ label, size = 'default', className, style, ...props }) {
10
+ const visual = useVisualTokens();
11
+ return (_jsxs(View, { className: cn('items-center justify-center', className), style: [{ paddingVertical: surfacePadding(visual, 'lg') }, style], ...props, children: [_jsx(Spinner, { size: size, accessibilityLabel: label ?? 'Loading' }), label ? (_jsx(Text, { variant: "caption", className: "mt-3 text-center", children: label })) : null] }));
12
+ }
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, useCallback, useContext, useMemo } from 'react';
3
- import { Modal, Platform, Pressable, ScrollView, useWindowDimensions, View, } from 'react-native';
3
+ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View, } from 'react-native';
4
+ import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
4
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { Text } from '../../components/text';
6
7
  import { cn } from '../../lib/cn';
7
8
  import { composeEventHandlers } from '../../lib/compose-event-handlers';
8
9
  import { useThemeColors } from '../../lib/theme-colors';
9
10
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
11
+ import { OverlayHost } from '../../lib/overlay-host';
12
+ import { useOverlayPresence } from '../../lib/overlay-presence';
10
13
  import { useTapOnly } from '../../lib/tap-only';
11
14
  import { useVisualTokens } from '../../lib/visual-tokens';
12
15
  import { surfacePadding, surfaceRadius, typographyStyle } from '../../lib/recipes';
@@ -16,6 +19,7 @@ export function Sheet({ open, onOpenChange, children, maxHeight = 'full', maxWid
16
19
  const insets = useSafeAreaInsets();
17
20
  const colors = useThemeColors();
18
21
  const visual = useVisualTokens();
22
+ const interaction = useInteractionTokens();
19
23
  const onClose = useCallback(() => onOpenChange(false), [onOpenChange]);
20
24
  const backdropPress = useTapOnly(onClose);
21
25
  const contextValue = useMemo(() => ({ onClose }), [onClose]);
@@ -34,6 +38,17 @@ export function Sheet({ open, onOpenChange, children, maxHeight = 'full', maxWid
34
38
  tintColor: colors.overlay,
35
39
  });
36
40
  const { height: screenHeight, width: windowWidth } = useWindowDimensions();
41
+ // The host Modal does not animate: the backdrop fades in place while the panel slides,
42
+ // so the dim layer never rises with the sheet.
43
+ const { mounted, progress } = useOverlayPresence({
44
+ visible: open,
45
+ openDuration: interaction.motionSlow,
46
+ closeDuration: interaction.motionNormal,
47
+ });
48
+ const backdropStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
49
+ const panelStyle = useAnimatedStyle(() => ({
50
+ transform: [{ translateY: interpolate(progress.value, [0, 1], [screenHeight, 0]) }],
51
+ }));
37
52
  const maxWidth = maxWidthProp ?? visual.sheetMaxWidth;
38
53
  const widthStyle = typeof maxWidth === 'number' && windowWidth >= visual.sheetTabletBreakpoint
39
54
  ? { maxWidth, alignSelf: 'center', width: maxWidth }
@@ -64,7 +79,11 @@ export function Sheet({ open, onOpenChange, children, maxHeight = 'full', maxWid
64
79
  widthStyle,
65
80
  frostedSurface.surfaceStyle,
66
81
  ];
67
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "slide", onRequestClose: onClose, statusBarTranslucent: Platform.OS === 'android', navigationBarTranslucent: Platform.OS === 'android', presentationStyle: "overFullScreen", children: _jsxs(View, { className: "flex-1 justify-end", style: { backgroundColor: frosted ? 'transparent' : colors.overlay }, children: [frostedBackdrop, _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1", ...backdropPress }), _jsxs(View, { className: cn('border-border overflow-hidden relative', frosted ? 'bg-transparent' : 'bg-background'), style: sheetStyle, children: [frostedSurface.overlay, _jsx(SheetContext.Provider, { value: contextValue, children: children })] })] }) }));
82
+ return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: onClose, children: _jsxs(View, { className: "flex-1 justify-end", children: [_jsx(Animated.View, { style: [
83
+ StyleSheet.absoluteFill,
84
+ { backgroundColor: frosted ? 'transparent' : colors.overlay },
85
+ backdropStyle,
86
+ ], children: frostedBackdrop }), _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1", ...backdropPress }), _jsxs(Animated.View, { className: cn('border-border overflow-hidden relative', frosted ? 'bg-transparent' : 'bg-background'), style: [sheetStyle, panelStyle], children: [frostedSurface.overlay, _jsx(SheetContext.Provider, { value: contextValue, children: children })] })] }) }));
68
87
  }
69
88
  export function SheetHeader({ title, description, className, children, style, ...props }) {
70
89
  const { onClose } = useContext(SheetContext);
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { View, Pressable, Platform, Modal, I18nManager, SectionList, useWindowDimensions, StyleSheet, } from 'react-native';
4
- import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing, interpolate, } from 'react-native-reanimated';
3
+ import { View, Pressable, Platform, I18nManager, SectionList, useWindowDimensions, StyleSheet, } from 'react-native';
4
+ import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated';
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import { Text } from '../../components/text';
7
7
  import { cn } from '../../lib/cn';
@@ -9,7 +9,8 @@ import { useThemeColors } from '../../lib/theme-colors';
9
9
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
10
10
  import { useVisualTokens } from '../../lib/visual-tokens';
11
11
  import { shadowStyle, surfaceRadius } from '../../lib/recipes';
12
- import { useMotionPolicy } from '../../lib/motion-policy';
12
+ import { OverlayHost } from '../../lib/overlay-host';
13
+ import { useOverlayPresence } from '../../lib/overlay-presence';
13
14
  import { useTapOnly } from '../../lib/tap-only';
14
15
  import { useFrostedSurface, useFrostedBackdrop, } from '../../lib/frosted-surface';
15
16
  function DrawerItemRow({ item, active, onSelect, interaction, visual }) {
@@ -34,54 +35,12 @@ function DrawerSectionList({ sections, activeKey, onSelect, interaction, visual,
34
35
  const renderSectionHeader = React.useCallback(({ section }) => section.title ? (_jsx(Text, { accessibilityRole: "header", className: "font-medium text-muted-foreground uppercase tracking-wide px-3 mb-2", style: { fontSize: visual.navSectionTitleFontSize }, children: section.title })) : null, [visual.navSectionTitleFontSize]);
35
36
  return (_jsx(SectionList, { sections: data, keyExtractor: (item) => item.key, className: "flex-1 px-3", keyboardShouldPersistTaps: "handled", showsVerticalScrollIndicator: false, renderSectionHeader: renderSectionHeader, renderSectionFooter: DrawerSectionGap, ItemSeparatorComponent: DrawerItemGap, renderItem: renderItem }));
36
37
  }
37
- function useDrawerAnimation({ visible, drawerWidth, interaction, motion }) {
38
- const progress = useSharedValue(0);
39
- const [mounted, setMounted] = React.useState(visible);
40
- const closeTimerRef = React.useRef(null);
41
- const [prevVisible, setPrevVisible] = React.useState(visible);
42
- // Open in the prop-change render so the first animation frame has a mounted panel;
43
- // closing stays mounted on the timer until the exit animation finishes.
44
- if (prevVisible !== visible) {
45
- setPrevVisible(visible);
46
- if (visible)
47
- setMounted(true);
48
- }
49
- React.useEffect(() => {
50
- if (!mounted)
51
- return;
52
- if (closeTimerRef.current) {
53
- clearTimeout(closeTimerRef.current);
54
- closeTimerRef.current = null;
55
- }
56
- const duration = motion.reduceMotion || !motion.animationsEnabled
57
- ? 0
58
- : visible
59
- ? interaction.drawerOpenDuration
60
- : interaction.drawerCloseDuration;
61
- progress.value = withTiming(visible ? 1 : 0, {
62
- duration,
63
- reduceMotion: motion.reanimatedReduceMotion,
64
- easing: visible ? Easing.bezier(0.2, 0.8, 0.2, 1) : Easing.bezier(0.4, 0, 1, 1),
65
- });
66
- if (!visible) {
67
- closeTimerRef.current = setTimeout(() => setMounted(false), duration);
68
- }
69
- return () => {
70
- if (closeTimerRef.current) {
71
- clearTimeout(closeTimerRef.current);
72
- closeTimerRef.current = null;
73
- }
74
- };
75
- }, [
76
- mounted,
38
+ function useDrawerAnimation({ visible, drawerWidth, interaction }) {
39
+ const { mounted, progress } = useOverlayPresence({
77
40
  visible,
78
- progress,
79
- interaction.drawerOpenDuration,
80
- interaction.drawerCloseDuration,
81
- motion.animationsEnabled,
82
- motion.reduceMotion,
83
- motion.reanimatedReduceMotion,
84
- ]);
41
+ openDuration: interaction.drawerOpenDuration,
42
+ closeDuration: interaction.drawerCloseDuration,
43
+ });
85
44
  const backdropStyle = useAnimatedStyle(() => ({
86
45
  opacity: progress.value,
87
46
  pointerEvents: progress.value > 0 ? 'auto' : 'none',
@@ -119,7 +78,6 @@ export function DrawerMenu({ visible, onClose, sections, activeKey, onSelect, he
119
78
  const colors = useThemeColors();
120
79
  const interaction = useInteractionTokens();
121
80
  const visual = useVisualTokens();
122
- const motion = useMotionPolicy();
123
81
  const backdropPress = useTapOnly(onClose);
124
82
  const frostedSurface = useFrostedSurface({
125
83
  frosted,
@@ -140,7 +98,6 @@ export function DrawerMenu({ visible, onClose, sections, activeKey, onSelect, he
140
98
  visible,
141
99
  drawerWidth: layout.width,
142
100
  interaction,
143
- motion,
144
101
  });
145
102
  const handleItemPress = React.useCallback((key) => {
146
103
  onSelect?.(key);
@@ -168,7 +125,5 @@ export function DrawerMenu({ visible, onClose, sections, activeKey, onSelect, he
168
125
  frostedSurface.surfaceStyle,
169
126
  style,
170
127
  ], className: drawerPanelClassName(frosted, className), children: [frostedSurface.overlay, _jsxs(View, { className: "flex-1 pb-8", style: { paddingTop: contentTopPadding }, children: [header && _jsx(View, { className: "px-5 pb-4 mb-2 border-b border-border", children: header }), _jsx(DrawerSectionList, { sections: sections, activeKey: activeKey, onSelect: handleItemPress, interaction: interaction, visual: visual }), footer && _jsx(View, { className: "px-5 py-4 mt-auto border-t border-border", children: footer })] })] })] }));
171
- if (Platform.OS !== 'android')
172
- return drawerTree;
173
- return (_jsx(Modal, { visible: mounted, transparent: true, animationType: "none", onRequestClose: onClose, statusBarTranslucent: true, navigationBarTranslucent: true, presentationStyle: "overFullScreen", children: drawerTree }));
128
+ return (_jsx(OverlayHost, { open: mounted, animationType: "none", onRequestClose: onClose, children: drawerTree }));
174
129
  }
@@ -4,6 +4,8 @@ export interface HeaderAction {
4
4
  key: string;
5
5
  icon: React.ReactNode;
6
6
  onPress: () => void;
7
+ /** Required in practice: an icon-only button has no other accessible name. */
8
+ accessibilityLabel?: string;
7
9
  }
8
10
  export interface HeaderProps extends ViewProps {
9
11
  title?: string;
@@ -14,7 +14,7 @@ export function Header({ title, subtitle, leftAction, rightActions, centerConten
14
14
  paddingVertical: visual.controlPaddingYDefault,
15
15
  },
16
16
  style,
17
- ], ...props, children: [_jsx(View, { className: "min-w-12", children: leftAction }), _jsx(View, { className: "flex-1 items-center", children: centerContent || (_jsxs(View, { className: "items-center", children: [title && (_jsx(Text, { className: "font-semibold text-foreground", style: typographyStyle(visual, 'body'), numberOfLines: 1, children: title })), subtitle && (_jsx(Text, { className: "text-muted-foreground", style: typographyStyle(visual, 'caption'), numberOfLines: 1, children: subtitle }))] })) }), _jsx(View, { className: "min-w-12 flex-row items-center justify-end gap-2", children: rightActions?.map((action) => (_jsx(Pressable, { accessibilityRole: "button", onPress: action.onPress, className: "rounded-full p-2 active:bg-accent", style: withInteractivePressableStyle(undefined, interaction, {
17
+ ], ...props, children: [_jsx(View, { className: "min-w-12", children: leftAction }), _jsx(View, { className: "flex-1 items-center", children: centerContent || (_jsxs(View, { className: "items-center", children: [title && (_jsx(Text, { className: "font-semibold text-foreground", style: typographyStyle(visual, 'body'), numberOfLines: 1, children: title })), subtitle && (_jsx(Text, { className: "text-muted-foreground", style: typographyStyle(visual, 'caption'), numberOfLines: 1, children: subtitle }))] })) }), _jsx(View, { className: "min-w-12 flex-row items-center justify-end gap-2", children: rightActions?.map((action) => (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.accessibilityLabel, onPress: action.onPress, className: "rounded-full p-2 active:bg-state-pressed", style: withInteractivePressableStyle(undefined, interaction, {
18
18
  pressedVariant: 'default',
19
19
  }), children: action.icon }, action.key))) })] }));
20
20
  }
@@ -22,7 +22,10 @@ const alertVariants = tv({
22
22
  });
23
23
  const Alert = React.forwardRef(({ className, variant, style, ...props }, ref) => {
24
24
  const visual = useVisualTokens();
25
- return (_jsx(View, { ref: ref, className: cn(alertVariants({ variant }), className), style: [
25
+ return (_jsx(View, { ref: ref,
26
+ // A status message that appears is announced without focus moving to it. Set
27
+ // before the spread so a decorative note can opt out.
28
+ accessibilityRole: "alert", accessibilityLiveRegion: "polite", className: cn(alertVariants({ variant }), className), style: [
26
29
  {
27
30
  borderRadius: surfaceRadius(visual, 'lg'),
28
31
  borderWidth: visual.borderWidthHairline,
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from 'react';
3
- import { View, Modal, Pressable, Text, Alert, Platform, StyleSheet, } from 'react-native';
3
+ import { View, Pressable, Text, Alert, Platform, StyleSheet, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { composeEventHandlers } from '../../lib/compose-event-handlers';
6
6
  import { useInteractionTokens, withInteractivePressableStyle } from '../../lib/interaction-tokens';
7
7
  import { useThemeColors } from '../../lib/theme-colors';
8
8
  import { useFrostedSurface } from '../../lib/frosted-surface';
9
+ import { OverlayHost } from '../../lib/overlay-host';
9
10
  import { useVisualTokens } from '../../lib/visual-tokens';
10
11
  import { shadowStyle, typographyStyle } from '../../lib/recipes';
11
12
  function emptyNativeCopy() {
@@ -231,7 +232,7 @@ function AlertDialogContent({ className, children, presentation = 'modal', frost
231
232
  }
232
233
  return (_jsx(View, { pointerEvents: "box-none", style: [StyleSheet.absoluteFill, { zIndex: 100 }], children: themedBody }));
233
234
  }
234
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "fade", presentationStyle: Platform.OS === 'ios' ? 'overFullScreen' : undefined, statusBarTranslucent: Platform.OS === 'android', navigationBarTranslucent: Platform.OS === 'android', onRequestClose: () => setOpen(false), children: themedBody }));
235
+ return (_jsx(OverlayHost, { open: open, animationType: "fade", onRequestClose: () => setOpen(false), children: themedBody }));
235
236
  }
236
237
  function useAlertDialogNativeCopy() {
237
238
  return useContext(AlertDialogContext).nativeCopyRef;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, useContext, useMemo, useState } from 'react';
3
- import { View, Modal, Pressable, Text } from 'react-native';
3
+ import { View, Pressable, Text } from 'react-native';
4
+ import { OverlayHost } from '../../lib/overlay-host';
4
5
  import { cn } from '../../lib/cn';
5
6
  import { useFrostedSurface } from '../../lib/frosted-surface';
6
7
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -53,7 +54,7 @@ function ContextMenuContent({ className, children, frosted = false, blurIntensit
53
54
  frostedSurface.surfaceStyle,
54
55
  style,
55
56
  ];
56
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false, accessibilityRole: "menu", className: cn('w-full border-border p-2 pb-8 relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', className), style: contentStyle, onPress: (e) => e.stopPropagation(), ...props, children: [frostedSurface.overlay, children] }) }) }));
57
+ return (_jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false, accessibilityRole: "menu", className: cn('w-full border-border p-2 pb-8 relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', className), style: contentStyle, onPress: (e) => e.stopPropagation(), ...props, children: [frostedSurface.overlay, children] }) }) }));
57
58
  }
58
59
  function ContextMenuItem({ className, style, ...props }) {
59
60
  const { setOpen } = useContext(ContextMenuContext);
@@ -64,7 +65,7 @@ function ContextMenuItem({ className, style, ...props }) {
64
65
  paddingHorizontal: visual.controlPaddingXSm,
65
66
  paddingVertical: visual.controlPaddingYSm,
66
67
  };
67
- return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-accent', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
68
+ return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-state-pressed', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
68
69
  props.onPress?.(e);
69
70
  setOpen(false);
70
71
  }, ...props }));
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
3
- import { View, Modal, Pressable, Text, } from 'react-native';
3
+ import { View, Pressable, Text, } from 'react-native';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { composeEventHandlers } from '../../lib/compose-event-handlers';
6
6
  import { useThemeColors } from '../../lib/theme-colors';
7
7
  import { useFrostedSurface } from '../../lib/frosted-surface';
8
+ import { OverlayHost } from '../../lib/overlay-host';
8
9
  import { useVisualTokens } from '../../lib/visual-tokens';
9
10
  import { shadowStyle, typographyStyle } from '../../lib/recipes';
10
11
  const DialogContext = createContext({
@@ -47,7 +48,7 @@ function DialogContent({ className, children, frosted = false, blurIntensity, bl
47
48
  defaultBlurPreset: 'medium',
48
49
  });
49
50
  const contentStyle = [frostedSurface.surfaceStyle, style];
50
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "fade", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: () => setOpen(false), children: _jsx(Pressable
51
+ return (_jsx(OverlayHost, { open: open, animationType: "fade", onRequestClose: () => setOpen(false), children: _jsx(Pressable
51
52
  // Tap-to-dismiss is a pointer affordance. Left accessible it would swallow the
52
53
  // whole panel into one element; screen reader users close from inside.
53
54
  , {
@@ -78,7 +79,7 @@ function DialogFooter({ className, ...props }) {
78
79
  }
79
80
  function DialogTitle({ className, style, ...props }) {
80
81
  const visual = useVisualTokens();
81
- return (_jsx(Text, { className: cn('font-semibold text-foreground', className), style: [typographyStyle(visual, 'title'), style], ...props }));
82
+ return (_jsx(Text, { accessibilityRole: "header", className: cn('font-semibold text-foreground', className), style: [typographyStyle(visual, 'title'), style], ...props }));
82
83
  }
83
84
  function DialogDescription({ className, style, ...props }) {
84
85
  const visual = useVisualTokens();
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, useContext, useMemo, useState } from 'react';
3
- import { View, Modal, Pressable, Text } from 'react-native';
3
+ import { View, Pressable, Text } from 'react-native';
4
+ import { OverlayHost } from '../../lib/overlay-host';
4
5
  import { cn } from '../../lib/cn';
5
6
  import { useFrostedSurface } from '../../lib/frosted-surface';
6
7
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -52,7 +53,7 @@ function DropdownMenuContent({ className, children, frosted = false, blurIntensi
52
53
  frostedSurface.surfaceStyle,
53
54
  style,
54
55
  ];
55
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false,
56
+ return (_jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false,
56
57
  // Names the container for the `menuitem` children below; without a `menu`
57
58
  // ancestor the role is invalid ARIA and assistive tech ignores it.
58
59
  accessibilityRole: "menu", className: cn('w-full border-border p-2 pb-8 relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', className), style: contentStyle, onPress: (e) => e.stopPropagation(), ...props, children: [frostedSurface.overlay, children] }) }) }));
@@ -66,7 +67,7 @@ function DropdownMenuItem({ className, style, ...props }) {
66
67
  paddingHorizontal: visual.controlPaddingXSm,
67
68
  paddingVertical: visual.controlPaddingYSm,
68
69
  };
69
- return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-accent', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
70
+ return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-state-pressed', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
70
71
  props.onPress?.(e);
71
72
  setOpen(false);
72
73
  }, ...props }));
@@ -4,6 +4,7 @@ import { Platform, StyleSheet } from 'react-native';
4
4
  import * as HoverCardPrimitive from '@rn-primitives/hover-card';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { useFrostedSurface } from '../../lib/frosted-surface';
7
+ import { useNestedOverlay, useOverlayPortalTarget } from '../../lib/overlay-host';
7
8
  import { useVisualTokens } from '../../lib/visual-tokens';
8
9
  import { useThemeColors } from '../../lib/theme-colors';
9
10
  import { shadowStyle, surfacePadding, surfaceRadius } from '../../lib/recipes';
@@ -22,6 +23,9 @@ const HoverCardContent = React.forwardRef(({ className, align = 'center', sideOf
22
23
  });
23
24
  const visual = useVisualTokens();
24
25
  const colors = useThemeColors();
26
+ const { open, onOpenChange } = HoverCardPrimitive.useRootContext();
27
+ const portalTarget = useOverlayPortalTarget(portalHost);
28
+ useNestedOverlay(open, () => onOpenChange(false));
25
29
  const resolvedStyle = StyleSheet.flatten([
26
30
  Platform.OS === 'web' ? undefined : contentStyles,
27
31
  {
@@ -33,7 +37,7 @@ const HoverCardContent = React.forwardRef(({ className, align = 'center', sideOf
33
37
  frostedSurface.surfaceStyle,
34
38
  style,
35
39
  ]);
36
- return (_jsx(HoverCardPrimitive.Portal, { hostName: portalHost, children: _jsx(HoverCardPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsxs(HoverCardPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn('z-50 w-64 border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: [frostedSurface.overlay, children] }) }) }));
40
+ return (_jsx(HoverCardPrimitive.Portal, { ...portalTarget, children: _jsx(HoverCardPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsxs(HoverCardPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn('z-50 w-64 border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: [frostedSurface.overlay, children] }) }) }));
37
41
  });
38
42
  HoverCardContent.displayName = 'HoverCardContent';
39
43
  const contentStyles = {
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useMemo, useState } from 'react';
3
- import { View, Modal, Pressable, Text } from 'react-native';
3
+ import { View, Pressable, Text } from 'react-native';
4
+ import { OverlayHost } from '../../lib/overlay-host';
4
5
  import { cn } from '../../lib/cn';
5
6
  import { useFrostedSurface } from '../../lib/frosted-surface';
6
7
  import { useVisualTokens } from '../../lib/visual-tokens';
@@ -23,7 +24,7 @@ function MenubarTrigger({ className, style, ...props }) {
23
24
  const { setOpen } = useContext(MenubarMenuContext);
24
25
  const visual = useVisualTokens();
25
26
  const tokenStyle = controlRecipe(visual, 'sm');
26
- return (_jsx(Pressable, { accessibilityRole: "button", className: cn('active:bg-accent', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: () => setOpen(true), ...props }));
27
+ return (_jsx(Pressable, { accessibilityRole: "button", className: cn('active:bg-state-pressed', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: () => setOpen(true), ...props }));
27
28
  }
28
29
  function MenubarContent({ className, children, frosted = false, blurIntensity, blurTintToken, style, ...props }) {
29
30
  const { open, setOpen } = useContext(MenubarMenuContext);
@@ -48,7 +49,7 @@ function MenubarContent({ className, children, frosted = false, blurIntensity, b
48
49
  frostedSurface.surfaceStyle,
49
50
  style,
50
51
  ];
51
- return (_jsx(Modal, { visible: open, transparent: true, animationType: "slide", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false, accessibilityRole: "menu", className: cn('w-full border-border p-2 pb-8 relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', className), style: contentStyle, onPress: (e) => e.stopPropagation(), ...props, children: [frostedSurface.overlay, children] }) }) }));
52
+ return (_jsx(OverlayHost, { open: open, animationType: "slide", onRequestClose: () => setOpen(false), children: _jsx(Pressable, { accessible: false, importantForAccessibility: "no", className: "flex-1 justify-end", onPress: () => setOpen(false), children: _jsxs(Pressable, { accessible: false, accessibilityRole: "menu", className: cn('w-full border-border p-2 pb-8 relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', className), style: contentStyle, onPress: (e) => e.stopPropagation(), ...props, children: [frostedSurface.overlay, children] }) }) }));
52
53
  }
53
54
  function MenubarItem({ className, style, ...props }) {
54
55
  const { setOpen } = useContext(MenubarMenuContext);
@@ -59,7 +60,7 @@ function MenubarItem({ className, style, ...props }) {
59
60
  paddingHorizontal: visual.controlPaddingXSm,
60
61
  paddingVertical: visual.controlPaddingYSm,
61
62
  };
62
- return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-accent', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
63
+ return (_jsx(Pressable, { accessibilityRole: "menuitem", className: cn('flex flex-row items-center active:bg-state-pressed', className), style: (state) => [tokenStyle, typeof style === 'function' ? style(state) : style], onPress: (e) => {
63
64
  props.onPress?.(e);
64
65
  setOpen(false);
65
66
  }, ...props }));
@@ -4,6 +4,7 @@ import { Platform, StyleSheet } from 'react-native';
4
4
  import * as PopoverPrimitive from '@rn-primitives/popover';
5
5
  import { cn } from '../../lib/cn';
6
6
  import { useFrostedSurface } from '../../lib/frosted-surface';
7
+ import { useNestedOverlay, useOverlayPortalTarget } from '../../lib/overlay-host';
7
8
  import { useOverlayInsets } from '../../lib/overlay-insets';
8
9
  import { useVisualTokens } from '../../lib/visual-tokens';
9
10
  import { useThemeColors } from '../../lib/theme-colors';
@@ -23,6 +24,9 @@ const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffs
23
24
  });
24
25
  const visual = useVisualTokens();
25
26
  const colors = useThemeColors();
27
+ const { open, onOpenChange } = PopoverPrimitive.useRootContext();
28
+ const portalTarget = useOverlayPortalTarget(portalHost);
29
+ useNestedOverlay(open, () => onOpenChange(false));
26
30
  // Keeps the panel off the screen edge and out from under the notch.
27
31
  const defaultInsets = useOverlayInsets();
28
32
  const resolvedInsets = { ...defaultInsets, ...insets };
@@ -41,7 +45,7 @@ const PopoverContent = React.forwardRef(({ className, align = 'center', sideOffs
41
45
  frostedSurface.surfaceStyle,
42
46
  style,
43
47
  ]);
44
- return (_jsx(PopoverPrimitive.Portal, { hostName: portalHost, children: _jsx(PopoverPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsxs(PopoverPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, insets: resolvedInsets, className: cn('z-50 w-72 border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: [frostedSurface.overlay, children] }) }) }));
48
+ return (_jsx(PopoverPrimitive.Portal, { ...portalTarget, children: _jsx(PopoverPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsxs(PopoverPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, insets: resolvedInsets, className: cn('z-50 w-72 border-border relative overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: [frostedSurface.overlay, children] }) }) }));
45
49
  });
46
50
  PopoverContent.displayName = 'PopoverContent';
47
51
  const PopoverClose = React.forwardRef(({ className, ...props }, ref) => (_jsx(PopoverPrimitive.Close, { ref: ref, className: cn(className), ...props })));
@@ -8,6 +8,8 @@ import { cn } from '../../lib/cn';
8
8
  import { focusRing } from '../../lib/focus-ring';
9
9
  import { useInteractionTokens } from '../../lib/interaction-tokens';
10
10
  import { useFrostedSurface } from '../../lib/frosted-surface';
11
+ import { useNestedOverlay, useOverlayPortalTarget } from '../../lib/overlay-host';
12
+ import { useOverlayInsets } from '../../lib/overlay-insets';
11
13
  import { useVisualTokens } from '../../lib/visual-tokens';
12
14
  import { useThemeColors } from '../../lib/theme-colors';
13
15
  import { fieldRecipe, shadowStyle, surfaceRadius, typographyStyle } from '../../lib/recipes';
@@ -59,6 +61,13 @@ const SelectContent = React.forwardRef(({ className, children, portalHost, posit
59
61
  });
60
62
  const visual = useVisualTokens();
61
63
  const colors = useThemeColors();
64
+ const { open, onOpenChange } = SelectPrimitive.useRootContext();
65
+ const portalTarget = useOverlayPortalTarget(portalHost);
66
+ useNestedOverlay(open, () => onOpenChange(false));
67
+ // Keeps the listbox off the screen edge and clear of the notch and home indicator
68
+ // without every caller reading the safe area itself.
69
+ const defaultInsets = useOverlayInsets();
70
+ const resolvedInsets = { ...defaultInsets, ...insets };
62
71
  // Radix applies its collision variables to the listbox wrapper, while
63
72
  // @rn-primitives renders our visual surface one level inside it. Keep the sizing
64
73
  // on that surface as inline web CSS: the Viewport's own `width: 100%` otherwise
@@ -71,7 +80,7 @@ const SelectContent = React.forwardRef(({ className, children, portalHost, posit
71
80
  maxHeight: 'var(--radix-select-content-available-height)',
72
81
  }
73
82
  : undefined;
74
- return (_jsx(SelectPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsxs(SelectPrimitive.Content, { ref: ref, position: position, insets: insets, style: {
83
+ return (_jsx(SelectPrimitive.Portal, { ...portalTarget, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsxs(SelectPrimitive.Content, { ref: ref, position: position, insets: resolvedInsets, style: {
75
84
  ...webSizingStyle,
76
85
  ...StyleSheet.flatten([
77
86
  {
@@ -10,6 +10,7 @@ declare const textVariants: import("tailwind-variants").TVReturnType<{
10
10
  body: "font-normal";
11
11
  caption: "text-muted-foreground font-normal";
12
12
  label: "font-medium leading-tight";
13
+ eyebrow: "text-muted-foreground font-medium";
13
14
  code: "font-mono bg-muted rounded px-1 py-0.5";
14
15
  };
15
16
  size: {
@@ -30,6 +31,7 @@ declare const textVariants: import("tailwind-variants").TVReturnType<{
30
31
  body: "font-normal";
31
32
  caption: "text-muted-foreground font-normal";
32
33
  label: "font-medium leading-tight";
34
+ eyebrow: "text-muted-foreground font-medium";
33
35
  code: "font-mono bg-muted rounded px-1 py-0.5";
34
36
  };
35
37
  size: {
@@ -50,6 +52,7 @@ declare const textVariants: import("tailwind-variants").TVReturnType<{
50
52
  body: "font-normal";
51
53
  caption: "text-muted-foreground font-normal";
52
54
  label: "font-medium leading-tight";
55
+ eyebrow: "text-muted-foreground font-medium";
53
56
  code: "font-mono bg-muted rounded px-1 py-0.5";
54
57
  };
55
58
  size: {
@@ -16,6 +16,7 @@ const textVariants = tv({
16
16
  body: 'font-normal',
17
17
  caption: 'text-muted-foreground font-normal',
18
18
  label: 'font-medium leading-tight',
19
+ eyebrow: 'text-muted-foreground font-medium',
19
20
  code: 'font-mono bg-muted rounded px-1 py-0.5',
20
21
  },
21
22
  size: {
@@ -40,6 +41,7 @@ const VARIANT_ROLES = {
40
41
  body: 'body',
41
42
  caption: 'caption',
42
43
  label: 'label',
44
+ eyebrow: 'label',
43
45
  code: 'label',
44
46
  };
45
47
  const SIZE_ROLES = {
@@ -15,7 +15,7 @@ declare const toggleVariants: import("tailwind-variants").TVReturnType<{
15
15
  true: "bg-accent";
16
16
  false: "";
17
17
  };
18
- }, undefined, "inline-flex items-center justify-center active:bg-accent", {
18
+ }, undefined, "inline-flex items-center justify-center active:bg-state-pressed", {
19
19
  variant: {
20
20
  default: "bg-transparent";
21
21
  outline: "border border-input bg-transparent";
@@ -12,7 +12,7 @@ const ToggleContext = createContext({
12
12
  size: 'default',
13
13
  });
14
14
  const toggleVariants = tv({
15
- base: 'inline-flex items-center justify-center active:bg-accent',
15
+ base: 'inline-flex items-center justify-center active:bg-state-pressed',
16
16
  variants: {
17
17
  variant: {
18
18
  default: 'bg-transparent',
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import { Platform, StyleSheet, Text } from 'react-native';
4
4
  import * as TooltipPrimitive from '@rn-primitives/tooltip';
5
5
  import { cn } from '../../lib/cn';
6
+ import { useOverlayPortalTarget } from '../../lib/overlay-host';
6
7
  import { useVisualTokens } from '../../lib/visual-tokens';
7
8
  import { useThemeColors } from '../../lib/theme-colors';
8
9
  import { shadowStyle, surfaceRadius, typographyStyle } from '../../lib/recipes';
@@ -14,6 +15,7 @@ TooltipTrigger.displayName = 'TooltipTrigger';
14
15
  const TooltipContent = React.forwardRef(({ className, sideOffset = 4, portalHost, children, style, ...props }, ref) => {
15
16
  const visual = useVisualTokens();
16
17
  const colors = useThemeColors();
18
+ const portalTarget = useOverlayPortalTarget(portalHost);
17
19
  const resolvedStyle = StyleSheet.flatten([
18
20
  Platform.OS === 'web' ? undefined : contentStyles,
19
21
  {
@@ -26,7 +28,7 @@ const TooltipContent = React.forwardRef(({ className, sideOffset = 4, portalHost
26
28
  shadowStyle(visual, colors, 'md'),
27
29
  style,
28
30
  ]);
29
- return (_jsx(TooltipPrimitive.Portal, { hostName: portalHost, children: _jsx(TooltipPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn('z-50 overflow-hidden bg-primary', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: typeof children === 'string' ? (_jsx(Text, { className: "text-primary-foreground", style: typographyStyle(visual, 'caption'), children: children })) : (children) }) }) }));
31
+ return (_jsx(TooltipPrimitive.Portal, { ...portalTarget, children: _jsx(TooltipPrimitive.Overlay, { style: Platform.OS !== 'web' ? StyleSheet.absoluteFill : undefined, children: _jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn('z-50 overflow-hidden bg-primary', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', className), style: resolvedStyle, ...props, children: typeof children === 'string' ? (_jsx(Text, { className: "text-primary-foreground", style: typographyStyle(visual, 'caption'), children: children })) : (children) }) }) }));
30
32
  });
31
33
  TooltipContent.displayName = 'TooltipContent';
32
34
  const contentStyles = {
@@ -0,0 +1,26 @@
1
+ import React from 'react';
2
+ import { type ModalProps } from 'react-native';
3
+ import type * as PopoverPrimitive from '@rn-primitives/popover';
4
+ type PortalContainer = React.ComponentProps<typeof PopoverPrimitive.Portal>['container'];
5
+ export interface OverlayHostProps extends Pick<ModalProps, 'animationType' | 'onShow' | 'onDismiss'> {
6
+ open: boolean;
7
+ /** Android back and web Escape. Omit for a host that only closes from inside. */
8
+ onRequestClose?: () => void;
9
+ children: React.ReactNode;
10
+ }
11
+ /**
12
+ * The platform `Modal` every WhileUI overlay is hosted in. Each Modal is its own window,
13
+ * so the platform already routes Android back and web Escape to the topmost one and, on
14
+ * web, traps and restores focus. The host adds what a root-level portal cannot reach:
15
+ * nested Select, Popover, Tooltip and HoverCard content renders inside this window, and a
16
+ * dismiss request closes the topmost nested overlay before the host itself.
17
+ */
18
+ export declare function OverlayHost({ open, onRequestClose, children, animationType, onShow, onDismiss, }: OverlayHostProps): React.JSX.Element;
19
+ /** Portal props for content rendered inside the nearest host. An explicit `portalHost` wins. */
20
+ export declare function useOverlayPortalTarget(portalHost?: string): {
21
+ hostName?: string;
22
+ container?: PortalContainer;
23
+ };
24
+ /** Lets the nearest host close this overlay before itself while `open` is true. */
25
+ export declare function useNestedOverlay(open: boolean, close: () => void): void;
26
+ export {};
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
3
+ import { Modal, Platform, View } from 'react-native';
4
+ import { PortalHost } from '@rn-primitives/portal';
5
+ const OverlayHostContext = createContext(null);
6
+ // Radix closes a nested overlay on the Escape keydown; React Native Web closes the Modal
7
+ // on the keyup of the same press. Seeing the keydown first lets the host drop that keyup.
8
+ const escapeKeyTarget = Platform.OS === 'web' ? globalThis.document : undefined;
9
+ /**
10
+ * The platform `Modal` every WhileUI overlay is hosted in. Each Modal is its own window,
11
+ * so the platform already routes Android back and web Escape to the topmost one and, on
12
+ * web, traps and restores focus. The host adds what a root-level portal cannot reach:
13
+ * nested Select, Popover, Tooltip and HoverCard content renders inside this window, and a
14
+ * dismiss request closes the topmost nested overlay before the host itself.
15
+ */
16
+ export function OverlayHost({ open, onRequestClose, children, animationType, onShow, onDismiss, }) {
17
+ const hostName = useId();
18
+ const nested = useRef([]);
19
+ const consumedByNested = useRef(false);
20
+ const [container, setContainer] = useState(undefined);
21
+ const register = useCallback((overlay) => {
22
+ nested.current = [...nested.current, overlay];
23
+ return () => {
24
+ nested.current = nested.current.filter((entry) => entry !== overlay);
25
+ };
26
+ }, []);
27
+ const requestClose = useCallback(() => {
28
+ if (consumedByNested.current) {
29
+ consumedByNested.current = false;
30
+ return;
31
+ }
32
+ const topmost = nested.current.at(-1);
33
+ if (topmost) {
34
+ topmost.close();
35
+ return;
36
+ }
37
+ onRequestClose?.();
38
+ }, [onRequestClose]);
39
+ useEffect(() => {
40
+ if (!escapeKeyTarget || !open)
41
+ return;
42
+ const markConsumed = (event) => {
43
+ if (event.key === 'Escape' && nested.current.length > 0)
44
+ consumedByNested.current = true;
45
+ };
46
+ escapeKeyTarget.addEventListener('keydown', markConsumed, true);
47
+ return () => {
48
+ escapeKeyTarget.removeEventListener('keydown', markConsumed, true);
49
+ consumedByNested.current = false;
50
+ };
51
+ }, [open]);
52
+ const attachContainer = useCallback((node) => {
53
+ setContainer(node);
54
+ }, []);
55
+ const value = useMemo(() => ({ hostName, container, register }), [hostName, container, register]);
56
+ return (_jsx(Modal, { visible: open, transparent: true, animationType: animationType, presentationStyle: "overFullScreen", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: requestClose, onShow: onShow, onDismiss: onDismiss, children: _jsxs(OverlayHostContext.Provider, { value: value, children: [children, Platform.OS === 'web' ? _jsx(View, { ref: attachContainer }) : _jsx(PortalHost, { name: hostName })] }) }));
57
+ }
58
+ /** Portal props for content rendered inside the nearest host. An explicit `portalHost` wins. */
59
+ export function useOverlayPortalTarget(portalHost) {
60
+ const host = useContext(OverlayHostContext);
61
+ if (portalHost !== undefined)
62
+ return { hostName: portalHost };
63
+ return { hostName: host?.hostName, container: host?.container };
64
+ }
65
+ /** Lets the nearest host close this overlay before itself while `open` is true. */
66
+ export function useNestedOverlay(open, close) {
67
+ const host = useContext(OverlayHostContext);
68
+ const closeRef = useRef(close);
69
+ useEffect(() => {
70
+ closeRef.current = close;
71
+ });
72
+ useEffect(() => {
73
+ if (!host || !open)
74
+ return;
75
+ return host.register({ close: () => closeRef.current() });
76
+ }, [host, open]);
77
+ }
@@ -0,0 +1,15 @@
1
+ interface OverlayPresenceOptions {
2
+ visible: boolean;
3
+ openDuration: number;
4
+ closeDuration: number;
5
+ }
6
+ /**
7
+ * Keeps an overlay mounted through its exit animation. `progress` runs 0 → 1 on open and
8
+ * back to 0 on close; `mounted` drops once the exit finishes. Reduced motion and the
9
+ * disabled test policy collapse both durations to zero.
10
+ */
11
+ export declare function useOverlayPresence({ visible, openDuration, closeDuration, }: OverlayPresenceOptions): {
12
+ mounted: boolean;
13
+ progress: import("react-native-reanimated").SharedValue<number>;
14
+ };
15
+ export {};
@@ -0,0 +1,55 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Easing, useSharedValue, withTiming } from 'react-native-reanimated';
3
+ import { useMotionPolicy } from './motion-policy';
4
+ /**
5
+ * Keeps an overlay mounted through its exit animation. `progress` runs 0 → 1 on open and
6
+ * back to 0 on close; `mounted` drops once the exit finishes. Reduced motion and the
7
+ * disabled test policy collapse both durations to zero.
8
+ */
9
+ export function useOverlayPresence({ visible, openDuration, closeDuration, }) {
10
+ const motion = useMotionPolicy();
11
+ const progress = useSharedValue(0);
12
+ const [mounted, setMounted] = useState(visible);
13
+ const closeTimerRef = useRef(null);
14
+ const [prevVisible, setPrevVisible] = useState(visible);
15
+ // Open in the prop-change render so the first animation frame has a mounted panel;
16
+ // closing stays mounted on the timer until the exit animation finishes.
17
+ if (prevVisible !== visible) {
18
+ setPrevVisible(visible);
19
+ if (visible)
20
+ setMounted(true);
21
+ }
22
+ useEffect(() => {
23
+ if (!mounted)
24
+ return;
25
+ if (closeTimerRef.current) {
26
+ clearTimeout(closeTimerRef.current);
27
+ closeTimerRef.current = null;
28
+ }
29
+ const duration = motion.reduceMotion || !motion.animationsEnabled ? 0 : visible ? openDuration : closeDuration;
30
+ progress.value = withTiming(visible ? 1 : 0, {
31
+ duration,
32
+ reduceMotion: motion.reanimatedReduceMotion,
33
+ easing: visible ? Easing.bezier(0.2, 0.8, 0.2, 1) : Easing.bezier(0.4, 0, 1, 1),
34
+ });
35
+ if (!visible) {
36
+ closeTimerRef.current = setTimeout(() => setMounted(false), duration);
37
+ }
38
+ return () => {
39
+ if (closeTimerRef.current) {
40
+ clearTimeout(closeTimerRef.current);
41
+ closeTimerRef.current = null;
42
+ }
43
+ };
44
+ }, [
45
+ mounted,
46
+ visible,
47
+ progress,
48
+ openDuration,
49
+ closeDuration,
50
+ motion.animationsEnabled,
51
+ motion.reduceMotion,
52
+ motion.reanimatedReduceMotion,
53
+ ]);
54
+ return { mounted, progress };
55
+ }
@@ -1,4 +1,4 @@
1
- import type { TextStyle, ViewStyle } from 'react-native';
1
+ import { type TextStyle, type ViewStyle } from 'react-native';
2
2
  import { type ThemeColors } from './theme-colors';
3
3
  import { type VisualTokens } from './visual-tokens';
4
4
  /**
@@ -1,3 +1,5 @@
1
+ import { Platform } from 'react-native';
2
+ import { formatRgb, parse } from 'culori';
1
3
  import { useThemeColors } from './theme-colors';
2
4
  import { useVisualTokens } from './visual-tokens';
3
5
  export function controlRecipe(visual, size = 'default') {
@@ -92,14 +94,31 @@ export function shadowStyle(visual, colors, tier) {
92
94
  : visual.shadowElevationLg;
93
95
  if (opacity <= 0)
94
96
  return {};
97
+ // Android below 9 cannot draw an outset box shadow and keeps the elevation fallback.
98
+ if (Platform.OS === 'android' && typeof Platform.Version === 'number' && Platform.Version < 28) {
99
+ return { elevation };
100
+ }
101
+ // A box shadow is clipped outside the border box, so it never shows through a
102
+ // translucent surface. Elevation and the legacy `shadow*` props draw under the whole
103
+ // view, which greyed every translucent card on Android. CSS blur is twice the
104
+ // Core Animation radius the tokens were tuned for.
95
105
  return {
96
- shadowColor: colors.shadow,
97
- shadowOpacity: opacity,
98
- shadowRadius: radius,
99
- shadowOffset: { width: 0, height: offsetY },
100
- elevation,
106
+ boxShadow: [
107
+ {
108
+ offsetX: 0,
109
+ offsetY,
110
+ blurRadius: radius * 2,
111
+ color: shadowColorWithOpacity(colors.shadow, opacity),
112
+ },
113
+ ],
101
114
  };
102
115
  }
116
+ function shadowColorWithOpacity(color, opacity) {
117
+ const parsed = parse(color);
118
+ if (!parsed)
119
+ return color;
120
+ return formatRgb({ ...parsed, alpha: opacity });
121
+ }
103
122
  // Component-owned text sizes. Font family/weight stays on className
104
123
  // (font-medium etc.) so app font mappings keep working.
105
124
  export function typographyStyle(visual, role) {
package/dist/metro.cjs ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const { withUniwindConfig } = require('uniwind/metro');
4
+
5
+ /**
6
+ * Packages that must resolve the real `react-native`. Uniwind's native resolver
7
+ * rewrites every `from 'react-native'` to its component wrapper, which re-exports
8
+ * components and nothing else, so a package importing a non-component export gets
9
+ * `undefined` with no build error. `expo-asset` imports `AssetRegistry` that way, and
10
+ * without it every `@expo/vector-icons` glyph is silently absent on iOS and Android.
11
+ */
12
+ const NEEDS_REAL_REACT_NATIVE = ['expo-asset', 'expo-font', '@expo/vector-icons'];
13
+
14
+ function withWhileUIMetro(config, options) {
15
+ const { exemptFromUniwindRewrite = [], ...uniwindOptions } = options;
16
+ const wrapped = withUniwindConfig(config, uniwindOptions);
17
+ const exempt = [...NEEDS_REAL_REACT_NATIVE, ...exemptFromUniwindRewrite];
18
+ const uniwindResolveRequest = wrapped.resolver.resolveRequest;
19
+
20
+ wrapped.resolver.resolveRequest = (context, moduleName, platform) => {
21
+ if (moduleName === 'react-native') {
22
+ const origin = context.originModulePath ?? '';
23
+ if (exempt.some((pkg) => origin.includes(`node_modules/${pkg}/`))) {
24
+ return context.resolveRequest(context, moduleName, platform);
25
+ }
26
+ }
27
+ return uniwindResolveRequest(context, moduleName, platform);
28
+ };
29
+
30
+ return wrapped;
31
+ }
32
+
33
+ module.exports = { withWhileUIMetro, NEEDS_REAL_REACT_NATIVE };
@@ -0,0 +1,21 @@
1
+ import type { withUniwindConfig } from 'uniwind/metro';
2
+
3
+ type UniwindMetroOptions = Parameters<typeof withUniwindConfig>[1];
4
+
5
+ export interface WithWhileUIMetroOptions extends UniwindMetroOptions {
6
+ /** Extra packages that must import the real `react-native` instead of Uniwind's wrapper. */
7
+ exemptFromUniwindRewrite?: string[];
8
+ }
9
+
10
+ /** Packages exempted from Uniwind's `react-native` rewrite by default. */
11
+ export const NEEDS_REAL_REACT_NATIVE: readonly string[];
12
+
13
+ /**
14
+ * Wraps an Expo Metro config with Uniwind and keeps `expo-asset`, `expo-font` and
15
+ * `@expo/vector-icons` on the real `react-native`, so icon fonts load on device.
16
+ * Use this in `metro.config.js` instead of calling `withUniwindConfig` yourself.
17
+ */
18
+ export function withWhileUIMetro<T extends Parameters<typeof withUniwindConfig>[0]>(
19
+ config: T,
20
+ options: WithWhileUIMetroOptions
21
+ ): ReturnType<typeof withUniwindConfig>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/vite.d.ts",
37
37
  "import": "./dist/vite.js"
38
38
  },
39
+ "./metro": {
40
+ "types": "./dist/metro.d.cts",
41
+ "default": "./dist/metro.cjs"
42
+ },
39
43
  "./tokens": {
40
44
  "types": "./dist/tokens.d.ts",
41
45
  "import": "./dist/tokens.js"
@@ -59,7 +63,7 @@
59
63
  "access": "public"
60
64
  },
61
65
  "scripts": {
62
- "build": "tsc -p tsconfig.build.json",
66
+ "build": "tsc -p tsconfig.build.json && cp src/metro.cjs src/metro.d.cts dist/",
63
67
  "prepack": "cp ../../README.md README.md",
64
68
  "postpack": "rm -f README.md",
65
69
  "prepare": "bun run build",