@rific/auto-paper 0.12.2 → 0.12.4

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 CHANGED
@@ -34,7 +34,7 @@ npm install expo-blur # frosted-glass BlurView
34
34
  npm install expo-navigation-bar # (>= 56.0.0, SDK 54+) auto-syncs Android nav bar icon style to the theme when BottomNavigation is mounted
35
35
  ```
36
36
 
37
- These integrations are opt-in: pass them to `Provider` (`expoBlur`, `navigationBar`, and `reanimated` — see the prop table below), and the relevant wrappers pick them up automatically. Omit them and you still get working fallbacks: `BlurView` renders its solid (non-blur) look, `BottomNavigation` simply skips nav-bar syncing, and `Dialog` treats `animatedStyle` as a no-op unless a reanimated module is injected.
37
+ These integrations are opt-in: pass them to `Provider` (`expoBlur`, `navigationBar`, and `reanimated` - see the prop table below), and the relevant wrappers pick them up automatically. Omit them and you still get working fallbacks: `BlurView` renders its solid (non-blur) look, `BottomNavigation` simply skips nav-bar syncing, and `Dialog` treats `animatedStyle` as a no-op unless a reanimated module is injected.
38
38
 
39
39
  The exported Redux slice has no dependency on `@reduxjs/toolkit`: it works with RTK stores, vanilla Redux, or no Redux at all.
40
40
 
@@ -221,7 +221,7 @@ export function Theme({ children }: { children: React.ReactNode }) {
221
221
  }
222
222
  ```
223
223
 
224
- Keep the scope honest: this only replaces the final `<Provider initialValue={...} onChange={...} onReady={...}>` prop list with `<Provider {...bridgeProps}>`. It doesn't do the `useSelector`/`useDispatch` wiring above it — that's still yours to write, since `RootState`'s shape is app-local and this package has no opinion on it (or on state management being Redux at all; `useThemeBridgeProps` takes plain values and callbacks, not a `dispatch` function).
224
+ Keep the scope honest: this only replaces the final `<Provider initialValue={...} onChange={...} onReady={...}>` prop list with `<Provider {...bridgeProps}>`. It doesn't do the `useSelector`/`useDispatch` wiring above it - that's still yours to write, since `RootState`'s shape is app-local and this package has no opinion on it (or on state management being Redux at all; `useThemeBridgeProps` takes plain values and callbacks, not a `dispatch` function).
225
225
 
226
226
  ### With `useComputedTheme`
227
227
 
