@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.
- package/README.md +61 -17
- package/dist/blocks/datepicker/date-range-picker-modal.js +3 -2
- package/dist/blocks/datepicker/datepicker-modal.js +3 -2
- package/dist/blocks/datepicker/time-picker-modal.js +3 -2
- package/dist/blocks/layout/confirm-action-sheet.js +3 -2
- package/dist/blocks/layout/index.d.ts +1 -0
- package/dist/blocks/layout/index.js +1 -0
- package/dist/blocks/layout/loading-state.d.ts +8 -0
- package/dist/blocks/layout/loading-state.js +12 -0
- package/dist/blocks/layout/sheet.js +21 -2
- package/dist/blocks/navigation/drawer-menu.js +10 -55
- package/dist/blocks/navigation/header.d.ts +2 -0
- package/dist/blocks/navigation/header.js +1 -1
- package/dist/components/alert/alert.js +4 -1
- package/dist/components/alert-dialog/alert-dialog.js +3 -2
- package/dist/components/context-menu/context-menu.js +4 -3
- package/dist/components/dialog/dialog.js +4 -3
- package/dist/components/dropdown-menu/dropdown-menu.js +4 -3
- package/dist/components/hover-card/hover-card.js +5 -1
- package/dist/components/menubar/menubar.js +5 -4
- package/dist/components/popover/popover.js +5 -1
- package/dist/components/select/select.js +10 -1
- package/dist/components/text/text.d.ts +3 -0
- package/dist/components/text/text.js +2 -0
- package/dist/components/toggle/toggle.d.ts +1 -1
- package/dist/components/toggle/toggle.js +1 -1
- package/dist/components/tooltip/tooltip.js +3 -1
- package/dist/lib/overlay-host.d.ts +26 -0
- package/dist/lib/overlay-host.js +77 -0
- package/dist/lib/overlay-presence.d.ts +15 -0
- package/dist/lib/overlay-presence.js +55 -0
- package/dist/lib/recipes.d.ts +1 -1
- package/dist/lib/recipes.js +24 -5
- package/dist/metro.cjs +33 -0
- package/dist/metro.d.cts +21 -0
- 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
|
|
41
|
+
1. **metro.config.js** (wrap with `withWhileUIMetro`):
|
|
42
42
|
|
|
43
43
|
```js
|
|
44
44
|
const { getDefaultConfig } = require('expo/metro-config');
|
|
45
|
-
const {
|
|
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 =
|
|
50
|
+
module.exports = withWhileUIMetro(config, {
|
|
51
51
|
cssEntryFile: './global.css',
|
|
52
52
|
});
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
> `withUniwindConfig(config, options)`
|
|
56
|
-
> config
|
|
57
|
-
> `
|
|
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
|
-
- **
|
|
243
|
-
|
|
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
|
|
1050
|
-
| ------- |
|
|
1051
|
-
| variant | `'default' \| 'heading' \| 'title' \| 'subtitle' \| 'body' \| 'caption' \| 'label' \| 'code'` | `'default'` |
|
|
1052
|
-
| size | `'xs' \| 'sm' \| 'base' \| 'lg' \| 'xl' \| '2xl' \| '3xl'`
|
|
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
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
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={[
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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,
|
|
4
|
-
import Animated, {
|
|
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 {
|
|
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
|
|
38
|
-
const progress =
|
|
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
|
-
|
|
79
|
-
interaction.
|
|
80
|
-
|
|
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
|
-
|
|
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-
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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-
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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-
|
|
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, {
|
|
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,
|
|
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-
|
|
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(
|
|
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-
|
|
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, {
|
|
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, {
|
|
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-
|
|
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-
|
|
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, {
|
|
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
|
+
}
|
package/dist/lib/recipes.d.ts
CHANGED
package/dist/lib/recipes.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 };
|
package/dist/metro.d.cts
ADDED
|
@@ -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.
|
|
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",
|