@thewhileloop/whileui 4.1.0 → 4.1.2

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 (44) hide show
  1. package/README.md +120 -22
  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/media/smart-image.js +5 -7
  12. package/dist/blocks/navigation/drawer-menu.js +10 -55
  13. package/dist/blocks/navigation/header.d.ts +2 -0
  14. package/dist/blocks/navigation/header.js +1 -1
  15. package/dist/components/alert/alert.js +4 -1
  16. package/dist/components/alert-dialog/alert-dialog.js +3 -2
  17. package/dist/components/avatar/avatar.d.ts +3 -2
  18. package/dist/components/avatar/avatar.js +19 -13
  19. package/dist/components/avatar/index.d.ts +1 -1
  20. package/dist/components/context-menu/context-menu.js +4 -3
  21. package/dist/components/dialog/dialog.js +4 -3
  22. package/dist/components/dropdown-menu/dropdown-menu.js +4 -3
  23. package/dist/components/hover-card/hover-card.js +5 -1
  24. package/dist/components/menubar/menubar.js +5 -4
  25. package/dist/components/popover/popover.js +5 -1
  26. package/dist/components/select/select.js +36 -15
  27. package/dist/components/text/text.d.ts +3 -0
  28. package/dist/components/text/text.js +2 -0
  29. package/dist/components/toast/toast.js +5 -1
  30. package/dist/components/toggle/toggle.d.ts +1 -1
  31. package/dist/components/toggle/toggle.js +1 -1
  32. package/dist/components/tooltip/tooltip.js +3 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/lib/overlay-host.d.ts +26 -0
  35. package/dist/lib/overlay-host.js +77 -0
  36. package/dist/lib/overlay-presence.d.ts +15 -0
  37. package/dist/lib/overlay-presence.js +55 -0
  38. package/dist/lib/recipes.d.ts +1 -1
  39. package/dist/lib/recipes.js +24 -5
  40. package/dist/lib/theme-bridge.d.ts +1 -1
  41. package/dist/lib/theme-bridge.js +52 -18
  42. package/dist/metro.cjs +33 -0
  43. package/dist/metro.d.cts +21 -0
  44. package/package.json +6 -2
package/README.md CHANGED
@@ -11,6 +11,18 @@ production targets — iOS and Android set the interaction baseline. See
11
11
  [ROADMAP.md](./ROADMAP.md) for the product boundary and extraction rules, and
12
12
  [PLAN.md](./PLAN.md) for the supported stack and production contracts.
13
13
 
14
+ ## See it in action
15
+
16
+ One React Native component contract across native and web, with semantic typography,
17
+ token-driven light and dark themes, accessible controls, and application-owned chat
18
+ composition. The same component tree can feel airy and atmospheric or compact and
19
+ editorial by changing tokens alone.
20
+
21
+ <p align="center">
22
+ <img src="./docs/assets/whileui-showcase.gif" width="300" alt="WhileUI Cosmic Aura mobile theme demonstrating light and dark modes, controls, Select, and composable chat" />
23
+ <img src="./docs/assets/whileui-showcase-atelier.gif" width="300" alt="WhileUI Atelier mobile theme demonstrating compact controls, authentication forms, Select, and composable chat" />
24
+ </p>
25
+
14
26
  ## Installation
15
27
 
16
28
  ```bash
@@ -26,23 +38,26 @@ Use `npm install` instead of `bun add` if you prefer npm.
26
38
 
27
39
  ### Setup Uniwind (required)
28
40
 
29
- 1. **metro.config.js** (wrap with withUniwindConfig):
41
+ 1. **metro.config.js** (wrap with `withWhileUIMetro`):
30
42
 
31
43
  ```js
32
44
  const { getDefaultConfig } = require('expo/metro-config');
33
- const { withUniwindConfig } = require('uniwind/metro');
45
+ const { withWhileUIMetro } = require('@thewhileloop/whileui/metro');
34
46
 
35
47
  /** @type {import('expo/metro-config').MetroConfig} */
36
48
  const config = getDefaultConfig(__dirname);
37
49
 