@@ -266,7 +266,7 @@ export default function App() {
266
266
  | `children` | `ReactNode` | |
267
267
  | `defaults` | `PaperDefaults` | Prop defaults for wrapper components (see below) |
268
268
  | `expoBlur` | `ExpoBlurModule` | Injects `expo-blur` (`import * as ExpoBlur from 'expo-blur'`) so `<BlurView>` renders the real frosted-glass effect. Omit to always render its solid fallback. On Android, expo-blur only blurs when given a `blurTarget` and a `blurMethod` (`'dimezisBlurView'`, or `'dimezisBlurViewSdk31Plus'` on API 31+); without both, `<BlurView>` renders the solid fallback there too, rather than expo-blur's unblurred translucent tint. |
269
- | `fontFamily` | `string` | Applied to every Paper typography variant (`bodyLarge`, `headlineMedium`, `labelSmall`, ...) via `configureFonts`'s flat-config mode — each variant keeps its own MD3 fontSize/lineHeight/weight, only fontFamily changes. Omit to keep each platform's system font. |
269
+ | `fontFamily` | `string` | Applied to every Paper typography variant (`bodyLarge`, `headlineMedium`, `labelSmall`, ...) via `configureFonts`'s flat-config mode - each variant keeps its own MD3 fontSize/lineHeight/weight, only fontFamily changes. Omit to keep each platform's system font. |
270
270
  | `navigationBar` | `ExpoNavigationBarModule` | Injects `expo-navigation-bar` (`import * as ExpoNavigationBar from 'expo-navigation-bar'`) so the Android nav bar icon style auto-syncs with the theme while a `BottomNavigation` is mounted. Omit (and don't pass `onNavBarChange`) to skip nav bar syncing entirely. |
271
271
  | `onNavBarChange` | `(color: string, dark: boolean) => void` | Overrides the built-in nav bar sync: called on Android when the theme changes while a `BottomNavigation` is mounted, instead of the automatic `navigationBar`-driven icon-style sync |
272
272
  | `onReady` | `() => void` | Called once when the theme first resolves |
@@ -288,7 +288,7 @@ export default function App() {
288
288
 
289
289
  ### `useThemeBridgeProps({ initialValue, onChange, onReady })`
290
290
 
291
- Reshapes already-resolved values into the exact `Pick<ProviderProps, 'initialValue' | 'onChange' | 'onReady'>` shape `Provider` expects, memoized on those three fields so the returned object is referentially stable across re-renders until an input actually changes. Meant for a consuming app's own Redux-backed "Theme" component — see "With Redux" above — but takes plain values and callbacks rather than a `dispatch` function or anything Redux-specific, so it works with any state management, or none.
291
+ Reshapes already-resolved values into the exact `Pick<ProviderProps, 'initialValue' | 'onChange' | 'onReady'>` shape `Provider` expects, memoized on those three fields so the returned object is referentially stable across re-renders until an input actually changes. Meant for a consuming app's own Redux-backed "Theme" component - see "With Redux" above - but takes plain values and callbacks rather than a `dispatch` function or anything Redux-specific, so it works with any state management, or none.
292
292
 
293
293
  It only replaces the final prop list handed to `Provider`; it does not do any `useSelector`/`dispatch` wiring itself, and `onReady` is threaded through unchanged.
294
294
 
@@ -378,7 +378,7 @@ function SettingsScreen() {
378
378
 
379
379
  ### `AutoAppearancePicker`
380
380
 
381
- `AppearancePicker` wired straight to the `Provider`'s own `ThemeSettings` — for the common case where the appearance being edited IS the whole app's own theme. Reads/writes `useThemeSettings()` internally, so it takes neither `value` nor `onChange`; every other `AppearancePicker` prop still passes through. Reach for the plain `AppearancePicker` instead when it needs to be controlled by something other than the global theme (a local preview before committing, one-off UI unrelated to `Provider`).
381
+ `AppearancePicker` wired straight to the `Provider`'s own `ThemeSettings` - for the common case where the appearance being edited IS the whole app's own theme. Reads/writes `useThemeSettings()` internally, so it takes neither `value` nor `onChange`; every other `AppearancePicker` prop still passes through. Reach for the plain `AppearancePicker` instead when it needs to be controlled by something other than the global theme (a local preview before committing, one-off UI unrelated to `Provider`).
382
382
 
383
383
  ```tsx
384
384
  import { AutoAppearancePicker } from '@rific/auto-paper'
@@ -390,7 +390,7 @@ function SettingsScreen() {
390
390
 
391
391
  ### `HarmonyPicker`
392
392
 
393
- Switches between the 6 `ColorHarmony` modes (`triadic`, `split-complementary`, `analogous`, `square`, `complementary`, `double-split`) via `react-native-paper`'s `SegmentedButtons`. `PalettePicker` can embed this same picker in its own dialog instead of standing alone — see `onHarmonyChange` below.
393
+ Switches between the 6 `ColorHarmony` modes (`triadic`, `split-complementary`, `analogous`, `square`, `complementary`, `double-split`) via `react-native-paper`'s `SegmentedButtons`. `PalettePicker` can embed this same picker in its own dialog instead of standing alone - see `onHarmonyChange` below.
394
394
 
395
395
  ```tsx
396
396
  import { HarmonyPicker, useThemeSettings } from '@rific/auto-paper'
@@ -405,7 +405,7 @@ function SettingsScreen() {
405
405
  |---|---|---|
406
406
  | `value` | `ColorHarmony` | Currently selected harmony |
407
407
  | `onChange` | `(harmony: ColorHarmony) => void` | Called when a segment is tapped |
408
- | `showLabels` | `boolean` | Set `false` for icon-only segments. Also lowers Paper's default 76px-per-segment minimum width down to 0, so all 6 segments can always shrink to fit — however narrow the container, they resize down together instead of overflowing it. `accessibilityLabel` is always set regardless, so screen readers still announce the full harmony name either way. Defaults to `true` |
408
+ | `showLabels` | `boolean` | Set `false` for icon-only segments. Also lowers Paper's default 76px-per-segment minimum width down to 0, so all 6 segments can always shrink to fit - however narrow the container, they resize down together instead of overflowing it. `accessibilityLabel` is always set regardless, so screen readers still announce the full harmony name either way. Defaults to `true` |
409
409
  | `checkedColor` / `uncheckedColor` | `string` | Icon color for the checked and unchecked segments, matching Paper's own segmented-button vocabulary |
410
410
  | `checkedContainerColor` / `uncheckedContainerColor` | `string` | Fill color for the checked and unchecked segments. Omit any of these four color props to fall back to Paper's default segmented-button styling |
411
411
 
@@ -425,6 +425,29 @@ import { Appbar } from '@rific/auto-paper'
425
425
 
426
426
  `Appbar.Content`, `Appbar.Action`, and `Appbar.BackAction` are re-exported directly from `react-native-paper`. Only `Appbar.Header` is wrapped: it applies `AppbarHeader` defaults from context and renders the synced `StatusBar`.
427
427
 
428
+ ### `BlurView`
429
+
430
+ A frosted-glass surface that follows the theme. It blurs when `blur` is true (the default) and `expoBlur` is injected into `Provider` (on Android, only with a `blurTarget` and a `blurMethod`, see the `Provider` table); otherwise it renders a solid `surface` (or `surfaceVariant`) background. `Dialog` and `Menu` use it for their surfaces, passing the ambient `settings.blur`.
431
+
432
+ ```tsx
433
+ import { BlurView, useBlur } from '@rific/auto-paper'
434
+
435
+ <BlurView blur={useBlur()} elevation={2} style={{ borderRadius: 16, overflow: 'hidden' }}>
436
+ <Text>Frosted</Text>
437
+ </BlurView>
438
+ ```
439
+
440
+ | Prop | Type | Description |
441
+ |---|---|---|
442
+ | `blur` | `boolean` | Whether to blur at all (default `true`). Flipping it while mounted never remounts the children: their state, scroll position and focus survive |
443
+ | `elevation` | `0`-`5` | Tints the surface with `colors.primary` at the MD3 elevation opacity |
444
+ | `variant` | `'primary' \| 'secondary' \| 'tertiary' \| 'error' \| 'surface' \| 'surfaceVariant'` | Theme-derived tint (or, for `surfaceVariant`, the base surface color) |
445
+ | `tintColor` / `tintOpacity` | `string` / `number` | An explicit tint overlay, overriding `variant` and `elevation` |
446
+ | `intensity`, `blurMethod`, `blurTarget`, `blurReductionFactor` | | Passed to expo-blur's `BlurView`, as in expo-blur |
447
+ | ...all `ViewProps` | | Applied to the outer `View` |
448
+
449
+ Whether or not it blurs, `BlurView` is always the same outer `View`, holding the blurTint surface layer (at `settings.blurTint`), the tint, and the children. On iOS and Android the blur is an absolutely filled expo-blur layer behind them, the same structure as expo-blur's own native `BlurView`, so (as with expo-blur) rounded corners only clip the blur when the style also sets `overflow: 'hidden'`. On web the outer `View` is itself the CSS `backdrop-filter` element, styled the way expo-blur's web `BlurView` styles its own, so an opacity animated on `BlurView` (as `Dialog` and `Menu` do on web) fades the blur correctly instead of cutting it off.
450
+
428
451
  ### `BottomNavigation`
429
452
 
430
453
  A thin wrapper around `react-native-paper`'s `BottomNavigation` that keeps the Android system navigation bar icons readable: when `navigationBar` is injected into `Provider`, the icon style automatically follows the theme's darkness (edge-to-edge safe, no background color calls). Pass `onNavBarChange` to the `Provider` to take full control instead; with neither, it silently no-ops.
@@ -514,7 +537,7 @@ import { IconButton } from '@rific/auto-paper'
514
537
 
515
538
  ### `PalettePicker`
516
539
 
517
- Like `ColorPicker`, but each swatch (and the trigger itself) renders the seed's full triadic palette as a 3-wedge pie instead of a flat color, so you see the whole result before picking. Pass `onHarmonyChange` to fold a compact, icon-only `HarmonyPicker` row into the same dialog, above the swatch grid — every swatch's own pie reshapes live as harmony changes, so there's one place to tune both instead of two separate controls. Omit it and this renders exactly as a plain color-only picker always has.
540
+ Like `ColorPicker`, but each swatch (and the trigger itself) renders the seed's full triadic palette as a 3-wedge pie instead of a flat color, so you see the whole result before picking. Pass `onHarmonyChange` to fold a compact, icon-only `HarmonyPicker` row into the same dialog, above the swatch grid - every swatch's own pie reshapes live as harmony changes, so there's one place to tune both instead of two separate controls. Omit it and this renders exactly as a plain color-only picker always has.
518
541
 
519
542
  ```tsx
520
543
  import { PalettePicker, useThemeSettings } from '@rific/auto-paper'
@@ -537,7 +560,7 @@ function SettingsScreen() {
537
560
 
538
561
  ### `AutoPalettePicker`
539
562
 
540
- `PalettePicker` wired straight to the `Provider`'s own `ThemeSettings` — both color AND harmony — for the common case where the palette being edited IS the whole app's own theme. Reads/writes `useThemeSettings()` internally (narrowing `ThemeSettings.color`'s `string | TriadicPalette` down to a plain string via `resolveSeedColor`), so it takes none of `value`/`onChange`/`harmony`/`onHarmonyChange`; `weights`/`colors`/`blur` still pass through. Reach for the plain `PalettePicker` instead when it needs to be controlled by something other than the global theme.
563
+ `PalettePicker` wired straight to the `Provider`'s own `ThemeSettings` - both color AND harmony - for the common case where the palette being edited IS the whole app's own theme. Reads/writes `useThemeSettings()` internally (narrowing `ThemeSettings.color`'s `string | TriadicPalette` down to a plain string via `resolveSeedColor`), so it takes none of `value`/`onChange`/`harmony`/`onHarmonyChange`; `weights`/`colors`/`blur` still pass through. Reach for the plain `PalettePicker` instead when it needs to be controlled by something other than the global theme.
541
564
 
542
565
  ```tsx
543
566
  import { AutoPalettePicker } from '@rific/auto-paper'
package/dist/index.d.mts CHANGED
@@ -133,7 +133,7 @@ type BlurViewProps = ExpoBlurViewProps & {
133
133
  tintOpacity?: number;
134
134
  variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'surface' | 'surfaceVariant';
135
135
  };
136
- declare const BlurView: ({ blur, children, elevation, style, tintColor, tintOpacity, variant, ...props }: BlurViewProps) => react.JSX.Element;
136
+ declare const BlurView: ({ blur, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant, ...props }: BlurViewProps) => react.JSX.Element;
137
137
 
138
138
  declare function BottomNavigationComponent<Route extends BottomNavigationRoute>(props: BottomNavigationProps<Route>): react__default.JSX.Element;
139
139
  declare namespace BottomNavigationComponent {
@@ -298,7 +298,7 @@ type ProviderProps = {
298
298
  /** Injects expo-blur for `<BlurView>`. Pass `import * as ExpoBlur from 'expo-blur'`; omit to always render BlurView's solid fallback. */
299
299
  expoBlur?: ExpoBlurModule;
300
300
  /** Applied to every Paper typography variant (bodyLarge, headlineMedium, labelSmall, ...) via
301
- * `configureFonts`'s flat-config mode — each variant keeps its own MD3 fontSize/lineHeight/weight,
301
+ * `configureFonts`'s flat-config mode - each variant keeps its own MD3 fontSize/lineHeight/weight,
302
302
  * only fontFamily changes. The one-line way to reskin an app's whole text to a custom typeface,
303
303
  * instead of passing `fontFamily` to every individual Text/TextInput/Button across the app. Omit
304
304
  * to keep each platform's system font (MD3's own default). */
package/dist/index.d.ts CHANGED
@@ -133,7 +133,7 @@ type BlurViewProps = ExpoBlurViewProps & {
133
133
  tintOpacity?: number;
134
134
  variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'surface' | 'surfaceVariant';
135
135
  };
136
- declare const BlurView: ({ blur, children, elevation, style, tintColor, tintOpacity, variant, ...props }: BlurViewProps) => react.JSX.Element;
136
+ declare const BlurView: ({ blur, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant, ...props }: BlurViewProps) => react.JSX.Element;
137
137
 
138
138
  declare function BottomNavigationComponent<Route extends BottomNavigationRoute>(props: BottomNavigationProps<Route>): react__default.JSX.Element;
139
139
  declare namespace BottomNavigationComponent {
@@ -298,7 +298,7 @@ type ProviderProps = {
298
298
  /** Injects expo-blur for `<BlurView>`. Pass `import * as ExpoBlur from 'expo-blur'`; omit to always render BlurView's solid fallback. */
299
299
  expoBlur?: ExpoBlurModule;
300
300
  /** Applied to every Paper typography variant (bodyLarge, headlineMedium, labelSmall, ...) via
301
- * `configureFonts`'s flat-config mode — each variant keeps its own MD3 fontSize/lineHeight/weight,
301
+ * `configureFonts`'s flat-config mode - each variant keeps its own MD3 fontSize/lineHeight/weight,
302
302
  * only fontFamily changes. The one-line way to reskin an app's whole text to a custom typeface,
303
303
  * instead of passing `fontFamily` to every individual Text/TextInput/Button across the app. Omit
304
304
  * to keep each platform's system font (MD3's own default). */
package/dist/index.js CHANGED
@@ -725,7 +725,12 @@ var blursOnAndroid = ({ blurMethod, blurTarget, experimentalBlurMethod }) => {
725
725
  };
726
726
  var ELEVATION_OPACITY = { 0: 0, 1: 0.05, 2: 0.08, 3: 0.11, 4: 0.12, 5: 0.14 };
727
727
  var VARIANT_TINT_OPACITY = { light: 0.12, dark: 0.18 };
728
- var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = "surface", ...props }) => {
728
+ var getWebBlurStyle = (intensity, dark) => {
729
+ const amount = Math.min(intensity, 100);
730
+ const filter = `saturate(180%) blur(${amount * 0.2}px)`;
731
+ return { backdropFilter: filter, backgroundColor: `rgba(${dark ? "25,25,25" : "249,249,249"},${amount / 100 * 0.78})`, WebkitBackdropFilter: filter };
732
+ };
733
+ var BlurView = ({ blur = true, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant = "surface", ...props }) => {
729
734
  const { dark, colors } = (0, import_react_native_paper5.useTheme)();
730
735
  const { settings } = (0, import_react6.useContext)(ThemeSettingsContext);
731
736
  const expoBlur = useBlurModule();
@@ -734,13 +739,13 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
734
739
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== void 0 ? colors.primary : void 0);
735
740
  const resolvedTintOpacity = tintOpacity ?? (tintColor ? dark ? 0.24 : 0.16 : variantColor ? dark ? VARIANT_TINT_OPACITY.dark : VARIANT_TINT_OPACITY.light : elevation !== void 0 ? ELEVATION_OPACITY[elevation] : 0);
736
741
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [import_react_native4.StyleSheet.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }] }) : null;
737
- if (blur && expoBlur && (import_react_native4.Platform.OS !== "android" || blursOnAndroid(props)))
738
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(expoBlur.BlurView, { ...props, tint: dark ? "dark" : "light", style, children: [
739
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [import_react_native4.StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }),
740
- tint,
741
- children
742
- ] });
743
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_react_native4.View, { ...props, style: [{ backgroundColor: surfaceColor }, style], children: [
742
+ const blurProps = { blurMethod, blurReductionFactor, blurTarget, experimentalBlurMethod, intensity };
743
+ const blurs = blur && expoBlur !== void 0 && (import_react_native4.Platform.OS !== "android" || blursOnAndroid(blurProps));
744
+ const web = import_react_native4.Platform.OS === "web";
745
+ const containerStyle = !blurs ? [{ backgroundColor: surfaceColor }, style] : web ? [style, getWebBlurStyle(intensity ?? 50, dark)] : style;
746
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_react_native4.View, { ...props, style: containerStyle, children: [
747
+ blurs && !web ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(expoBlur.BlurView, { ...blurProps, style: import_react_native4.StyleSheet.absoluteFill, tint: dark ? "dark" : "light" }) : null,
748
+ blurs ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_react_native4.View, { style: [import_react_native4.StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }) : null,
744
749
  tint,
745
750
  children
746
751
  ] });
@@ -750,6 +755,8 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
750
755
  var import_jsx_runtime6 = require("react/jsx-runtime");
751
756
  var ANIMATION_DURATION = 220;
752
757
  var DIALOG_MARGIN = 32;
758
+ var AnimatedBlurView = import_react_native5.Animated.createAnimatedComponent(BlurView);
759
+ var AnimatedPressable = import_react_native5.Animated.createAnimatedComponent(import_react_native5.Pressable);
753
760
  function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = "dialog" }) {
754
761
  const blur = useBlur(blurProp);
755
762
  const reanimated = useReanimatedModule();
@@ -793,9 +800,16 @@ function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, di
793
800
  return child;
794
801
  });
795
802
  const borderRadius = (isV3 ? 7 : 1) * roundness;
796
- return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react_native_paper6.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_react_native5.Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [import_react_native5.StyleSheet.absoluteFill, { opacity }], testID, children: [
797
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react_native5.Pressable, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [import_react_native5.StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }], testID: `${testID}-backdrop` }),
798
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react_native5.View, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: reanimated && animatedStyle ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(reanimated.View, { style: animatedStyle, testID: `${testID}-animated-wrapper`, children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) })
803
+ const web = import_react_native5.Platform.OS === "web";
804
+ const fade = { opacity };
805
+ const styleOpacity = import_react_native5.StyleSheet.flatten(style)?.opacity;
806
+ const cardFade = typeof styleOpacity === "number" ? { opacity: import_react_native5.Animated.multiply(opacity, styleOpacity) } : fade;
807
+ const Backdrop = web ? AnimatedPressable : import_react_native5.Pressable;
808
+ const Card = web ? AnimatedBlurView : BlurView;
809
+ const card = /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Card, { blur, style: [styles2.card, { borderRadius }, style, web && cardFade], testID: `${testID}-surface`, children: content });
810
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react_native_paper6.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_react_native5.Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [import_react_native5.StyleSheet.absoluteFill, !web && fade], testID, children: [
811
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Backdrop, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [import_react_native5.StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }, web && fade], testID: `${testID}-backdrop` }),
812
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_react_native5.View, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: reanimated && animatedStyle ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(reanimated.View, { style: animatedStyle, testID: `${testID}-animated-wrapper`, children: card }) : card })
799
813
  ] }) });
