@rific/auto-paper 0.6.4 → 0.7.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 CHANGED
@@ -8,7 +8,7 @@ Adaptive theming for [`react-native-paper`](https://callstack.github.io/react-na
8
8
  - System/light/dark appearance with live updates via `Appearance` API
9
9
  - Tinted surface, surfaceVariant, outline, and elevation levels derived from the seed
10
10
  - Optional Redux slice for wiring appearance and color into your store
11
- - Wrapper components (`Appbar`, `AppearancePicker`, `BlurView`, `BottomNavigation`, `Button`, `Chip`, `ColorPicker`, `Dialog`, `FAB`, `HarmonyPicker`, `IconButton`, `Menu`, `TextInput`) with prop defaults via context
11
+ - Wrapper components (`Appbar`, `AppearancePicker`, `BlurView`, `BottomNavigation`, `Button`, `Chip`, `ColorPicker`, `Dialog`, `FAB`, `HarmonyPicker`, `IconButton`, `Menu`, `PalettePicker`, `TextInput`) with prop defaults via context
12
12
  - All color utilities exported for standalone use
13
13
 
14
14
  ## Installation
@@ -111,7 +111,7 @@ import { Button, Chip, Provider as AutoPaperProvider } from '@rific/auto-paper'
111
111
  </AutoPaperProvider>
112
112
  ```
113
113
 
114
- Available wrapper components: `Appbar`, `AppearancePicker`, `BlurView`, `BottomNavigation`, `Button`, `Chip`, `ColorPicker`, `Dialog`, `FAB`, `HarmonyPicker`, `IconButton`, `Menu`, `TextInput`. Each is a thin wrapper around the matching `react-native-paper` component and accepts the same props.
114
+ Available wrapper components: `Appbar`, `AppearancePicker`, `BlurView`, `BottomNavigation`, `Button`, `Chip`, `ColorPicker`, `Dialog`, `FAB`, `HarmonyPicker`, `IconButton`, `Menu`, `PalettePicker`, `TextInput`. Each is a thin wrapper around the matching `react-native-paper` component and accepts the same props.
115
115
 
116
116
  ### With Redux
117
117
 
@@ -309,6 +309,28 @@ import { IconButton } from '@rific/auto-paper'
309
309
 
310
310
  `IconButtonProps` is exported from `@rific/auto-paper` and extends `react-native-paper`'s `IconButtonProps` with the `variant` field. Use `PaperIconButtonProps` if you need the base paper type.
311
311
 
312
+ ### `PalettePicker`
313
+
314
+ 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.
315
+
316
+ ```tsx
317
+ import { PalettePicker, useThemeSettings } from '@rific/auto-paper'
318
+
319
+ function SettingsScreen() {
320
+ const { settings: { color, harmony }, set } = useThemeSettings()
321
+ return <PalettePicker value={color} harmony={harmony} onChange={(c) => set({ color: c })} />
322
+ }
323
+ ```
324
+
325
+ | Prop | Type | Description |
326
+ |---|---|---|
327
+ | `value` | `string` | Currently selected seed color |
328
+ | `onChange` | `(color: string) => void` | Called with the seed color when a swatch is tapped |
329
+ | `harmony` | `ColorHarmony` | Harmony used to compute each swatch's preview palette. Defaults to `'split-complementary'` |
330
+ | `weights` | `PaletteWeights` | Relative size of each wedge — `{ primary, secondary, tertiary }`. Defaults to `{ primary: 2, secondary: 1, tertiary: 1 }` (primary half the pie, secondary/tertiary a quarter each). Values are normalized, so any positive ratio works; keep each share at or under half the total so its wedge stays a single slice |
331
+ | `colors` | `SeedColor[]` | Seed color swatches to offer. Defaults to the same set as `ColorPicker` |
332
+ | `blur` | `boolean` | Overrides the ambient blur setting for this component's dialog |
333
+
312
334
  ### `PaperDefaults` / `usePaperDefaults`
313
335
 
314
336
  `PaperDefaults` is the type for the `defaults` prop on `Provider`:
package/dist/index.d.mts CHANGED
@@ -31,11 +31,11 @@ declare function getTriadicPalette(primaryColor: string, harmony?: ColorHarmony)
31
31
  type ThemeAppearance = 'system' | 'light' | 'dark';
32
32
  declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): MD3Theme | null;
33
33
 
34
- type Props$2 = {
34
+ type Props$3 = {
35
35
  value: ThemeAppearance;
36
36
  onChange: (appearance: ThemeAppearance) => void;
37
37
  };
38
- declare const AppearancePicker: ({ value, onChange }: Props$2) => react.JSX.Element;
38
+ declare const AppearancePicker: ({ value, onChange }: Props$3) => react.JSX.Element;
39
39
 
40
40
  type ExpoBlurTint = 'light' | 'dark' | 'default' | 'extraLight' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
41
41
  type ExpoBlurMethod = 'none' | 'dimezisBlurView' | 'dimezisBlurViewSdk31Plus';
@@ -86,18 +86,19 @@ type SeedColor = {
86
86
  value: string;
87
87
  };
88
88
  declare const defaultColors: SeedColor[];
89
- type Props$1 = {
89
+ type Props$2 = {
90
90
  value: string;
91
91
  onChange: (color: string) => void;
92
92
  colors?: SeedColor[];
93
93
  blur?: boolean;
94
94
  };
95
- declare const ColorPicker: ({ value, onChange, colors, blur: blurProp }: Props$1) => react.JSX.Element;
95
+ declare const ColorPicker: ({ value, onChange, colors, blur: blurProp }: Props$2) => react.JSX.Element;
96
96
 
97
- type DialogProps = DialogProps$1 & {
97
+ type DialogProps = Omit<DialogProps$1, 'style'> & {
98
98
  blur?: boolean;
99
+ style?: StyleProp<ViewStyle>;
99
100
  };
100
- declare function DialogComponent({ blur: blurProp, visible, onDismiss, children, style, ...rest }: DialogProps): react__default.JSX.Element | null;
101
+ declare function DialogComponent({ blur: blurProp, dismissable, dismissableBackButton, visible, onDismiss, children, style, testID }: DialogProps): react__default.JSX.Element | null;
101
102
  declare namespace DialogComponent {
102
103
  var Title: react__default.FC<DialogTitleProps>;
103
104
  var Content: react__default.FC<DialogContentProps>;
@@ -108,12 +109,12 @@ declare const Dialog: typeof DialogComponent;
108
109
 
109
110
  declare function FAB(props: FABProps): react.JSX.Element;
110
111
 
111
- type Props = {
112
+ type Props$1 = {
112
113
  value: ColorHarmony;
113
114
  onChange: (harmony: ColorHarmony) => void;
114
115
  blur?: boolean;
115
116
  };
116
- declare const HarmonyPicker: ({ value, onChange, blur: blurProp }: Props) => react.JSX.Element;
117
+ declare const HarmonyPicker: ({ value, onChange, blur: blurProp }: Props$1) => react.JSX.Element;
117
118
 
118
119
  type IconButtonProps = IconButtonProps$1 & {
119
120
  variant?: 'primary' | 'secondary' | 'tertiary' | 'surface';
@@ -127,6 +128,21 @@ declare const Menu: (({ blur: blurProp, children, ...props }: MenuProps) => reac
127
128
  Item: react__default.FC<MenuItemProps>;
128
129
  };
129
130
 
131
+ type PaletteWeights = {
132
+ primary: number;
133
+ secondary: number;
134
+ tertiary: number;
135
+ };
136
+ type Props = {
137
+ value: string;
138
+ onChange: (color: string) => void;
139
+ harmony?: ColorHarmony;
140
+ weights?: PaletteWeights;
141
+ colors?: SeedColor[];
142
+ blur?: boolean;
143
+ };
144
+ declare const PalettePicker: ({ value, onChange, harmony, weights, colors, blur: blurProp }: Props) => react.JSX.Element;
145
+
130
146
  declare const TextInput: react.ForwardRefExoticComponent<Omit<TextInputProps, "ref"> & react.RefAttributes<TextInput$1>>;
131
147
 
132
148
  type PaperDefaults = {
@@ -244,4 +260,4 @@ declare const getTonalColor: (color: string, lightness: number) => string;
244
260
 
245
261
  declare const isDarkColor: (value: string) => boolean;
246
262
 
247
- export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
263
+ export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
package/dist/index.d.ts CHANGED
@@ -31,11 +31,11 @@ declare function getTriadicPalette(primaryColor: string, harmony?: ColorHarmony)
31
31
  type ThemeAppearance = 'system' | 'light' | 'dark';
32
32
  declare function useComputedTheme(appearance: ThemeAppearance, color: string, harmony?: ColorHarmony): MD3Theme | null;
33
33
 
34
- type Props$2 = {
34
+ type Props$3 = {
35
35
  value: ThemeAppearance;
36
36
  onChange: (appearance: ThemeAppearance) => void;
37
37
  };
38
- declare const AppearancePicker: ({ value, onChange }: Props$2) => react.JSX.Element;
38
+ declare const AppearancePicker: ({ value, onChange }: Props$3) => react.JSX.Element;
39
39
 
40
40
  type ExpoBlurTint = 'light' | 'dark' | 'default' | 'extraLight' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark';
41
41
  type ExpoBlurMethod = 'none' | 'dimezisBlurView' | 'dimezisBlurViewSdk31Plus';
@@ -86,18 +86,19 @@ type SeedColor = {
86
86
  value: string;
87
87
  };
88
88
  declare const defaultColors: SeedColor[];
89
- type Props$1 = {
89
+ type Props$2 = {
90
90
  value: string;
91
91
  onChange: (color: string) => void;
92
92
  colors?: SeedColor[];
93
93
  blur?: boolean;
94
94
  };
95
- declare const ColorPicker: ({ value, onChange, colors, blur: blurProp }: Props$1) => react.JSX.Element;
95
+ declare const ColorPicker: ({ value, onChange, colors, blur: blurProp }: Props$2) => react.JSX.Element;
96
96
 
97
- type DialogProps = DialogProps$1 & {
97
+ type DialogProps = Omit<DialogProps$1, 'style'> & {
98
98
  blur?: boolean;
99
+ style?: StyleProp<ViewStyle>;
99
100
  };
100
- declare function DialogComponent({ blur: blurProp, visible, onDismiss, children, style, ...rest }: DialogProps): react__default.JSX.Element | null;
101
+ declare function DialogComponent({ blur: blurProp, dismissable, dismissableBackButton, visible, onDismiss, children, style, testID }: DialogProps): react__default.JSX.Element | null;
101
102
  declare namespace DialogComponent {
102
103
  var Title: react__default.FC<DialogTitleProps>;
103
104
  var Content: react__default.FC<DialogContentProps>;
@@ -108,12 +109,12 @@ declare const Dialog: typeof DialogComponent;
108
109
 
109
110
  declare function FAB(props: FABProps): react.JSX.Element;
110
111
 
111
- type Props = {
112
+ type Props$1 = {
112
113
  value: ColorHarmony;
113
114
  onChange: (harmony: ColorHarmony) => void;
114
115
  blur?: boolean;
115
116
  };
116
- declare const HarmonyPicker: ({ value, onChange, blur: blurProp }: Props) => react.JSX.Element;
117
+ declare const HarmonyPicker: ({ value, onChange, blur: blurProp }: Props$1) => react.JSX.Element;
117
118
 
118
119
  type IconButtonProps = IconButtonProps$1 & {
119
120
  variant?: 'primary' | 'secondary' | 'tertiary' | 'surface';
@@ -127,6 +128,21 @@ declare const Menu: (({ blur: blurProp, children, ...props }: MenuProps) => reac
127
128
  Item: react__default.FC<MenuItemProps>;
128
129
  };
129
130
 
131
+ type PaletteWeights = {
132
+ primary: number;
133
+ secondary: number;
134
+ tertiary: number;
135
+ };
136
+ type Props = {
137
+ value: string;
138
+ onChange: (color: string) => void;
139
+ harmony?: ColorHarmony;
140
+ weights?: PaletteWeights;
141
+ colors?: SeedColor[];
142
+ blur?: boolean;
143
+ };
144
+ declare const PalettePicker: ({ value, onChange, harmony, weights, colors, blur: blurProp }: Props) => react.JSX.Element;
145
+
130
146
  declare const TextInput: react.ForwardRefExoticComponent<Omit<TextInputProps, "ref"> & react.RefAttributes<TextInput$1>>;
131
147
 
132
148
  type PaperDefaults = {
@@ -244,4 +260,4 @@ declare const getTonalColor: (color: string, lightness: number) => string;
244
260
 
245
261
  declare const isDarkColor: (value: string) => boolean;
246
262
 
247
- export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
263
+ export { Appbar, AppearancePicker, BlurView, type BlurViewProps, BottomNavigation, Button, Chip, type ChipProps, type ColorHarmony, ColorPicker, Dialog, type DialogProps, FAB, HarmonyPicker, IconButton, type IconButtonProps, Menu, type MenuProps, PalettePicker, type PaletteWeights, type PaperDefaults, Provider, type ProviderProps, type RGB, type SeedColor, TextInput, type ThemeAppearance, type ThemeSettings, ThemeSettingsContext, type ThemeSettingsContextType, type ThemeState, type TriadicPalette, createThemeReducer, defaultColors, defaultThemeSettings, getBlendedColor, getHex, getRgb, getTintTextColor, getTonalColor, getTriadicPalette, isDarkColor, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer, useBlur, useComputedTheme, usePaperDefaults, useThemeSettings };
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -32,6 +42,7 @@ __export(index_exports, {
32
42
  HarmonyPicker: () => HarmonyPicker,
33
43
  IconButton: () => IconButton,
34
44
  Menu: () => Menu,
45
+ PalettePicker: () => PalettePicker,
35
46
  Provider: () => Provider,
36
47
  TextInput: () => TextInput,
37
48
  ThemeSettingsContext: () => ThemeSettingsContext,
@@ -148,7 +159,7 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
148
159
  tint,
149
160
  children
150
161
  ] });
151
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { style: [{ backgroundColor: surfaceColor }, style], children: [
162
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native2.View, { ...props, style: [{ backgroundColor: surfaceColor }, style], children: [
152
163
  tint,
153
164
  children
154
165
  ] });
@@ -688,35 +699,72 @@ function Chip({ variant, style, selectedColor, ...props }) {
688
699
  }
689
700
 
690
701
  // src/components/ColorPicker.tsx
691
- var import_react9 = require("react");
702
+ var import_react10 = require("react");
692
703
  var import_react_native7 = require("react-native");
693
704
  var import_react_native_paper10 = require("react-native-paper");
694
705
 
695
- // src/components/Dialog.tsx
706
+ // src/useSwatchGrid.ts
696
707
  var import_react8 = require("react");
708
+ var SWATCH_GRID_COLUMNS = 5;
709
+ var SWATCH_GRID_GAP = 12;
710
+ function useSwatchSize(fallback) {
711
+ const [width, setWidth] = (0, import_react8.useState)(0);
712
+ const onLayout = ({ nativeEvent }) => setWidth(nativeEvent.layout.width);
713
+ const size = width > 0 ? (width - SWATCH_GRID_GAP * (SWATCH_GRID_COLUMNS - 1)) / SWATCH_GRID_COLUMNS : fallback;
714
+ return { onLayout, size };
715
+ }
716
+
717
+ // src/components/Dialog.tsx
718
+ var import_react9 = __toESM(require("react"));
697
719
  var import_react_native6 = require("react-native");
698
720
  var import_react_native_paper9 = require("react-native-paper");
721
+ var import_react_native_safe_area_context = require("react-native-safe-area-context");
699
722
  var import_jsx_runtime8 = require("react/jsx-runtime");
700
- function DialogComponent({ blur: blurProp, visible, onDismiss, children, style, ...rest }) {
723
+ var ANIMATION_DURATION = 220;
724
+ var DIALOG_MARGIN = 32;
725
+ function DialogComponent({ blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = "dialog" }) {
701
726
  const blur = useBlur(blurProp);
702
- const { roundness, colors } = (0, import_react_native_paper9.useTheme)();
703
- const opacity = (0, import_react8.useRef)(new import_react_native6.Animated.Value(0)).current;
704
- const [mounted, setMounted] = (0, import_react8.useState)(visible);
705
- (0, import_react8.useEffect)(() => {
727
+ const theme = (0, import_react_native_paper9.useTheme)();
728
+ const { colors, isV3, roundness } = theme;
729
+ const { scale } = theme.animation;
730
+ const { bottom, left, right, top } = (0, import_react_native_safe_area_context.useSafeAreaInsets)();
731
+ const opacity = (0, import_react9.useRef)(new import_react_native6.Animated.Value(visible ? 1 : 0)).current;
732
+ const [mounted, setMounted] = (0, import_react9.useState)(visible);
733
+ (0, import_react9.useEffect)(() => {
706
734
  if (visible) {
707
735
  setMounted(true);
708
- import_react_native6.Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }).start();
736
+ import_react_native6.Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: import_react_native6.Easing.out(import_react_native6.Easing.cubic), toValue: 1, useNativeDriver: true }).start();
709
737
  } else {
710
- import_react_native6.Animated.timing(opacity, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => setMounted(false));
738
+ import_react_native6.Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: import_react_native6.Easing.out(import_react_native6.Easing.cubic), toValue: 0, useNativeDriver: true }).start(({ finished }) => {
739
+ if (finished) setMounted(false);
740
+ });
711
741
  }
712
742
  }, [visible]);
713
- if (!blur) {
714
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper9.Dialog, { visible, onDismiss, style: [styles2.sized, style], ...rest, children });
715
- }
743
+ (0, import_react9.useEffect)(() => {
744
+ if (!visible) return void 0;
745
+ const onHardwareBackPress = () => {
746
+ if (dismissable || dismissableBackButton) onDismiss?.();
747
+ return true;
748
+ };
749
+ const subscription = import_react_native6.BackHandler.addEventListener("hardwareBackPress", onHardwareBackPress);
750
+ return () => subscription.remove();
751
+ }, [dismissable, dismissableBackButton, onDismiss, visible]);
716
752
  if (!mounted) return null;
717
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper9.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native6.Animated.View, { style: [import_react_native6.StyleSheet.absoluteFill, { opacity }], pointerEvents: visible ? "box-none" : "none", children: [
718
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native6.TouchableWithoutFeedback, { onPress: onDismiss, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native6.View, { style: [import_react_native6.StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }] }) }),
719
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native6.View, { style: styles2.wrapper, pointerEvents: "box-none", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(BlurView, { style: [styles2.card, { borderRadius: roundness * 7 }], children }) })
753
+ const content = import_react9.default.Children.toArray(children).filter((child) => child != null && typeof child !== "boolean").map((child, i) => {
754
+ if (isV3) {
755
+ if (i === 0 && import_react9.default.isValidElement(child)) {
756
+ return import_react9.default.cloneElement(child, { style: [{ marginTop: 24 }, child.props.style] });
757
+ }
758
+ }
759
+ if (i === 0 && import_react9.default.isValidElement(child) && child.type === import_react_native_paper9.Dialog.Content) {
760
+ return import_react9.default.cloneElement(child, { style: [{ paddingTop: 24 }, child.props.style] });
761
+ }
762
+ return child;
763
+ });
764
+ const borderRadius = (isV3 ? 7 : 1) * roundness;
765
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native_paper9.Portal, { children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_react_native6.Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [import_react_native6.StyleSheet.absoluteFill, { opacity }], testID, children: [
766
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native6.Pressable, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [import_react_native6.StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }], testID: `${testID}-backdrop` }),
767
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_react_native6.View, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) })
720
768
  ] }) });
721
769
  }
722
770
  DialogComponent.Title = import_react_native_paper9.Dialog.Title;
@@ -727,12 +775,12 @@ var Dialog = DialogComponent;
727
775
  var styles2 = import_react_native6.StyleSheet.create({
728
776
  // Width cap keeps dialogs card-sized on wide viewports (web/tablet); no effect on phones.
729
777
  card: { alignSelf: "center", maxWidth: 400, overflow: "hidden", width: "100%" },
730
- sized: { alignSelf: "center", maxWidth: 400, width: "100%" },
731
- wrapper: { flex: 1, justifyContent: "center", paddingHorizontal: 26 }
778
+ wrapper: { flex: 1, justifyContent: "center", paddingVertical: DIALOG_MARGIN }
732
779
  });
733
780
 
734
781
  // src/components/ColorPicker.tsx
735
782
  var import_jsx_runtime9 = require("react/jsx-runtime");
783
+ var SIZE = 48;
736
784
  var defaultColors = [
737
785
  { label: "Red", value: "#f44336" },
738
786
  { label: "Coral", value: "#ff5722" },
@@ -743,6 +791,7 @@ var defaultColors = [
743
791
  { label: "Emerald", value: "#4caf50" },
744
792
  { label: "Teal", value: "#009688" },
745
793
  { label: "Cyan", value: "#00bcd4" },
794
+ { label: "Sky", value: "#03a9f4" },
746
795
  { label: "Blue", value: "#2196f3" },
747
796
  { label: "Indigo", value: "#3f51b5" },
748
797
  { label: "Deep Purple", value: "#673ab7" },
@@ -751,21 +800,23 @@ var defaultColors = [
751
800
  { label: "Pink", value: "#ec407a" },
752
801
  { label: "Rose", value: "#e91e63" },
753
802
  { label: "Brown", value: "#795548" },
803
+ { label: "Taupe", value: "#8d7b68" },
754
804
  { label: "Blue Grey", value: "#607d8b" }
755
805
  ];
756
806
  var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }) => {
757
807
  const blur = useBlur(blurProp);
758
- const [open, setOpen] = (0, import_react9.useState)(false);
808
+ const [open, setOpen] = (0, import_react10.useState)(false);
809
+ const { onLayout, size } = useSwatchSize(SIZE);
759
810
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
760
811
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }], borderless: true, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "palette", size: 20, color: "white" }) }),
761
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native7.View, { style: styles3.swatches, children: colors.map(({ value: hex }) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
812
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Dialog.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native7.View, { style: styles3.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
762
813
  import_react_native7.TouchableOpacity,
763
814
  {
764
815
  onPress: () => {
765
816
  onChange(hex);
766
817
  setOpen(false);
767
818
  },
768
- style: [styles3.swatch, { backgroundColor: hex }, value === hex && styles3.selected],
819
+ style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles3.selected],
769
820
  children: value === hex && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(import_react_native_paper10.Icon, { source: "check", size: 20, color: "white" })
770
821
  },
771
822
  hex
@@ -774,9 +825,9 @@ var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp })
774
825
  };
775
826
  var styles3 = import_react_native7.StyleSheet.create({
776
827
  selected: { borderColor: "white", borderWidth: 3 },
777
- swatch: { alignItems: "center", borderRadius: 24, height: 48, justifyContent: "center", width: 48 },
778
- swatches: { flexDirection: "row", flexWrap: "wrap", gap: 12, paddingTop: 12 },
779
- trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: 24, height: 48, justifyContent: "center", width: 48 }
828
+ swatch: { alignItems: "center", justifyContent: "center" },
829
+ swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
830
+ trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE / 2, height: SIZE, justifyContent: "center", width: SIZE }
780
831
  });
781
832
 
782
833
  // src/components/FAB.tsx
@@ -788,7 +839,7 @@ function FAB(props) {
788
839
  }
789
840
 
790
841
  // src/components/HarmonyPicker.tsx
791
- var import_react10 = require("react");
842
+ var import_react11 = require("react");
792
843
  var import_react_native_paper13 = require("react-native-paper");
793
844
 
794
845
  // src/components/Menu.tsx
@@ -818,7 +869,7 @@ var HARMONIES = [
818
869
  ];
819
870
  var HarmonyPicker = ({ value, onChange, blur: blurProp }) => {
820
871
  const blur = useBlur(blurProp);
821
- const [open, setOpen] = (0, import_react10.useState)(false);
872
+ const [open, setOpen] = (0, import_react11.useState)(false);
822
873
  const selected = HARMONIES.find((h) => h.value === value);
823
874
  return /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
824
875
  Menu,
@@ -854,13 +905,72 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
854
905
  return /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(import_react_native_paper14.IconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
855
906
  }
856
907
 
857
- // src/components/TextInput.tsx
858
- var import_react11 = require("react");
908
+ // src/components/PalettePicker.tsx
909
+ var import_react12 = require("react");
910
+ var import_react_native9 = require("react-native");
859
911
  var import_react_native_paper15 = require("react-native-paper");
860
912
  var import_jsx_runtime14 = require("react/jsx-runtime");
861
- var TextInput = (0, import_react11.forwardRef)(function TextInput2(props, ref) {
913
+ var SIZE2 = 48;
914
+ var defaultWeights = { primary: 2, secondary: 1, tertiary: 1 };
915
+ var buildWedges = (weights) => {
916
+ const total = weights.primary + weights.secondary + weights.tertiary;
917
+ const primary = weights.primary / total * 360;
918
+ const secondary = weights.secondary / total * 360;
919
+ const tertiary = weights.tertiary / total * 360;
920
+ return [
921
+ { key: "primary", rotate: -primary / 2, share: primary },
922
+ { key: "tertiary", rotate: primary / 2, share: tertiary },
923
+ { key: "secondary", rotate: primary / 2 + tertiary, share: secondary }
924
+ ];
925
+ };
926
+ var Pie = ({ palette, weights = defaultWeights, size = SIZE2 }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.pie, { borderRadius: size / 2, height: size, width: size }], children: buildWedges(weights).map(({ key, rotate, share }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.rotator, { transform: [{ rotate: `${rotate}deg` }] }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: styles5.halfMask, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: [styles5.wedgeFill, { backgroundColor: palette[key] }] }) }) }) }, key)) });
927
+ var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights, colors = defaultColors, blur: blurProp }) => {
928
+ const blur = useBlur(blurProp);
929
+ const [open, setOpen] = (0, import_react12.useState)(false);
930
+ const { onLayout, size } = useSwatchSize(SIZE2);
931
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
932
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.TouchableRipple, { onPress: () => setOpen(true), style: styles5.trigger, borderless: true, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_react_native9.View, { style: styles5.triggerContent, children: [
933
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Pie, { palette: getTriadicPalette(value, harmony), weights }),
934
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: styles5.triggerIcon, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "palette", size: 20, color: "white" }) })
935
+ ] }) }),
936
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Dialog.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { style: styles5.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
937
+ import_react_native9.TouchableOpacity,
938
+ {
939
+ onPress: () => {
940
+ onChange(hex);
941
+ setOpen(false);
942
+ },
943
+ style: [styles5.swatch, { height: size, width: size }],
944
+ children: [
945
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Pie, { palette: getTriadicPalette(hex, harmony), weights, size }),
946
+ value === hex && /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native9.View, { pointerEvents: "none", style: [styles5.selectedRing, { borderRadius: size / 2 }], children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.Icon, { source: "check", size: 20, color: "white" }) })
947
+ ]
948
+ },
949
+ hex
950
+ )) }) }) })
951
+ ] });
952
+ };
953
+ var styles5 = import_react_native9.StyleSheet.create({
954
+ halfMask: { height: "100%", left: "50%", overflow: "hidden", position: "absolute", top: 0, width: "50%" },
955
+ innerRotator: { height: "100%", left: "-100%", overflow: "hidden", position: "absolute", top: 0, width: "200%" },
956
+ pie: { overflow: "hidden" },
957
+ rotator: { height: "100%", left: 0, overflow: "hidden", position: "absolute", top: 0, width: "100%" },
958
+ selectedRing: { alignItems: "center", borderColor: "white", borderWidth: 3, bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
959
+ swatch: { alignItems: "center", justifyContent: "center" },
960
+ swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
961
+ trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE2 / 2, height: SIZE2, justifyContent: "center", width: SIZE2 },
962
+ triggerContent: { height: SIZE2, width: SIZE2 },
963
+ triggerIcon: { alignItems: "center", bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
964
+ wedgeFill: { height: "100%", left: "50%", position: "absolute", top: 0, width: "50%" }
965
+ });
966
+
967
+ // src/components/TextInput.tsx
968
+ var import_react13 = require("react");
969
+ var import_react_native_paper16 = require("react-native-paper");
970
+ var import_jsx_runtime15 = require("react/jsx-runtime");
971
+ var TextInput = (0, import_react13.forwardRef)(function TextInput2(props, ref) {
862
972
  const defaults = usePaperDefaults();
863
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_react_native_paper15.TextInput, { ref, ...defaults.TextInput, ...props });
973
+ return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(import_react_native_paper16.TextInput, { ref, ...defaults.TextInput, ...props });
864
974
  });
865
975
 
866
976
  // src/redux/themeSlice.ts
@@ -901,8 +1011,8 @@ var selectThemeColor = (state) => state.color;
901
1011
  var selectThemeHarmony = (state) => state.harmony;
902
1012
 
903
1013
  // src/useThemeSettings.ts
904
- var import_react12 = require("react");
905
- var useThemeSettings = () => (0, import_react12.useContext)(ThemeSettingsContext);
1014
+ var import_react14 = require("react");
1015
+ var useThemeSettings = () => (0, import_react14.useContext)(ThemeSettingsContext);
906
1016
 
907
1017
  // src/utils/getTintTextColor.ts
908
1018
  var getTintTextColor = (tintColor, backdropColor, tintOpacity) => {
@@ -923,6 +1033,7 @@ var getTintTextColor = (tintColor, backdropColor, tintOpacity) => {
923
1033
  HarmonyPicker,
924
1034
  IconButton,
925
1035
  Menu,
1036
+ PalettePicker,
926
1037
  Provider,
927
1038
  TextInput,
928
1039
  ThemeSettingsContext,
package/dist/index.mjs CHANGED
@@ -95,7 +95,7 @@ var BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacit
95
95
  tint,
96
96
  children
97
97
  ] });
98
- return /* @__PURE__ */ jsxs2(View, { style: [{ backgroundColor: surfaceColor }, style], children: [
98
+ return /* @__PURE__ */ jsxs2(View, { ...props, style: [{ backgroundColor: surfaceColor }, style], children: [
99
99
  tint,
100
100
  children
101
101
  ] });
@@ -635,35 +635,72 @@ function Chip({ variant, style, selectedColor, ...props }) {
635
635
  }
636
636
 
637
637
  // src/components/ColorPicker.tsx
638
- import { useState as useState4 } from "react";
638
+ import { useState as useState5 } from "react";
639
639
  import { StyleSheet as StyleSheet4, TouchableOpacity, View as View4 } from "react-native";
640
640
  import { Icon, TouchableRipple } from "react-native-paper";
641
641
 
642
+ // src/useSwatchGrid.ts
643
+ import { useState as useState3 } from "react";
644
+ var SWATCH_GRID_COLUMNS = 5;
645
+ var SWATCH_GRID_GAP = 12;
646
+ function useSwatchSize(fallback) {
647
+ const [width, setWidth] = useState3(0);
648
+ const onLayout = ({ nativeEvent }) => setWidth(nativeEvent.layout.width);
649
+ const size = width > 0 ? (width - SWATCH_GRID_GAP * (SWATCH_GRID_COLUMNS - 1)) / SWATCH_GRID_COLUMNS : fallback;
650
+ return { onLayout, size };
651
+ }
652
+
642
653
  // src/components/Dialog.tsx
643
- import { useEffect as useEffect4, useRef as useRef2, useState as useState3 } from "react";
644
- import { Animated, StyleSheet as StyleSheet3, TouchableWithoutFeedback, View as View3 } from "react-native";
654
+ import React2, { useEffect as useEffect4, useRef as useRef2, useState as useState4 } from "react";
655
+ import { Animated, BackHandler, Easing, Pressable, StyleSheet as StyleSheet3, View as View3 } from "react-native";
645
656
  import { Dialog as PaperDialog, Portal, useTheme as useTheme5 } from "react-native-paper";
657
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
646
658
  import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
647
- function DialogComponent({ blur: blurProp, visible, onDismiss, children, style, ...rest }) {
659
+ var ANIMATION_DURATION = 220;
660
+ var DIALOG_MARGIN = 32;
661
+ function DialogComponent({ blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = "dialog" }) {
648
662
  const blur = useBlur(blurProp);
649
- const { roundness, colors } = useTheme5();
650
- const opacity = useRef2(new Animated.Value(0)).current;
651
- const [mounted, setMounted] = useState3(visible);
663
+ const theme = useTheme5();
664
+ const { colors, isV3, roundness } = theme;
665
+ const { scale } = theme.animation;
666
+ const { bottom, left, right, top } = useSafeAreaInsets();
667
+ const opacity = useRef2(new Animated.Value(visible ? 1 : 0)).current;
668
+ const [mounted, setMounted] = useState4(visible);
652
669
  useEffect4(() => {
653
670
  if (visible) {
654
671
  setMounted(true);
655
- Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }).start();
672
+ Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: Easing.out(Easing.cubic), toValue: 1, useNativeDriver: true }).start();
656
673
  } else {
657
- Animated.timing(opacity, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => setMounted(false));
674
+ Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: Easing.out(Easing.cubic), toValue: 0, useNativeDriver: true }).start(({ finished }) => {
675
+ if (finished) setMounted(false);
676
+ });
658
677
  }
659
678
  }, [visible]);
660
- if (!blur) {
661
- return /* @__PURE__ */ jsx8(PaperDialog, { visible, onDismiss, style: [styles2.sized, style], ...rest, children });
662
- }
679
+ useEffect4(() => {
680
+ if (!visible) return void 0;
681
+ const onHardwareBackPress = () => {
682
+ if (dismissable || dismissableBackButton) onDismiss?.();
683
+ return true;
684
+ };
685
+ const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBackPress);
686
+ return () => subscription.remove();
687
+ }, [dismissable, dismissableBackButton, onDismiss, visible]);
663
688
  if (!mounted) return null;
664
- return /* @__PURE__ */ jsx8(Portal, { children: /* @__PURE__ */ jsxs4(Animated.View, { style: [StyleSheet3.absoluteFill, { opacity }], pointerEvents: visible ? "box-none" : "none", children: [
665
- /* @__PURE__ */ jsx8(TouchableWithoutFeedback, { onPress: onDismiss, children: /* @__PURE__ */ jsx8(View3, { style: [StyleSheet3.absoluteFill, { backgroundColor: colors.backdrop }] }) }),
666
- /* @__PURE__ */ jsx8(View3, { style: styles2.wrapper, pointerEvents: "box-none", children: /* @__PURE__ */ jsx8(BlurView, { style: [styles2.card, { borderRadius: roundness * 7 }], children }) })
689
+ const content = React2.Children.toArray(children).filter((child) => child != null && typeof child !== "boolean").map((child, i) => {
690
+ if (isV3) {
691
+ if (i === 0 && React2.isValidElement(child)) {
692
+ return React2.cloneElement(child, { style: [{ marginTop: 24 }, child.props.style] });
693
+ }
694
+ }
695
+ if (i === 0 && React2.isValidElement(child) && child.type === PaperDialog.Content) {
696
+ return React2.cloneElement(child, { style: [{ paddingTop: 24 }, child.props.style] });
697
+ }
698
+ return child;
699
+ });
700
+ const borderRadius = (isV3 ? 7 : 1) * roundness;
701
+ return /* @__PURE__ */ jsx8(Portal, { children: /* @__PURE__ */ jsxs4(Animated.View, { accessibilityLiveRegion: "polite", accessibilityViewIsModal: true, pointerEvents: visible ? "box-none" : "none", style: [StyleSheet3.absoluteFill, { opacity }], testID, children: [
702
+ /* @__PURE__ */ jsx8(Pressable, { accessibilityLabel: "Close modal", accessibilityRole: "button", disabled: !dismissable, onPress: dismissable ? onDismiss : void 0, style: [StyleSheet3.absoluteFill, { backgroundColor: colors.backdrop }], testID: `${testID}-backdrop` }),
703
+ /* @__PURE__ */ jsx8(View3, { pointerEvents: "box-none", style: [styles2.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }], testID: `${testID}-wrapper`, children: /* @__PURE__ */ jsx8(BlurView, { blur, style: [styles2.card, { borderRadius }, style], testID: `${testID}-surface`, children: content }) })
667
704
  ] }) });
668
705
  }
669
706
  DialogComponent.Title = PaperDialog.Title;
@@ -674,12 +711,12 @@ var Dialog = DialogComponent;
674
711
  var styles2 = StyleSheet3.create({
675
712
  // Width cap keeps dialogs card-sized on wide viewports (web/tablet); no effect on phones.
676
713
  card: { alignSelf: "center", maxWidth: 400, overflow: "hidden", width: "100%" },
677
- sized: { alignSelf: "center", maxWidth: 400, width: "100%" },
678
- wrapper: { flex: 1, justifyContent: "center", paddingHorizontal: 26 }
714
+ wrapper: { flex: 1, justifyContent: "center", paddingVertical: DIALOG_MARGIN }
679
715
  });
680
716
 
681
717
  // src/components/ColorPicker.tsx
682
718
  import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
719
+ var SIZE = 48;
683
720
  var defaultColors = [
684
721
  { label: "Red", value: "#f44336" },
685
722
  { label: "Coral", value: "#ff5722" },
@@ -690,6 +727,7 @@ var defaultColors = [
690
727
  { label: "Emerald", value: "#4caf50" },
691
728
  { label: "Teal", value: "#009688" },
692
729
  { label: "Cyan", value: "#00bcd4" },
730
+ { label: "Sky", value: "#03a9f4" },
693
731
  { label: "Blue", value: "#2196f3" },
694
732
  { label: "Indigo", value: "#3f51b5" },
695
733
  { label: "Deep Purple", value: "#673ab7" },
@@ -698,21 +736,23 @@ var defaultColors = [
698
736
  { label: "Pink", value: "#ec407a" },
699
737
  { label: "Rose", value: "#e91e63" },
700
738
  { label: "Brown", value: "#795548" },
739
+ { label: "Taupe", value: "#8d7b68" },
701
740
  { label: "Blue Grey", value: "#607d8b" }
702
741
  ];
703
742
  var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }) => {
704
743
  const blur = useBlur(blurProp);
705
- const [open, setOpen] = useState4(false);
744
+ const [open, setOpen] = useState5(false);
745
+ const { onLayout, size } = useSwatchSize(SIZE);
706
746
  return /* @__PURE__ */ jsxs5(Fragment2, { children: [
707
747
  /* @__PURE__ */ jsx9(TouchableRipple, { onPress: () => setOpen(true), style: [styles3.trigger, { backgroundColor: value }], borderless: true, children: /* @__PURE__ */ jsx9(Icon, { source: "palette", size: 20, color: "white" }) }),
708
- /* @__PURE__ */ jsx9(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsx9(Dialog.Content, { children: /* @__PURE__ */ jsx9(View4, { style: styles3.swatches, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsx9(
748
+ /* @__PURE__ */ jsx9(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsx9(Dialog.Content, { children: /* @__PURE__ */ jsx9(View4, { style: styles3.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsx9(
709
749
  TouchableOpacity,
710
750
  {
711
751
  onPress: () => {
712
752
  onChange(hex);
713
753
  setOpen(false);
714
754
  },
715
- style: [styles3.swatch, { backgroundColor: hex }, value === hex && styles3.selected],
755
+ style: [styles3.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles3.selected],
716
756
  children: value === hex && /* @__PURE__ */ jsx9(Icon, { source: "check", size: 20, color: "white" })
717
757
  },
718
758
  hex
@@ -721,9 +761,9 @@ var ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp })
721
761
  };
722
762
  var styles3 = StyleSheet4.create({
723
763
  selected: { borderColor: "white", borderWidth: 3 },
724
- swatch: { alignItems: "center", borderRadius: 24, height: 48, justifyContent: "center", width: 48 },
725
- swatches: { flexDirection: "row", flexWrap: "wrap", gap: 12, paddingTop: 12 },
726
- trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: 24, height: 48, justifyContent: "center", width: 48 }
764
+ swatch: { alignItems: "center", justifyContent: "center" },
765
+ swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
766
+ trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE / 2, height: SIZE, justifyContent: "center", width: SIZE }
727
767
  });
728
768
 
729
769
  // src/components/FAB.tsx
@@ -735,7 +775,7 @@ function FAB(props) {
735
775
  }
736
776
 
737
777
  // src/components/HarmonyPicker.tsx
738
- import { useState as useState5 } from "react";
778
+ import { useState as useState6 } from "react";
739
779
  import { Button as Button2 } from "react-native-paper";
740
780
 
741
781
  // src/components/Menu.tsx
@@ -765,7 +805,7 @@ var HARMONIES = [
765
805
  ];
766
806
  var HarmonyPicker = ({ value, onChange, blur: blurProp }) => {
767
807
  const blur = useBlur(blurProp);
768
- const [open, setOpen] = useState5(false);
808
+ const [open, setOpen] = useState6(false);
769
809
  const selected = HARMONIES.find((h) => h.value === value);
770
810
  return /* @__PURE__ */ jsx12(
771
811
  Menu,
@@ -801,13 +841,72 @@ function IconButton({ variant, iconColor, containerColor, ...props }) {
801
841
  return /* @__PURE__ */ jsx13(PaperIconButton, { ...defaults.IconButton, containerColor: containerColor ?? variantContainerColor, iconColor: iconColor ?? variantIconColor, ...props });
802
842
  }
803
843
 
844
+ // src/components/PalettePicker.tsx
845
+ import { useState as useState7 } from "react";
846
+ import { StyleSheet as StyleSheet6, TouchableOpacity as TouchableOpacity2, View as View5 } from "react-native";
847
+ import { Icon as Icon2, TouchableRipple as TouchableRipple2 } from "react-native-paper";
848
+ import { Fragment as Fragment3, jsx as jsx14, jsxs as jsxs6 } from "react/jsx-runtime";
849
+ var SIZE2 = 48;
850
+ var defaultWeights = { primary: 2, secondary: 1, tertiary: 1 };
851
+ var buildWedges = (weights) => {
852
+ const total = weights.primary + weights.secondary + weights.tertiary;
853
+ const primary = weights.primary / total * 360;
854
+ const secondary = weights.secondary / total * 360;
855
+ const tertiary = weights.tertiary / total * 360;
856
+ return [
857
+ { key: "primary", rotate: -primary / 2, share: primary },
858
+ { key: "tertiary", rotate: primary / 2, share: tertiary },
859
+ { key: "secondary", rotate: primary / 2 + tertiary, share: secondary }
860
+ ];
861
+ };
862
+ var Pie = ({ palette, weights = defaultWeights, size = SIZE2 }) => /* @__PURE__ */ jsx14(View5, { style: [styles5.pie, { borderRadius: size / 2, height: size, width: size }], children: buildWedges(weights).map(({ key, rotate, share }) => /* @__PURE__ */ jsx14(View5, { style: [styles5.rotator, { transform: [{ rotate: `${rotate}deg` }] }], children: /* @__PURE__ */ jsx14(View5, { style: styles5.halfMask, children: /* @__PURE__ */ jsx14(View5, { style: [styles5.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }], children: /* @__PURE__ */ jsx14(View5, { style: [styles5.wedgeFill, { backgroundColor: palette[key] }] }) }) }) }, key)) });
863
+ var PalettePicker = ({ value, onChange, harmony = "split-complementary", weights, colors = defaultColors, blur: blurProp }) => {
864
+ const blur = useBlur(blurProp);
865
+ const [open, setOpen] = useState7(false);
866
+ const { onLayout, size } = useSwatchSize(SIZE2);
867
+ return /* @__PURE__ */ jsxs6(Fragment3, { children: [
868
+ /* @__PURE__ */ jsx14(TouchableRipple2, { onPress: () => setOpen(true), style: styles5.trigger, borderless: true, children: /* @__PURE__ */ jsxs6(View5, { style: styles5.triggerContent, children: [
869
+ /* @__PURE__ */ jsx14(Pie, { palette: getTriadicPalette(value, harmony), weights }),
870
+ /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: styles5.triggerIcon, children: /* @__PURE__ */ jsx14(Icon2, { source: "palette", size: 20, color: "white" }) })
871
+ ] }) }),
872
+ /* @__PURE__ */ jsx14(Dialog, { blur, visible: open, onDismiss: () => setOpen(false), children: /* @__PURE__ */ jsx14(Dialog.Content, { children: /* @__PURE__ */ jsx14(View5, { style: styles5.swatches, onLayout, children: colors.map(({ value: hex }) => /* @__PURE__ */ jsxs6(
873
+ TouchableOpacity2,
874
+ {
875
+ onPress: () => {
876
+ onChange(hex);
877
+ setOpen(false);
878
+ },
879
+ style: [styles5.swatch, { height: size, width: size }],
880
+ children: [
881
+ /* @__PURE__ */ jsx14(Pie, { palette: getTriadicPalette(hex, harmony), weights, size }),
882
+ value === hex && /* @__PURE__ */ jsx14(View5, { pointerEvents: "none", style: [styles5.selectedRing, { borderRadius: size / 2 }], children: /* @__PURE__ */ jsx14(Icon2, { source: "check", size: 20, color: "white" }) })
883
+ ]
884
+ },
885
+ hex
886
+ )) }) }) })
887
+ ] });
888
+ };
889
+ var styles5 = StyleSheet6.create({
890
+ halfMask: { height: "100%", left: "50%", overflow: "hidden", position: "absolute", top: 0, width: "50%" },
891
+ innerRotator: { height: "100%", left: "-100%", overflow: "hidden", position: "absolute", top: 0, width: "200%" },
892
+ pie: { overflow: "hidden" },
893
+ rotator: { height: "100%", left: 0, overflow: "hidden", position: "absolute", top: 0, width: "100%" },
894
+ selectedRing: { alignItems: "center", borderColor: "white", borderWidth: 3, bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
895
+ swatch: { alignItems: "center", justifyContent: "center" },
896
+ swatches: { flexDirection: "row", flexWrap: "wrap", gap: SWATCH_GRID_GAP, paddingTop: 12 },
897
+ trigger: { alignItems: "center", alignSelf: "flex-start", borderRadius: SIZE2 / 2, height: SIZE2, justifyContent: "center", width: SIZE2 },
898
+ triggerContent: { height: SIZE2, width: SIZE2 },
899
+ triggerIcon: { alignItems: "center", bottom: 0, justifyContent: "center", left: 0, position: "absolute", right: 0, top: 0 },
900
+ wedgeFill: { height: "100%", left: "50%", position: "absolute", top: 0, width: "50%" }
901
+ });
902
+
804
903
  // src/components/TextInput.tsx
805
904
  import { forwardRef } from "react";
806
905
  import { TextInput as PaperTextInput } from "react-native-paper";
807
- import { jsx as jsx14 } from "react/jsx-runtime";
906
+ import { jsx as jsx15 } from "react/jsx-runtime";
808
907
  var TextInput = forwardRef(function TextInput2(props, ref) {
809
908
  const defaults = usePaperDefaults();
810
- return /* @__PURE__ */ jsx14(PaperTextInput, { ref, ...defaults.TextInput, ...props });
909
+ return /* @__PURE__ */ jsx15(PaperTextInput, { ref, ...defaults.TextInput, ...props });
811
910
  });
812
911
 
813
912
  // src/redux/themeSlice.ts
@@ -869,6 +968,7 @@ export {
869
968
  HarmonyPicker,
870
969
  IconButton,
871
970
  Menu,
971
+ PalettePicker,
872
972
  Provider,
873
973
  TextInput,
874
974
  ThemeSettingsContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/auto-paper",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
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",
@@ -86,6 +86,7 @@
86
86
  "react-dom": "^19.0.0",
87
87
  "react-native": "^0.76.0",
88
88
  "react-native-paper": "^5.12.0",
89
+ "react-native-safe-area-context": "^5.5.2",
89
90
  "ts-jest": "^29.4.9",
90
91
  "ts-node": "^10.9.2",
91
92
  "tsup": "^8.0.0",
@@ -97,7 +98,8 @@
97
98
  "expo-navigation-bar": ">=56.0.0",
98
99
  "react": ">=18.0.0",
99
100
  "react-native": ">=0.76.0",
100
- "react-native-paper": ">=5.0.0"
101
+ "react-native-paper": ">=5.0.0",
102
+ "react-native-safe-area-context": "*"
101
103
  },
102
104
  "peerDependenciesMeta": {
103
105
  "expo-blur": {
@@ -67,7 +67,7 @@ export const BlurView = ({ blur = true, children, elevation, style, tintColor, t
67
67
  </ExpoBlurView>
68
68
  )
69
69
  return (
70
- <View style={[{ backgroundColor: surfaceColor }, style]}>
70
+ <View {...props} style={[{ backgroundColor: surfaceColor }, style]}>
71
71
  {tint}
72
72
  {children}
73
73
  </View>
@@ -3,8 +3,11 @@ import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
3
  import { Icon, TouchableRipple } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
+ import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
6
7
  import { Dialog } from './Dialog'
7
8
 
9
+ const SIZE = 48
10
+
8
11
  export type SeedColor = { label: string; value: string }
9
12
 
10
13
  export const defaultColors: SeedColor[] = [
@@ -17,6 +20,7 @@ export const defaultColors: SeedColor[] = [
17
20
  { label: 'Emerald', value: '#4caf50' },
18
21
  { label: 'Teal', value: '#009688' },
19
22
  { label: 'Cyan', value: '#00bcd4' },
23
+ { label: 'Sky', value: '#03a9f4' },
20
24
  { label: 'Blue', value: '#2196f3' },
21
25
  { label: 'Indigo', value: '#3f51b5' },
22
26
  { label: 'Deep Purple', value: '#673ab7' },
@@ -25,6 +29,7 @@ export const defaultColors: SeedColor[] = [
25
29
  { label: 'Pink', value: '#ec407a' },
26
30
  { label: 'Rose', value: '#e91e63' },
27
31
  { label: 'Brown', value: '#795548' },
32
+ { label: 'Taupe', value: '#8d7b68' },
28
33
  { label: 'Blue Grey', value: '#607d8b' }
29
34
  ]
30
35
 
@@ -38,6 +43,7 @@ type Props = {
38
43
  export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }: Props) => {
39
44
  const blur = useBlur(blurProp)
40
45
  const [open, setOpen] = useState(false)
46
+ const { onLayout, size } = useSwatchSize(SIZE)
41
47
 
42
48
  return (
43
49
  <>
@@ -47,7 +53,7 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
47
53
 
48
54
  <Dialog blur={blur} visible={open} onDismiss={() => setOpen(false)}>
49
55
  <Dialog.Content>
50
- <View style={styles.swatches}>
56
+ <View style={styles.swatches} onLayout={onLayout}>
51
57
  {colors.map(({ value: hex }) => (
52
58
  <TouchableOpacity
53
59
  key={hex}
@@ -55,7 +61,7 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
55
61
  onChange(hex)
56
62
  setOpen(false)
57
63
  }}
58
- style={[styles.swatch, { backgroundColor: hex }, value === hex && styles.selected]}
64
+ style={[styles.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles.selected]}
59
65
  >
60
66
  {value === hex && <Icon source='check' size={20} color='white' />}
61
67
  </TouchableOpacity>
@@ -69,7 +75,7 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
69
75
 
70
76
  const styles = StyleSheet.create({
71
77
  selected: { borderColor: 'white', borderWidth: 3 },
72
- swatch: { alignItems: 'center', borderRadius: 24, height: 48, justifyContent: 'center', width: 48 },
73
- swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingTop: 12 },
74
- trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: 24, height: 48, justifyContent: 'center', width: 48 }
78
+ swatch: { alignItems: 'center', justifyContent: 'center' },
79
+ swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
80
+ trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE }
75
81
  })
@@ -1,47 +1,83 @@
1
1
  import React, { useEffect, useRef, useState } from 'react'
2
- import { Animated, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'
2
+ import { Animated, BackHandler, Easing, 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
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
4
5
 
5
6
  import { useBlur } from '../BlurContext'
6
7
  import { BlurView } from './BlurView'
7
8
 
8
- export type DialogProps = PaperDialogProps & {
9
+ // `style` is narrowed to a plain ViewStyle: unlike react-native-paper's Dialog, the surface here is
10
+ // never wrapped in Animated.View, so it can't drive an Animated-value style the way upstream's can.
11
+ export type DialogProps = Omit<PaperDialogProps, 'style'> & {
9
12
  blur?: boolean
13
+ style?: StyleProp<ViewStyle>
10
14
  }
11
15
 
12
- function DialogComponent({ blur: blurProp, visible, onDismiss, children, style, ...rest }: DialogProps) {
16
+ // Matches react-native-paper's Modal: DEFAULT_DURATION * theme.animation.scale, eased the same way,
17
+ // so toggling blur never changes how the dialog animates in/out.
18
+ const ANIMATION_DURATION = 220
19
+
20
+ // Minimum breathing room between the card and the screen edge, clamped up from safe-area insets.
21
+ const DIALOG_MARGIN = 32
22
+
23
+ function DialogComponent({ blur: blurProp, dismissable = true, dismissableBackButton = dismissable, visible, onDismiss, children, style, testID = 'dialog' }: DialogProps) {
13
24
  const blur = useBlur(blurProp)
14
- const { roundness, colors } = useTheme()
15
- const opacity = useRef(new Animated.Value(0)).current
25
+ const theme = useTheme()
26
+ const { colors, isV3, roundness } = theme
27
+ const { scale } = theme.animation
28
+ const { bottom, left, right, top } = useSafeAreaInsets()
29
+ const opacity = useRef(new Animated.Value(visible ? 1 : 0)).current
16
30
  const [mounted, setMounted] = useState(visible)
17
31
 
18
32
  useEffect(() => {
19
33
  if (visible) {
20
34
  setMounted(true)
21
- Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }).start()
35
+ Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: Easing.out(Easing.cubic), toValue: 1, useNativeDriver: true }).start()
22
36
  } else {
23
- Animated.timing(opacity, { toValue: 0, duration: 150, useNativeDriver: true }).start(() => setMounted(false))
37
+ Animated.timing(opacity, { duration: scale * ANIMATION_DURATION, easing: Easing.out(Easing.cubic), toValue: 0, useNativeDriver: true }).start(({ finished }) => {
38
+ if (finished) setMounted(false)
39
+ })
24
40
  }
25
41
  }, [visible])
26
42
 
27
- if (!blur) {
28
- return (
29
- <PaperDialog visible={visible} onDismiss={onDismiss} style={[styles.sized, style]} {...rest}>
30
- {children}
31
- </PaperDialog>
32
- )
33
- }
43
+ useEffect(() => {
44
+ if (!visible) return undefined
45
+ const onHardwareBackPress = () => {
46
+ if (dismissable || dismissableBackButton) onDismiss?.()
47
+ return true
48
+ }
49
+ const subscription = BackHandler.addEventListener('hardwareBackPress', onHardwareBackPress)
50
+ return () => subscription.remove()
51
+ }, [dismissable, dismissableBackButton, onDismiss, visible])
34
52
 
35
53
  if (!mounted) return null
36
54
 
55
+ // Mirrors react-native-paper's own Dialog: the first child gets extra top spacing so the
56
+ // header sits the same distance from the edge whether the surface is blurred or solid.
57
+ const content = React.Children.toArray(children)
58
+ .filter((child) => child != null && typeof child !== 'boolean')
59
+ .map((child, i) => {
60
+ if (isV3) {
61
+ if (i === 0 && React.isValidElement<DialogContentProps>(child)) {
62
+ return React.cloneElement(child, { style: [{ marginTop: 24 }, child.props.style] })
63
+ }
64
+ }
65
+ if (i === 0 && React.isValidElement<DialogContentProps>(child) && child.type === PaperDialog.Content) {
66
+ return React.cloneElement(child, { style: [{ paddingTop: 24 }, child.props.style] })
67
+ }
68
+ return child
69
+ })
70
+
71
+ const borderRadius = (isV3 ? 7 : 1) * roundness
72
+
37
73
  return (
38
74
  <Portal>
39
- <Animated.View style={[StyleSheet.absoluteFill, { opacity }]} pointerEvents={visible ? 'box-none' : 'none'}>
40
- <TouchableWithoutFeedback onPress={onDismiss}>
41
- <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }]} />
42
- </TouchableWithoutFeedback>
43
- <View style={styles.wrapper} pointerEvents='box-none'>
44
- <BlurView style={[styles.card, { borderRadius: roundness * 7 }]}>{children}</BlurView>
75
+ <Animated.View accessibilityLiveRegion='polite' accessibilityViewIsModal pointerEvents={visible ? 'box-none' : 'none'} style={[StyleSheet.absoluteFill, { opacity }]} testID={testID}>
76
+ <Pressable accessibilityLabel='Close modal' accessibilityRole='button' disabled={!dismissable} onPress={dismissable ? onDismiss : undefined} style={[StyleSheet.absoluteFill, { backgroundColor: colors.backdrop }]} testID={`${testID}-backdrop`} />
77
+ <View pointerEvents='box-none' style={[styles.wrapper, { marginBottom: bottom, marginTop: top, paddingHorizontal: Math.max(left, right, DIALOG_MARGIN) }]} testID={`${testID}-wrapper`}>
78
+ <BlurView blur={blur} style={[styles.card, { borderRadius }, style]} testID={`${testID}-surface`}>
79
+ {content}
80
+ </BlurView>
45
81
  </View>
46
82
  </Animated.View>
47
83
  </Portal>
@@ -58,6 +94,5 @@ export const Dialog = DialogComponent
58
94
  const styles = StyleSheet.create({
59
95
  // Width cap keeps dialogs card-sized on wide viewports (web/tablet); no effect on phones.
60
96
  card: { alignSelf: 'center', maxWidth: 400, overflow: 'hidden', width: '100%' },
61
- sized: { alignSelf: 'center', maxWidth: 400, width: '100%' },
62
- wrapper: { flex: 1, justifyContent: 'center', paddingHorizontal: 26 }
97
+ wrapper: { flex: 1, justifyContent: 'center', paddingVertical: DIALOG_MARGIN }
63
98
  })
@@ -0,0 +1,117 @@
1
+ import { useState } from 'react'
2
+ import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
+ import { Icon, TouchableRipple } from 'react-native-paper'
4
+
5
+ import { useBlur } from '../BlurContext'
6
+ import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import type { ColorHarmony, TriadicPalette } from '../utils/getTriadicPalette'
8
+ import { getTriadicPalette } from '../utils/getTriadicPalette'
9
+ import type { SeedColor } from './ColorPicker'
10
+ import { defaultColors } from './ColorPicker'
11
+ import { Dialog } from './Dialog'
12
+
13
+ const SIZE = 48
14
+
15
+ export type PaletteWeights = { primary: number; secondary: number; tertiary: number }
16
+
17
+ const defaultWeights: PaletteWeights = { primary: 2, secondary: 1, tertiary: 1 }
18
+
19
+ // Each wedge = a static right-half mask, doubly rotated: an outer spin positions the slice's
20
+ // leading edge around the circle, then an inner spin (its own share, minus 180deg) carves the
21
+ // trailing edge — together covering exactly `share` of weights' total, for any share <= 50%.
22
+ // Both rotators are sized to exactly overlap the full circle so React Native's default
23
+ // (own-center) rotation pivot is already correct — transformOrigin is intentionally avoided
24
+ // since it isn't reliably honored across RN renderers. Primary is centered at 12 o'clock,
25
+ // tertiary fills clockwise from there (bottom-right), secondary fills the remainder (bottom-left).
26
+ const buildWedges = (weights: PaletteWeights) => {
27
+ const total = weights.primary + weights.secondary + weights.tertiary
28
+ const primary = (weights.primary / total) * 360
29
+ const secondary = (weights.secondary / total) * 360
30
+ const tertiary = (weights.tertiary / total) * 360
31
+
32
+ return [
33
+ { key: 'primary' as const, rotate: -primary / 2, share: primary },
34
+ { key: 'tertiary' as const, rotate: primary / 2, share: tertiary },
35
+ { key: 'secondary' as const, rotate: primary / 2 + tertiary, share: secondary }
36
+ ]
37
+ }
38
+
39
+ const Pie = ({ palette, weights = defaultWeights, size = SIZE }: { palette: TriadicPalette; weights?: PaletteWeights; size?: number }) => (
40
+ <View style={[styles.pie, { borderRadius: size / 2, height: size, width: size }]}>
41
+ {buildWedges(weights).map(({ key, rotate, share }) => (
42
+ <View key={key} style={[styles.rotator, { transform: [{ rotate: `${rotate}deg` }] }]}>
43
+ <View style={styles.halfMask}>
44
+ <View style={[styles.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }]}>
45
+ <View style={[styles.wedgeFill, { backgroundColor: palette[key] }]} />
46
+ </View>
47
+ </View>
48
+ </View>
49
+ ))}
50
+ </View>
51
+ )
52
+
53
+ type Props = {
54
+ value: string
55
+ onChange: (color: string) => void
56
+ harmony?: ColorHarmony
57
+ weights?: PaletteWeights
58
+ colors?: SeedColor[]
59
+ blur?: boolean
60
+ }
61
+
62
+ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', weights, colors = defaultColors, blur: blurProp }: Props) => {
63
+ const blur = useBlur(blurProp)
64
+ const [open, setOpen] = useState(false)
65
+ const { onLayout, size } = useSwatchSize(SIZE)
66
+
67
+ return (
68
+ <>
69
+ <TouchableRipple onPress={() => setOpen(true)} style={styles.trigger} borderless>
70
+ <View style={styles.triggerContent}>
71
+ <Pie palette={getTriadicPalette(value, harmony)} weights={weights} />
72
+ <View pointerEvents='none' style={styles.triggerIcon}>
73
+ <Icon source='palette' size={20} color='white' />
74
+ </View>
75
+ </View>
76
+ </TouchableRipple>
77
+
78
+ <Dialog blur={blur} visible={open} onDismiss={() => setOpen(false)}>
79
+ <Dialog.Content>
80
+ <View style={styles.swatches} onLayout={onLayout}>
81
+ {colors.map(({ value: hex }) => (
82
+ <TouchableOpacity
83
+ key={hex}
84
+ onPress={() => {
85
+ onChange(hex)
86
+ setOpen(false)
87
+ }}
88
+ style={[styles.swatch, { height: size, width: size }]}
89
+ >
90
+ <Pie palette={getTriadicPalette(hex, harmony)} weights={weights} size={size} />
91
+ {value === hex && (
92
+ <View pointerEvents='none' style={[styles.selectedRing, { borderRadius: size / 2 }]}>
93
+ <Icon source='check' size={20} color='white' />
94
+ </View>
95
+ )}
96
+ </TouchableOpacity>
97
+ ))}
98
+ </View>
99
+ </Dialog.Content>
100
+ </Dialog>
101
+ </>
102
+ )
103
+ }
104
+
105
+ const styles = StyleSheet.create({
106
+ halfMask: { height: '100%', left: '50%', overflow: 'hidden', position: 'absolute', top: 0, width: '50%' },
107
+ innerRotator: { height: '100%', left: '-100%', overflow: 'hidden', position: 'absolute', top: 0, width: '200%' },
108
+ pie: { overflow: 'hidden' },
109
+ rotator: { height: '100%', left: 0, overflow: 'hidden', position: 'absolute', top: 0, width: '100%' },
110
+ selectedRing: { alignItems: 'center', borderColor: 'white', borderWidth: 3, bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0 },
111
+ swatch: { alignItems: 'center', justifyContent: 'center' },
112
+ swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
113
+ trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE },
114
+ triggerContent: { height: SIZE, width: SIZE },
115
+ triggerIcon: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0 },
116
+ wedgeFill: { height: '100%', left: '50%', position: 'absolute', top: 0, width: '50%' }
117
+ })
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export type { IconButtonProps } from './components/IconButton'
17
17
  export { IconButton } from './components/IconButton'
18
18
  export type { MenuProps } from './components/Menu'
19
19
  export { Menu } from './components/Menu'
20
+ export type { PaletteWeights } from './components/PalettePicker'
21
+ export { PalettePicker } from './components/PalettePicker'
20
22
  export { TextInput } from './components/TextInput'
21
23
  export type { PaperDefaults } from './PaperDefaultsContext'
22
24
  export { usePaperDefaults } from './PaperDefaultsContext'
@@ -0,0 +1,18 @@
1
+ import { useState } from 'react'
2
+ import type { LayoutChangeEvent } from 'react-native'
3
+
4
+ // Fixed column count so the swatch grid always fills complete rows, regardless of dialog width
5
+ // (phone vs tablet vs web) — sizing the swatches to fit, rather than wrapping fixed-size swatches
6
+ // and hoping the total divides evenly into whatever column count the width happens to produce.
7
+ export const SWATCH_GRID_COLUMNS = 5
8
+ export const SWATCH_GRID_GAP = 12
9
+
10
+ // `fallback` is returned before the container's first layout pass reports a real width.
11
+ export function useSwatchSize(fallback: number) {
12
+ const [width, setWidth] = useState(0)
13
+
14
+ const onLayout = ({ nativeEvent }: LayoutChangeEvent) => setWidth(nativeEvent.layout.width)
15
+ const size = width > 0 ? (width - SWATCH_GRID_GAP * (SWATCH_GRID_COLUMNS - 1)) / SWATCH_GRID_COLUMNS : fallback
16
+
17
+ return { onLayout, size }
18
+ }