38
- module.exports = withUniwindConfig(config, {
50
+ module.exports = withWhileUIMetro(config, {
39
51
  cssEntryFile: './global.css',
40
52
  });
41
53
  ```
42
54
 
43
- > `withUniwindConfig(config, options)` takes the config first and returns the wrapped
44
- > config — it is not curried. It must wrap the outermost config object, and
45
- > `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.
46
61
 
47
62
  2. **global.css** at app root:
48
63
 
@@ -132,6 +147,12 @@ export default function App() {
132
147
 
133
148
  > **Note:** If you use Select, Popover, Tooltip, or HoverCard, add `<PortalHost />` at the root of your app (as the last child).
134
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
+
135
156
  ### Vite + React Native Web + WhileUI setup
136
157
 
137
158
  Use the WhileUI Vite compatibility helper when your app includes portal-based primitives.
@@ -225,11 +246,15 @@ function MyScreen() {
225
246
  - **Layout:** `Stack` (vertical), `Row` (horizontal) — both support `gap`, `align`, `justify`
226
247
  - **Auth callbacks:** Auth templates use objects: `onSubmit({ email, password })`, `onSubmit({ firstName, lastName, email, password })`, etc. Copy templates from `apps/showcase/templates/auth/`.
227
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.
228
250
  - **Motion:** Wrap the root in `<MotionProvider>`; use `useMotionPolicy()` for semantic
229
251
  durations, the responsive spring, and reduced-motion behavior.
230
- - **Uniwind:** `withUniwindConfig(config, options)` wraps metro config — config first, not
231
- 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
232
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.
233
258
  - **Reference:** Block props in `packages/ui/src/blocks` (core) and `apps/showcase/templates/` (auth, profile); flow patterns in README "Flow Patterns" section.
234
259
 
235
260
  ### Composition grammar
@@ -372,6 +397,7 @@ Copy from `apps/showcase/templates/auth/`:
372
397
  | **ErrorBoundary** | React ErrorBoundary that renders ErrorState by default |
373
398
  | **EmptyState** | Empty content placeholder |
374
399
  | **ErrorState** | Error display with retry |
400
+ | **LoadingState** | In-flow loading placeholder for a section still fetching |
375
401
  | **LoadingScreen** | Full-screen loading indicator |
376
402
  | **PullToRefreshScrollView** | Themed ScrollView with RefreshControl (useThemeTokens for colors) |
377
403
  | **KeyboardAwareScreen** | One screen-level keyboard policy for bottom-docked controls |
@@ -425,9 +451,9 @@ Copy from `apps/showcase/templates/profile/`:
425
451
 
426
452
  ### Media
427
453
 
428
- | Block | Description |
429
- | -------------- | ----------------------------------- |
430
- | **SmartImage** | Image with aspect ratio and loading |
454
+ | Block | Description |
455
+ | -------------- | ------------------------------------------------------------------ |
456
+ | **SmartImage** | Image with explicit loading, loaded, fallback, and error lifecycle |
431
457
 
432
458
  ### Date Picker
433
459
 
@@ -926,6 +952,9 @@ const adapter: ThemeBridgeAdapter = {
926
952
  const { mode, resolvedTheme, setMode, cycleMode } = useThemeBridge({ adapter });
927
953
  ```
928
954
 
955
+ The bridge loads a new adapter before saving into it. If a user changes the mode while
956
+ storage is still loading, that explicit choice wins over the late persisted value.
957
+
929
958
  When another store already owns the mode, mount the controlled bridge once near the
930
959
  application root. Change `mode` in the owning event handler; the bridge applies and
931
960
  persists it without mirroring state back through a callback effect.
@@ -1031,10 +1060,10 @@ import { Button, ButtonText, ButtonIcon } from '@thewhileloop/whileui';
1031
1060
  classes are unnecessary for normal hierarchy. `heading` also sets
1032
1061
  `accessibilityRole="header"` unless the caller supplies another role.
1033
1062
 
1034
- | Prop | Type | Default |
1035
- | ------- | --------------------------------------------------------------------------------------------- | ----------- |
1036
- | variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'code'` | `'default'` |
1037
- | 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 |
1038
1067
 
1039
1068
  ## Input
1040
1069
 
@@ -1379,9 +1408,12 @@ import {
1379
1408
 
1380
1409
  ### React Native: stacked modals
1381
1410
 
1382
- 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.
1383
-
1384
- **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.
1385
1417
 
1386
1418
  ## Checkbox
1387
1419
 
@@ -1446,6 +1478,8 @@ import {
1446
1478
  Focused and pressed options use the subtle interaction-state tokens; the primary token
1447
1479
  marks the selected indicator without replacing the option's foreground colour. Long
1448
1480
  selected values stay on one line and truncate without displacing the trigger chevron.
1481
+ For popover presentation on web, the options panel matches the trigger width, caps
1482
+ itself to the collision-available viewport, and wraps long option labels inside it.
1449
1483
 
1450
1484
  `SelectContent` supports optional frosted props:
1451
1485
 
@@ -1455,6 +1489,11 @@ selected values stay on one line and truncate without displacing the trigger che
1455
1489
  | blurIntensity | `number` | subtle preset |
1456
1490
  | blurTintToken | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | `'popover'` |
1457
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
+
1458
1497
  ## Tabs
1459
1498
 
1460
1499
  ```tsx
@@ -1507,7 +1546,7 @@ import {
1507
1546
  import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1508
1547
 
1509
1548
  <Avatar size="default">
1510
- <AvatarImage src="https://..." />
1549
+ <AvatarImage source={{ uri: 'https://...' }} />
1511
1550
  <AvatarFallback>JD</AvatarFallback>
1512
1551
  </Avatar>;
1513
1552
  ```
@@ -1516,6 +1555,10 @@ import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1516
1555
  | ---- | --------------------------- | ----------- |
1517
1556
  | size | `'sm' \| 'default' \| 'lg'` | `'default'` |
1518
1557
 
1558
+ `AvatarImage` keeps `AvatarFallback` visible while loading or after an error. Its
1559
+ `onLoadingStatusChange` callback reports `'loading'`, `'loaded'`, and `'error'`, including
1560
+ when a changed source begins loading, while the standard image event callbacks still run.
1561
+
1519
1562
  ## Progress
1520
1563
 
1521
1564
  ```tsx
@@ -1552,6 +1595,9 @@ toast({ title: 'Success', description: 'Saved!', variant: 'success' });
1552
1595
  | variant | `'default' \| 'success' \| 'destructive'` |
1553
1596
  | duration | `number` (ms) |
1554
1597
 
1598
+ Each toast owns its duration from the moment it appears; adding or dismissing another
1599
+ toast does not restart timers already in progress.
1600
+
1555
1601
  ## Popover
1556
1602
 
1557
1603
  ```tsx
@@ -1889,6 +1935,36 @@ code, and fenced code. It never opens links or writes to the clipboard itself:
1889
1935
  `onLinkPress` and `codeActions` keep those capabilities in the application. `CodeBlock`
1890
1936
  is dependency-free and accepts an application-owned `actions` slot.
1891
1937
 
1938
+ ## SmartImage
1939
+
1940
+ ```tsx
1941
+ import { SmartImage } from '@thewhileloop/whileui';
1942
+
1943
+ <SmartImage
1944
+ source={{ uri: imageUrl }}
1945
+ fallbackSource={{ uri: fallbackUrl }}
1946
+ alt="Product preview"
1947
+ className="h-48 w-full"
1948
+ />;
1949
+ ```
1950
+
1951
+ | Prop | Type | Default |
1952
+ | ------------------- | -------------------------------------------------- | --------- |
1953
+ | `source` | `ImageProps['source']` | required |
1954
+ | `alt` | `string` | required |
1955
+ | `fallbackSource` | `ImageProps['source']` | — |
1956
+ | `skeleton` | `boolean` | `true` |
1957
+ | `skeletonClassName` | `string` | — |
1958
+ | `transition` | `number` | `200` |
1959
+ | `radius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` |
1960
+ | `objectFit` | `'cover' \| 'contain' \| 'fill' \| 'scale-down'` | `'cover'` |
1961
+ | `onLoad` | `() => void` | — |
1962
+ | `onError` | `() => void` | — |
1963
+
1964
+ The skeleton returns whenever the primary image starts loading again. A failed primary
1965
+ source switches to `fallbackSource` when supplied without re-entering a primary/fallback
1966
+ loop.
1967
+
1892
1968
  ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1893
1969
 
1894
1970
  Date selection blocks using react-native-calendars. Theme-aware via `useCalendarTheme` (Uniwind light/dark). Optional `theme` prop for custom RN color strings (hex/rgb/hsl/named). `CalendarTheme` maps to react-native-calendars Theme and supports `arrowColor`, `disabledArrowColor`, and optional font keys.
@@ -2032,7 +2108,8 @@ keeps taps on inputs while the keyboard is open (`keyboardShouldPersistTaps="han
2032
2108
  Your `contentContainerStyle` is merged after the default padding. Tapping the backdrop
2033
2109
  closes the sheet; a drag across the backdrop does not, so a missed scroll never dismisses
2034
2110
  it. There is no drag-to-dismiss: close with the backdrop, Android back, Escape on web or
2035
- `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.
2036
2113
 
2037
2114
  ## Header
2038
2115
 
@@ -2043,7 +2120,9 @@ import { Header, HeaderBackButton } from '@thewhileloop/whileui';
2043
2120
  title="Settings"
2044
2121
  subtitle="Manage preferences"
2045
2122
  leftAction={<HeaderBackButton onPress={() => {}} />}
2046
- rightActions={[{ key: 'search', icon: <Icon />, onPress: () => {} }]}
2123
+ rightActions={[
2124
+ { key: 'search', icon: <Icon />, onPress: () => {}, accessibilityLabel: 'Search' },
2125
+ ]}
2047
2126
  />;
2048
2127
  ```
2049
2128
 
@@ -2185,6 +2264,22 @@ import { EmptyState } from '@thewhileloop/whileui';
2185
2264
  />;
2186
2265
  ```
2187
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
+
2188
2283
  ## ProfileHeader
2189
2284
 
2190
2285
  Copy from `apps/showcase/templates/profile/profile-header.tsx`, then:
@@ -2384,6 +2479,9 @@ import { DrawerMenu } from '@thewhileloop/whileui';
2384
2479
  | `blurIntensity` | `number` | medium preset |
2385
2480
  | `blurTintToken` | `'surfaceElevated' \| 'surfaceTranslucent' \| 'card' \| 'popover'` | `'surfaceTranslucent'` |
2386
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
+
2387
2485
  ## Roadmap
2388
2486
 
2389
2487
  Tracked work items for future releases.
@@ -2410,7 +2508,7 @@ Tracked work items for future releases.
2410
2508
  ### Documentation Gaps
2411
2509
 
2412
2510
  - [ ] Add `## API` sections for: Textarea, Toggle, ToggleGroup, Label, Separator, Skeleton, AspectRatio, Collapsible, Text, View
2413
- - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, SmartImage, TimelineFeed
2511
+ - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, TimelineFeed
2414
2512
 
2415
2513
  ### Infrastructure
2416
2514
 
@@ -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);
@@ -44,21 +44,19 @@ function resolveRadius(visual, radius) {
44
44
  return visual.radiusMd;
45
45
  }
46
46
  const SmartImage = React.forwardRef(({ className, source, alt, skeleton = true, skeletonClassName, transition = 200, radius, objectFit, onLoad, onError, fallbackSource, style, ...props }, ref) => {
47
- const [loading, setLoading] = useState(true);
48
- const [error, setError] = useState(false);
47
+ const [phase, setPhase] = useState('loading');
49
48
  const visual = useVisualTokens();
50
49
  const borderRadius = resolveRadius(visual, radius ?? 'md');
51
50
  const handleLoad = () => {
52
- setLoading(false);
51
+ setPhase((current) => (current === 'fallback' ? 'fallback' : 'loaded'));
53
52
  onLoad?.();
54
53
  };
55
54
  const handleError = () => {
56
- setLoading(false);
57
- setError(true);
55
+ setPhase((current) => current === 'fallback' ? 'fallback' : fallbackSource ? 'fallback' : 'error');
58
56
  onError?.();
59
57
  };
60
- const imageSource = error && fallbackSource ? fallbackSource : source;
61
- return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && loading && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: loading && !error ? 0 : 1 } })] }));
58
+ const imageSource = phase === 'fallback' ? fallbackSource : source;
59
+ return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && phase === 'loading' && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoadStart: () => setPhase((current) => (current === 'fallback' ? current : 'loading')), onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: phase === 'loading' ? 0 : 1 } })] }));
62
60
  });
63
61
  SmartImage.displayName = 'SmartImage';
64
62
  export { SmartImage, smartImageVariants };
@@ -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;