800
814
  }
801
815
  DialogComponent.Title = import_react_native_paper6.Dialog.Title;
@@ -1036,25 +1050,45 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
1036
1050
  }
1037
1051
 
1038
1052
  // src/components/Menu.tsx
1053
+ var import_react11 = require("react");
1039
1054
  var import_react_native9 = require("react-native");
1040
1055
  var import_react_native_paper15 = require("react-native-paper");
1041
1056
  var import_jsx_runtime16 = require("react/jsx-runtime");
1057
+ var ANIMATION_DURATION2 = 250;
1058
+ var EASING = import_react_native9.Easing.bezier(0.4, 0, 0.2, 1);
1059
+ var DEFAULT_ELEVATION = 2;
1060
+ var SHADOW_HEIGHT = [0, 1, 2, 4, 6, 8];
1061
+ var SHADOW_RADIUS = [0, 3, 6, 8, 10, 12];
1062
+ var AnimatedBlurView2 = import_react_native9.Animated.createAnimatedComponent(BlurView);
1063
+ var FadingBlurView = ({ blur, children, elevation, theme: themeOverrides, visible }) => {
1064
+ const { animation, roundness } = (0, import_react_native_paper15.useTheme)(themeOverrides);
1065
+ const [opacity] = (0, import_react11.useState)(() => new import_react_native9.Animated.Value(0));
1066
+ const [closing, setClosing] = (0, import_react11.useState)(false);
1067
+ if (!visible && !closing) setClosing(true);
1068
+ (0, import_react11.useEffect)(() => {
1069
+ import_react_native9.Animated.timing(opacity, { duration: animation.scale * ANIMATION_DURATION2, easing: EASING, toValue: closing ? 0 : 1, useNativeDriver: false }).start();
1070
+ }, [animation.scale, closing, opacity]);
1071
+ const shadow = elevation > 0 ? { shadowColor: "#000000", shadowOffset: { height: SHADOW_HEIGHT[elevation], width: 0 }, shadowOpacity: 0.3, shadowRadius: SHADOW_RADIUS[elevation] } : null;
1072
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(AnimatedBlurView2, { blur, style: [{ borderRadius: roundness, opacity }, shadow], children });
1073
+ };
1042
1074
  var MenuComponent = ({ blur: blurProp, children, ...props }) => {
1043
1075
  const blur = useBlur(blurProp);
1044
- return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native_paper15.Menu, { ...props, contentStyle: [styles5.content, props.contentStyle], children: /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(BlurView, { blur, children }) });
1076
+ const web = import_react_native9.Platform.OS === "web";
1077
+ return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(import_react_native_paper15.Menu, { ...props, contentStyle: [styles5.content, web && styles5.webSurface, props.contentStyle], children: web ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(FadingBlurView, { blur, elevation: props.mode === "flat" ? 0 : props.elevation ?? DEFAULT_ELEVATION, theme: props.theme, visible: props.visible, children }) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(BlurView, { blur, children }) });
1045
1078
  };
1046
1079
  var Menu = Object.assign(MenuComponent, {
1047
1080
  Item: import_react_native_paper15.Menu.Item
1048
1081
  });
1049
1082
  var styles5 = import_react_native9.StyleSheet.create({
1050
- content: { backgroundColor: "transparent", paddingVertical: 0 }
1083
+ content: { backgroundColor: "transparent", paddingVertical: 0 },
1084
+ webSurface: { opacity: 1, shadowOpacity: 0 }
1051
1085
  });
1052
1086
 
1053
1087
  // src/components/TextInput.tsx
1054
- var import_react11 = require("react");
1088
+ var import_react12 = require("react");
1055
1089
  var import_react_native_paper16 = require("react-native-paper");
1056
1090
  var import_jsx_runtime17 = require("react/jsx-runtime");
1057
- var TextInput = (0, import_react11.forwardRef)(function TextInput2(props, ref) {
1091
+ var TextInput = (0, import_react12.forwardRef)(function TextInput2(props, ref) {
1058
1092
  const defaults = usePaperDefaults();
1059
1093
  return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(import_react_native_paper16.TextInput, { ref, ...defaults.TextInput, ...props });
1060
1094
  });
@@ -1085,10 +1119,10 @@ var import_react_native_paper17 = require("react-native-paper");
1085
1119
  var useAutoPaperTheme = () => (0, import_react_native_paper17.useTheme)();
1086
1120
 
1087
1121
  // src/useThemeBridgeProps.ts
1088
- var import_react12 = require("react");
1122
+ var import_react13 = require("react");
1089
1123
  function useThemeBridgeProps(props) {
1090
1124
  const { initialValue, onChange, onReady } = props;
1091
- return (0, import_react12.useMemo)(() => ({ initialValue, onChange, onReady }), [initialValue, onChange, onReady]);
1125
+ return (0, import_react13.useMemo)(() => ({ initialValue, onChange, onReady }), [initialValue, onChange, onReady]);
1092
1126
  }
1093
1127
 
1094
1128
  // src/utils/getThirdColor.ts
package/dist/index.mjs CHANGED
@@ -416,7 +416,7 @@ import { Icon, TouchableRipple, useTheme as useTheme4 } from "react-native-paper
416
416
 
417
417
  // src/components/Dialog.tsx
418
418
  import React, { useEffect as useEffect3, useState as useState3 } from "react";
419
- import { Animated, BackHandler, Easing, Pressable, StyleSheet as StyleSheet3, View as View3 } from "react-native";
419
+ import { Animated, BackHandler, Easing, Platform as Platform2, Pressable, StyleSheet as StyleSheet3, View as View3 } from "react-native";
420
420
  import { Dialog as PaperDialog, Portal as Portal2, useTheme as useTheme3 } from "react-native-paper";
421
421
  import { useSafeAreaInsets } from "react-native-safe-area-context";
422
422
 
@@ -642,7 +642,12 @@ var blursOnAndroid = ({ blurMethod, blurTarget, experimentalBlurMethod }) => {
642
642
  };
643
643
  var ELEVATION_OPACITY = { 0: 0, 1: 0.05, 2: 0.08, 3: 0.11, 4: 0.12, 5: 0.14 };
644
644
  var VARIANT_TINT_OPACITY = { light: 0.12, dark: 0.18 };
645
- var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = "surface", ...props }) => {
645
+ var getWebBlurStyle = (intensity, dark) => {
646
+ const amount = Math.min(intensity, 100);
647
+ const filter = `saturate(180%) blur(${amount * 0.2}px)`;
648
+ return { backdropFilter: filter, backgroundColor: `rgba(${dark ? "25,25,25" : "249,249,249"},${amount / 100 * 0.78})`, WebkitBackdropFilter: filter };
649
+ };
650
+ var BlurView = ({ blur = true, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant = "surface", ...props }) => {
646
651
  const { dark, colors } = useTheme2();
647
652
  const { settings } = useContext4(ThemeSettingsContext);
648
653
  const expoBlur = useBlurModule();
@@ -651,13 +656,13 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
651
656
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== void 0 ? colors.primary : void 0);
652
657
  const resolvedTintOpacity = tintOpacity ?? (tintColor ? dark ? 0.24 : 0.16 : variantColor ? dark ? VARIANT_TINT_OPACITY.dark : VARIANT_TINT_OPACITY.light : elevation !== void 0 ? ELEVATION_OPACITY[elevation] : 0);
653
658
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }] }) : null;
654
- if (blur && expoBlur && (Platform.OS !== "android" || blursOnAndroid(props)))
655
- return /* @__PURE__ */ jsxs3(expoBlur.BlurView, { ...props, tint: dark ? "dark" : "light", style, children: [
656
- /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }),
657
- tint,
658
- children
659
- ] });
660
- return /* @__PURE__ */ jsxs3(View2, { ...props, style: [{ backgroundColor: surfaceColor }, style], children: [
659
+ const blurProps = { blurMethod, blurReductionFactor, blurTarget, experimentalBlurMethod, intensity };
660
+ const blurs = blur && expoBlur !== void 0 && (Platform.OS !== "android" || blursOnAndroid(blurProps));
661
+ const web = Platform.OS === "web";
662
+ const containerStyle = !blurs ? [{ backgroundColor: surfaceColor }, style] : web ? [style, getWebBlurStyle(intensity ?? 50, dark)] : style;
663
+ return /* @__PURE__ */ jsxs3(View2, { ...props, style: containerStyle, children: [
664
+ blurs && !web ? /* @__PURE__ */ jsx5(expoBlur.BlurView, { ...blurProps, style: StyleSheet2.absoluteFill, tint: dark ? "dark" : "light" }) : null,
665
+ blurs ? /* @__PURE__ */ jsx5(View2, { style: [StyleSheet2.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }] }) : null,
661
666
  tint,
662
667
  children
663
668
  ] });
@@ -667,6 +672,8 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
667
672
  import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
668
673
  var ANIMATION_DURATION = 220;
669
674
  var DIALOG_MARGIN = 32;
675
+ var AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
676
+ var AnimatedPressable = Animated.createAnimatedComponent(Pressable);
670
677
  function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = "dialog" }) {
671
678
  const blur = useBlur(blurProp);
672
679
  const reanimated = useReanimatedModule();
@@ -710,9 +717,16 @@ function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, di
710
717
  return child;
711
718
  });
712
719
  const borderRadius = (isV3 ? 7 : 1) * roundness;
713
- return /* @__PURE__ */ jsx6(Portal2, { children: /* @__PURE__ */ jsxs4(Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [StyleSheet3.absoluteFill, { opacity }], testID, children: [
714
- /* @__PURE__ */ jsx6(Pressable, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [StyleSheet3.absoluteFill, { backgroundColor: colors.backdrop }], testID: `${testID}-backdrop` }),
715
- /* @__PURE__ */ jsx6(View3, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: reanimated && animatedStyle ? /* @__PURE__ */ jsx6(reanimated.View, { style: animatedStyle, testID: `${testID}-animated-wrapper`, children: /* @__PURE__ */ jsx6(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) }) : /* @__PURE__ */ jsx6(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) })
720
+ const web = Platform2.OS === "web";
721
+ const fade = { opacity };
722
+ const styleOpacity = StyleSheet3.flatten(style)?.opacity;
723
+ const cardFade = typeof styleOpacity === "number" ? { opacity: Animated.multiply(opacity, styleOpacity) } : fade;
724
+ const Backdrop = web ? AnimatedPressable : Pressable;
725
+ const Card = web ? AnimatedBlurView : BlurView;
726
+ const card = /* @__PURE__ */ jsx6(Card, { blur, style: [styles2.card, { borderRadius }, style, web && cardFade], testID: `${testID}-surface`, children: content });
727
+ return /* @__PURE__ */ jsx6(Portal2, { children: /* @__PURE__ */ jsxs4(Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [StyleSheet3.absoluteFill, !web && fade], testID, children: [
728
+ /* @__PURE__ */ jsx6(Backdrop, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [StyleSheet3.absoluteFill, { backgroundColor: colors.backdrop }, web && fade], testID: `${testID}-backdrop` }),
729
+ /* @__PURE__ */ jsx6(View3, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: reanimated && animatedStyle ? /* @__PURE__ */ jsx6(reanimated.View, { style: animatedStyle, testID: `${testID}-animated-wrapper`, children: card }) : card })
716
730
  ] }) });
717
731
  }
718
732
  DialogComponent.Title = PaperDialog.Title;
@@ -881,7 +895,7 @@ var AutoPalettePicker = (props) => {
881
895
 
882
896
  // src/components/BottomNavigation.tsx
883
897
  import { useEffect as useEffect4 } from "react";
884
- import { Platform as Platform2 } from "react-native";
898
+ import { Platform as Platform3 } from "react-native";
885
899
  import { BottomNavigation as PaperBottomNavigation, useTheme as useTheme6 } from "react-native-paper";
886
900
 
887
901
  // src/navigation-bar.ts
@@ -896,7 +910,7 @@ function useNavigationBarSync() {
896
910
  const theme = useTheme6();
897
911
  const { navigationBar, onNavBarChange } = useNavBarContext();
898
912
  useEffect4(() => {
899
- if (Platform2.OS !== "android") return;
913
+ if (Platform3.OS !== "android") return;
900
914
  if (onNavBarChange) onNavBarChange(theme.colors.surface, theme.dark);
901
915
  else if (navigationBar) setNavigationBarStyle(navigationBar, theme.dark);
902
916
  }, [theme, onNavBarChange, navigationBar]);
@@ -953,18 +967,38 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
953
967
  }
954
968
 
955
969
  // src/components/Menu.tsx
956
- import { StyleSheet as StyleSheet6 } from "react-native";
957
- import { Menu as PaperMenu } from "react-native-paper";
970
+ import { useEffect as useEffect5, useState as useState6 } from "react";
971
+ import { Animated as Animated2, Easing as Easing2, Platform as Platform4, StyleSheet as StyleSheet6 } from "react-native";
972
+ import { Menu as PaperMenu, useTheme as useTheme9 } from "react-native-paper";
958
973
  import { jsx as jsx16 } from "react/jsx-runtime";
974
+ var ANIMATION_DURATION2 = 250;
975
+ var EASING = Easing2.bezier(0.4, 0, 0.2, 1);
976
+ var DEFAULT_ELEVATION = 2;
977
+ var SHADOW_HEIGHT = [0, 1, 2, 4, 6, 8];
978
+ var SHADOW_RADIUS = [0, 3, 6, 8, 10, 12];
979
+ var AnimatedBlurView2 = Animated2.createAnimatedComponent(BlurView);
980
+ var FadingBlurView = ({ blur, children, elevation, theme: themeOverrides, visible }) => {
981
+ const { animation, roundness } = useTheme9(themeOverrides);
982
+ const [opacity] = useState6(() => new Animated2.Value(0));
983
+ const [closing, setClosing] = useState6(false);
984
+ if (!visible && !closing) setClosing(true);
985
+ useEffect5(() => {
986
+ Animated2.timing(opacity, { duration: animation.scale * ANIMATION_DURATION2, easing: EASING, toValue: closing ? 0 : 1, useNativeDriver: false }).start();
987
+ }, [animation.scale, closing, opacity]);
988
+ const shadow = elevation > 0 ? { shadowColor: "#000000", shadowOffset: { height: SHADOW_HEIGHT[elevation], width: 0 }, shadowOpacity: 0.3, shadowRadius: SHADOW_RADIUS[elevation] } : null;
989
+ return /* @__PURE__ */ jsx16(AnimatedBlurView2, { blur, style: [{ borderRadius: roundness, opacity }, shadow], children });
990
+ };
959
991
  var MenuComponent = ({ blur: blurProp, children, ...props }) => {
960
992
  const blur = useBlur(blurProp);
961
- return /* @__PURE__ */ jsx16(PaperMenu, { ...props, contentStyle: [styles5.content, props.contentStyle], children: /* @__PURE__ */ jsx16(BlurView, { blur, children }) });
993
+ const web = Platform4.OS === "web";
994
+ return /* @__PURE__ */ jsx16(PaperMenu, { ...props, contentStyle: [styles5.content, web && styles5.webSurface, props.contentStyle], children: web ? /* @__PURE__ */ jsx16(FadingBlurView, { blur, elevation: props.mode === "flat" ? 0 : props.elevation ?? DEFAULT_ELEVATION, theme: props.theme, visible: props.visible, children }) : /* @__PURE__ */ jsx16(BlurView, { blur, children }) });
962
995
  };
963
996
  var Menu = Object.assign(MenuComponent, {
964
997
  Item: PaperMenu.Item
965
998
  });
966
999
  var styles5 = StyleSheet6.create({
967
- content: { backgroundColor: "transparent", paddingVertical: 0 }
1000
+ content: { backgroundColor: "transparent", paddingVertical: 0 },
1001
+ webSurface: { opacity: 1, shadowOpacity: 0 }
968
1002
  });
969
1003
 
970
1004
  // src/components/TextInput.tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "Adaptive react-native-paper theming: triadic palette from a seed color + system/light/dark appearance",
5
5
  "keywords": [
6
6
  "react-native",
@@ -30,7 +30,7 @@ export type ProviderProps = {
30
30
  /** Injects expo-blur for `<BlurView>`. Pass `import * as ExpoBlur from 'expo-blur'`; omit to always render BlurView's solid fallback. */
31
31
  expoBlur?: ExpoBlurModule
32
32
  /** Applied to every Paper typography variant (bodyLarge, headlineMedium, labelSmall, ...) via
33
- * `configureFonts`'s flat-config mode — each variant keeps its own MD3 fontSize/lineHeight/weight,
33
+ * `configureFonts`'s flat-config mode - each variant keeps its own MD3 fontSize/lineHeight/weight,
34
34
  * only fontFamily changes. The one-line way to reskin an app's whole text to a custom typeface,
35
35
  * instead of passing `fontFamily` to every individual Text/TextInput/Button across the app. Omit
36
36
  * to keep each platform's system font (MD3's own default). */
@@ -48,7 +48,7 @@ export type ProviderProps = {
48
48
  }
49
49
 
50
50
  // createSettingsContext's generated Provider only exposes `settings` to descendants (via its
51
- // generated useSettings()) — it can't itself also compute the theme/gate mounting on it being
51
+ // generated useSettings()) - it can't itself also compute the theme/gate mounting on it being
52
52
  // ready, since that logic needs `settings` in scope. Provider (below) is just this thin outer
53
53
  // wrapper for the settings half; ThemeProviderBody (an actual descendant, reached via
54
54
  // useThemeSettingsInternal()) does all the theme-computation/gating work the single combined
@@ -71,7 +71,7 @@ function ThemeProviderBody({ children, defaults, expoBlur, fontFamily, navigatio
71
71
  const { settings } = useThemeSettingsInternal()
72
72
 
73
73
  const computedTheme = useComputedTheme(settings.appearance, settings.color, settings.harmony)
74
- // Layered on top of useComputedTheme's own output rather than folded into it — that hook only
74
+ // Layered on top of useComputedTheme's own output rather than folded into it - that hook only
75
75
  // ever recomputes for a color/appearance/harmony change, and fontFamily is a completely
76
76
  // independent axis; merging it there would mean either re-running the whole color computation on
77
77
  // every fontFamily change (wasteful) or threading fontFamily through as a fourth computeTheme
@@ -8,7 +8,7 @@ type Props = {
8
8
 
9
9
  // AppearancePicker wired straight to the Provider's own ThemeSettings, for the common case where
10
10
  // the appearance being edited IS the whole app's own theme. Takes neither `value` nor `onChange`
11
- // — reads/writes useThemeSettings() itself, so there's nothing for a consumer to wire up. Reach
11
+ // - reads/writes useThemeSettings() itself, so there's nothing for a consumer to wire up. Reach
12
12
  // for the plain AppearancePicker instead when it needs to be controlled by something other than
13
13
  // the global theme (a local preview before committing, one-off UI unrelated to Provider).
14
14
  export const AutoAppearancePicker = (props: Props) => {
@@ -9,9 +9,9 @@ type Props = {
9
9
  blur?: boolean
10
10
  }
11
11
 
12
- // PalettePicker wired straight to the Provider's own ThemeSettings — both color AND harmony —
12
+ // PalettePicker wired straight to the Provider's own ThemeSettings - both color AND harmony -
13
13
  // for the common case where the palette being edited IS the whole app's own theme. Takes none of
14
- // `value`/`onChange`/`harmony`/`onHarmonyChange` — reads/writes useThemeSettings() itself
14
+ // `value`/`onChange`/`harmony`/`onHarmonyChange` - reads/writes useThemeSettings() itself
15
15
  // (resolveSeedColor narrows ThemeSettings.color's `string | TriadicPalette` down to the plain
16
16
  // string PalettePicker's own `value` wants). Reach for the plain PalettePicker instead when it
17
17
  // needs to be controlled by something other than the global theme.
@@ -58,7 +58,16 @@ export type BlurViewProps = ExpoBlurViewProps & {
58
58
  variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'surface' | 'surfaceVariant'
59
59
  }
60
60
 
61
- export const BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = 'surface', ...props }: BlurViewProps) => {
61
+ // The style expo-blur's web BlurView puts on its View (getBlurStyle in its src/BlurView.web.tsx, with
62
+ // getBackgroundColor's 'dark' and 'light' tints, the only two this component uses): a CSS
63
+ // backdrop-filter plus a translucent tint. On web BlurView applies it to its own View instead.
64
+ const getWebBlurStyle = (intensity: number, dark: boolean) => {
65
+ const amount = Math.min(intensity, 100)
66
+ const filter = `saturate(180%) blur(${amount * 0.2}px)`
67
+ return { backdropFilter: filter, backgroundColor: `rgba(${dark ? '25,25,25' : '249,249,249'},${(amount / 100) * 0.78})`, WebkitBackdropFilter: filter } as ViewStyle
68
+ }
69
+
70
+ export const BlurView = ({ blur = true, blurMethod, blurReductionFactor, blurTarget, children, elevation, experimentalBlurMethod, intensity, style, tint: _tint, tintColor, tintOpacity, variant = 'surface', ...props }: BlurViewProps) => {
62
71
  const { dark, colors } = useTheme()
63
72
  const { settings } = useContext(ThemeSettingsContext)
64
73
  const expoBlur = useBlurModule()
@@ -67,16 +76,21 @@ export const BlurView = ({ blur = true, children, elevation, style, tintColor, t
67
76
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== undefined ? colors.primary : undefined)
68
77
  const resolvedTintOpacity = tintOpacity ?? (tintColor ? (dark ? 0.24 : 0.16) : variantColor ? (dark ? VARIANT_TINT_OPACITY.dark : VARIANT_TINT_OPACITY.light) : elevation !== undefined ? ELEVATION_OPACITY[elevation] : 0)
69
78
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? <View style={[StyleSheet.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }]} /> : null
70
- if (blur && expoBlur && (Platform.OS !== 'android' || blursOnAndroid(props)))
71
- return (
72
- <expoBlur.BlurView {...props} tint={dark ? 'dark' : 'light'} style={style}>
73
- <View style={[StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }]} />
74
- {tint}
75
- {children}
76
- </expoBlur.BlurView>
77
- )
79
+ const blurProps = { blurMethod, blurReductionFactor, blurTarget, experimentalBlurMethod, intensity }
80
+ const blurs = blur && expoBlur !== undefined && (Platform.OS !== 'android' || blursOnAndroid(blurProps))
81
+ const web = Platform.OS === 'web'
82
+ // The View holding the children is the same element whether or not blur applies, so flipping
83
+ // `blur` (a Dialog or Menu following the player's live settings.blur) never remounts them: their
84
+ // state, scroll offset, focus and ripples all survive. On iOS and Android the blur is an absoluteFill
85
+ // layer behind everything else, the same structure as expo-blur's own native BlurView. On web it
86
+ // can't be: Dialog and Menu fade this View's own opacity there, and an ancestor below opacity 1 cuts
87
+ // a CSS backdrop-filter off from the page behind it, so on web this View itself is the
88
+ // backdrop-filter element, styled the way expo-blur's web BlurView styles its own.
89
+ const containerStyle = !blurs ? [{ backgroundColor: surfaceColor }, style] : web ? [style, getWebBlurStyle(intensity ?? 50, dark)] : style
78
90
  return (
79
- <View {...props} style={[{ backgroundColor: surfaceColor }, style]}>
91
+ <View {...props} style={containerStyle}>
92
+ {blurs && !web ? <expoBlur.BlurView {...blurProps} style={StyleSheet.absoluteFill} tint={dark ? 'dark' : 'light'} /> : null}
93
+ {blurs ? <View style={[StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }]} /> : null}
80
94
  {tint}
81
95
  {children}
82
96
  </View>
@@ -1,5 +1,5 @@
1
1
  import React, { type ComponentType, type ReactNode, useEffect, useState } from 'react'
2
- import { Animated, BackHandler, Easing, Pressable, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
2
+ import { Animated, BackHandler, Easing, Platform, Pressable, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'
3
3
  import { Dialog as PaperDialog, type DialogActionsProps, type DialogContentProps, type DialogProps as PaperDialogProps, type DialogScrollAreaProps, type DialogTitleProps, Portal, useTheme } from 'react-native-paper'
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
5
5
 
@@ -7,11 +7,11 @@ import { useBlur } from '../BlurContext'
7
7
  import { useReanimatedModule } from '../ThemeProvider'
8
8
  import { BlurView } from './BlurView'
9
9
 
10
- // Minimal local mirror of react-native-reanimated's Animated.View — same reasoning as BlurView's
10
+ // Minimal local mirror of react-native-reanimated's Animated.View - same reasoning as BlurView's
11
11
  // ExpoBlurModule: Metro doesn't rewrite a require()-in-try/catch call into its module graph inside
12
12
  // an ESM build, so the already-imported peer module is injected via <Provider reanimated={...}>
13
13
  // (see ThemeProvider.tsx) instead of required directly. Only `View` is needed here. `style: any`,
14
- // not a real style type — reanimated's actual AnimatedView only accepts its own branded
14
+ // not a real style type - reanimated's actual AnimatedView only accepts its own branded
15
15
  // AnimatedStyle shape, which this package never imports (no reanimated dependency at all), so `any`
16
16
  // is the only type that both sides (this interface and the real component passed in) can agree on.
17
17
  export interface ReanimatedModule {
@@ -23,7 +23,7 @@ export type DialogProps = Omit<PaperDialogProps, 'style'> & {
23
23
  blur?: boolean
24
24
  style?: StyleProp<ViewStyle>
25
25
  // Applied to the card's outer wrapper instead of the card itself, and only takes effect once a
26
- // reanimated module has been injected via <Provider reanimated={...}> — otherwise ignored. `style`
26
+ // reanimated module has been injected via <Provider reanimated={...}> - otherwise ignored. `style`
27
27
  // above is a plain object read once per render, so a fast-changing value (e.g. tracking the
28
28
  // keyboard height every frame) driven through it needs a full JS re-render per update to paint at
29
29
  // all; wiring the same value through `animatedStyle` (e.g. the result of Reanimated's
@@ -39,6 +39,9 @@ const ANIMATION_DURATION = 220
39
39
  // Minimum breathing room between the card and the screen edge, clamped up from safe-area insets.
40
40
  const DIALOG_MARGIN = 32
41
41
 
42
+ const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
43
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
44
+
42
45
  function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = 'dialog' }: DialogProps) {
43
46
  const blur = useBlur(blurProp)
44
47
  const reanimated = useReanimatedModule()
@@ -93,21 +96,34 @@ function DialogComponent({ animatedStyle, blur: blurProp, dismissable = true, di
93
96
 
94
97
  const borderRadius = (isV3 ? 7 : 1) * roundness
95
98
 
99
+ // On web, an ancestor below opacity 1 cuts the card's CSS backdrop-filter off from the page behind
100
+ // it, and Chromium doesn't restore the blur once that ancestor fades back up to 1, so fading the
101
+ // whole layer would leave the open card see-through. There the fade runs on the backdrop and on the
102
+ // card (the backdrop-filter element) itself instead, off the same value. A numeric opacity in the
103
+ // card's own style still applies there, multiplied into the fade.
104
+ const web = Platform.OS === 'web'
105
+ const fade = { opacity }
106
+ const styleOpacity = StyleSheet.flatten(style)?.opacity
107
+ const cardFade = typeof styleOpacity === 'number' ? { opacity: Animated.multiply(opacity, styleOpacity) } : fade
108
+ const Backdrop = web ? AnimatedPressable : Pressable
109
+ const Card = web ? AnimatedBlurView : BlurView
110
+ const card = (
111
+ <Card blur={blur} style={[styles.card, { borderRadius }, style, web && cardFade]} testID={`${testID}-surface`}>
112
+ {content}
113
+ </Card>
114
+ )
115
+
96
116
  return (
97
117
  <Portal>
98
- <Animated.View accessibilityLiveRegion='polite' accessibilityViewIsModal pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, { opacity }]} testID={testID}>
99
- <Pressable accessibilityLabel='Close modal' accessibilityRole='button' disabled={!dismissable} onPress={dismissable ? onDismiss : undefined} style={[StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }]} testID={`${testID}-backdrop`} />
118
+ <Animated.View accessibilityLiveRegion='polite' accessibilityViewIsModal pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, !web && fade]} testID={testID}>
119
+ <Backdrop accessibilityLabel='Close modal' accessibilityRole='button' disabled={!dismissable} onPress={dismissable ? onDismiss : undefined} style={[StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }, web && fade]} testID={`${testID}-backdrop`} />
100
120
  <View pointerEvents='box-none' style={[styles.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }]} testID={`${testID}-wrapper`}>
101
121
  {reanimated && animatedStyle ? (
102
122
  <reanimated.View style={animatedStyle} testID={`${testID}-animated-wrapper`}>
103
- <BlurView blur={blur} style={[styles.card, { borderRadius }, style]} testID={`${testID}-surface`}>
104
- {content}
105
- </BlurView>
123
+ {card}
106
124
  </reanimated.View>
107
125
  ) : (
108
- <BlurView blur={blur} style={[styles.card, { borderRadius }, style]} testID={`${testID}-surface`}>
109
- {content}
110
- </BlurView>
126
+ card
111
127
  )}
112
128
  </View>
113
129
  </Animated.View>
@@ -13,12 +13,12 @@ const HARMONIES: { label: string; value: ColorHarmony; icon: string }[] = [
13
13
 
14
14
  // Paper's own SegmentedButtonItem hardcodes `minWidth: 76` per segment (sized for a labeled text
15
15
  // button), which 6 of never fit inside PalettePicker's own dialog card (capped at 400, and
16
- // narrower still once its padding and safe-area margins are subtracted) — they just overflow past
16
+ // narrower still once its padding and safe-area margins are subtracted) - they just overflow past
17
17
  // its `overflow: hidden` edge instead of shrinking. A fixed smaller guess (say 44) only moves the
18
- // breakpoint rather than removing it — 6x44 still exceeds the card's content width on an ordinary
18
+ // breakpoint rather than removing it - 6x44 still exceeds the card's content width on an ordinary
19
19
  // ~360-375px phone. Zeroing the floor instead lets each segment's own `flex: 1` divide whatever
20
20
  // width IS available with nothing left to enforce a minimum, so 6 of them mathematically can never
21
- // exceed their container at any screen size — same "shrink to fit, never overflow" contract
21
+ // exceed their container at any screen size - same "shrink to fit, never overflow" contract
22
22
  // useSwatchGrid.ts's own SWATCH_GRID_COLUMNS already uses for the swatches below this row. Left
23
23
  // untouched in labeled mode, where the wider floor is still doing its job of fitting the text.
24
24
  const COMPACT_MIN_WIDTH = 0
@@ -26,13 +26,13 @@ const COMPACT_MIN_WIDTH = 0
26
26
  type Props = {
27
27
  value: ColorHarmony
28
28
  onChange: (harmony: ColorHarmony) => void
29
- // Hides text labels, leaving just the icon per segment — mirrors AppearancePicker's own
29
+ // Hides text labels, leaving just the icon per segment - mirrors AppearancePicker's own
30
30
  // showLabels, useful here since there are 6 segments to fit instead of 3.
31
31
  // accessibilityLabel is always set regardless, so screen readers still announce the full
32
32
  // harmony name either way.
33
33
  showLabels?: boolean
34
34
  // Per-segment fill, e.g. theme.colors.secondary/onSecondary for the checked segment and
35
- // secondaryContainer/onSecondaryContainer for the rest — matches Paper's own checked/
35
+ // secondaryContainer/onSecondaryContainer for the rest - matches Paper's own checked/
36
36
  // unchecked + container color vocabulary. Omit to fall back to Paper's default segmented-
37
37
  // button styling.
38
38
  checkedColor?: string
@@ -1,6 +1,6 @@
1
- import React from 'react'
2
- import { StyleSheet } from 'react-native'
3
- import { Menu as PaperMenu, type MenuItemProps, type MenuProps as PaperMenuProps } from 'react-native-paper'
1
+ import React, { type ReactNode, useEffect, useState } from 'react'
2
+ import { Animated, Easing, Platform, StyleSheet } from 'react-native'
3
+ import { type MD2Theme, type MD3Theme, Menu as PaperMenu, type MenuItemProps, type MenuProps as PaperMenuProps, useTheme } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
6
  import { BlurView } from './BlurView'
@@ -9,11 +9,61 @@ export type MenuProps = PaperMenuProps & {
9
9
  blur?: boolean
10
10
  }
11
11
 
12
+ // Mirror react-native-paper's Menu: its fade (ANIMATION_DURATION * theme.animation.scale, eased with
13
+ // EASING), its default elevation, and the MD3 shadow its Surface draws at each elevation level.
14
+ const ANIMATION_DURATION = 250
15
+ const EASING = Easing.bezier(0.4, 0, 0.2, 1)
16
+ const DEFAULT_ELEVATION = 2
17
+ const SHADOW_HEIGHT = [0, 1, 2, 4, 6, 8]
18
+ const SHADOW_RADIUS = [0, 3, 6, 8, 10, 12]
19
+
20
+ const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
21
+
22
+ type FadingBlurViewProps = {
23
+ blur: boolean
24
+ children: ReactNode
25
+ elevation: number
26
+ theme: PaperMenuProps['theme']
27
+ visible: boolean
28
+ }
29
+
30
+ // Paper fades the menu's Surface in and out. On web, an ancestor below opacity 1 cuts BlurView's CSS
31
+ // backdrop-filter off from the page behind it, and Chromium doesn't restore the blur once the Surface
32
+ // reaches 1, so the open menu stays see-through until something else repaints it. So on web the Surface
33
+ // is pinned at opacity 1 with its shadow hidden, and BlurView itself (the backdrop-filter element) runs
34
+ // Paper's fades and draws that shadow, so the two still fade together.
35
+ const FadingBlurView = ({ blur, children, elevation, theme: themeOverrides, visible }: FadingBlurViewProps) => {
36
+ const { animation, roundness } = useTheme<MD2Theme | MD3Theme>(themeOverrides)
37
+ const [opacity] = useState(() => new Animated.Value(0))
38
+ // Once Paper starts hiding the menu it unmounts this content when that fade-out ends, even if visible
39
+ // turns back on first, so a close is final here too rather than fading back in only to vanish.
40
+ const [closing, setClosing] = useState(false)
41
+ if (!visible && !closing) setClosing(true)
42
+
43
+ useEffect(() => {
44
+ Animated.timing(opacity, { duration: animation.scale * ANIMATION_DURATION, easing: EASING, toValue: closing ? 0 : 1, useNativeDriver: false }).start()
45
+ }, [animation.scale, closing, opacity])
46
+
47
+ const shadow = elevation > 0 ? { shadowColor: '#000000', shadowOffset: { height: SHADOW_HEIGHT[elevation], width: 0 }, shadowOpacity: 0.3, shadowRadius: SHADOW_RADIUS[elevation] } : null
48
+ return (
49
+ <AnimatedBlurView blur={blur} style={[{ borderRadius: roundness, opacity }, shadow]}>
50
+ {children}
51
+ </AnimatedBlurView>
52
+ )
53
+ }
54
+
12
55
  const MenuComponent = ({ blur: blurProp, children, ...props }: MenuProps) => {
13
56
  const blur = useBlur(blurProp)
57
+ const web = Platform.OS === 'web'
14
58
  return (
15
- <PaperMenu {...props} contentStyle={[styles.content, props.contentStyle]}>
16
- <BlurView blur={blur}>{children}</BlurView>
59
+ <PaperMenu {...props} contentStyle={[styles.content, web && styles.webSurface, props.contentStyle]}>
60
+ {web ? (
61
+ <FadingBlurView blur={blur} elevation={props.mode === 'flat' ? 0 : (props.elevation ?? DEFAULT_ELEVATION)} theme={props.theme} visible={props.visible}>
62
+ {children}
63
+ </FadingBlurView>
64
+ ) : (
65
+ <BlurView blur={blur}>{children}</BlurView>
66
+ )}
17
67
  </PaperMenu>
18
68
  )
19
69
  }
@@ -23,5 +73,6 @@ export const Menu = Object.assign(MenuComponent, {
23
73
  })
24
74
 
25
75
  const styles = StyleSheet.create({
26
- content: { backgroundColor: 'transparent', paddingVertical: 0 }
76
+ content: { backgroundColor: 'transparent', paddingVertical: 0 },
77
+ webSurface: { opacity: 1, shadowOpacity: 0 }
27
78
  })
@@ -58,7 +58,7 @@ type Props = {
58
58
  harmony?: ColorHarmony
59
59
  // Opt-in: passing this turns on a compact, icon-only HarmonyPicker row inside the dialog, above
60
60
  // the swatch grid, so every swatch's own Pie preview reshapes live as harmony changes. Omit it
61
- // and this renders exactly as a plain PalettePicker always has — same trigger, same dialog,
61
+ // and this renders exactly as a plain PalettePicker always has - same trigger, same dialog,
62
62
  // harmony fixed at whatever the `harmony` prop says. Gated on the callback itself rather than a
63
63
  // separate boolean so there's no way to pass one without the other.
64
64
  onHarmonyChange?: (harmony: ColorHarmony) => void
@@ -29,7 +29,7 @@ export type ThemeAppearance = 'system' | 'light' | 'dark'
29
29
  // HelperText, Badge) read theme.colors.error/onError directly, so a consumer that wants a
30
30
  // getColorRoles-derived "high severity" color without changing what those components render needs
31
31
  // its own name. #B00020 already clears WCAG AA for both onDanger-vs-danger (6.16:1) and
32
- // dangerContainer/onDangerContainer (12–17:1 across modes), verified the same way as warning's
32
+ // dangerContainer/onDangerContainer (12-17:1 across modes), verified the same way as warning's
33
33
  // base above.
34
34
  export const SEMANTIC_BASE_COLORS = {
35
35
  success: '#2E7D32',
@@ -1,7 +1,7 @@
1
1
  import { getHex } from './getHex'
2
2
  import { getRgb } from './getRgb'
3
3
 
4
- // Returns the color with lightness clamped to the given target (0–1), preserving hue and saturation.
4
+ // Returns the color with lightness clamped to the given target (0-1), preserving hue and saturation.
5
5
  export const getTonalColor = (color: string, lightness: number): string => {
6
6
  const rgb = getRgb(color)
7
7
  if (!rgb) return color
@@ -1,8 +1,8 @@
1
1
  import type { TriadicPalette } from './getTriadicPalette'
2
2
 
3
- // ThemeSettings.color is `string | TriadicPalette` — a plain seed hex normally, or an explicit
3
+ // ThemeSettings.color is `string | TriadicPalette` - a plain seed hex normally, or an explicit
4
4
  // triad when a consumer bypassed harmony math entirely (see the README's own "Explicit triad"
5
- // section). Anything that needs a single representative color from it — a picker's own `value`,
6
- // a card's accent — narrows via this: `.primary` is that triad's own seed, so it's the
5
+ // section). Anything that needs a single representative color from it - a picker's own `value`,
6
+ // a card's accent - narrows via this: `.primary` is that triad's own seed, so it's the
7
7
  // representative color on the rare path where this branch is actually hit.
8
8
  export const resolveSeedColor = (color: string | TriadicPalette): string => (typeof color === 'string' ? color : color.primary)