@rific/auto-paper 0.7.0 → 0.9.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.
@@ -2,15 +2,35 @@ import { SegmentedButtons } from 'react-native-paper'
2
2
 
3
3
  import type { ThemeAppearance } from '../useComputedTheme'
4
4
 
5
- const APPEARANCES: { label: string; value: ThemeAppearance; icon: string }[] = [
6
- { label: 'System', value: 'system', icon: 'brightness-auto' },
7
- { label: 'Light', value: 'light', icon: 'white-balance-sunny' },
8
- { label: 'Dark', value: 'dark', icon: 'weather-night' }
5
+ const APPEARANCES: { label: string; value: ThemeAppearance }[] = [
6
+ { label: 'System', value: 'system' },
7
+ { label: 'Light', value: 'light' },
8
+ { label: 'Dark', value: 'dark' }
9
9
  ]
10
10
 
11
+ export type AppearanceIcons = Partial<Record<ThemeAppearance, string>>
12
+
13
+ // 'monitor' (a plain device/screen glyph) reads more clearly as "match my device" than
14
+ // 'brightness-auto' (an exposure/brightness symbol). Swap via the `icons` prop for a different
15
+ // convention, e.g. `theme-light-dark`'s half-sun/half-moon.
16
+ const DEFAULT_ICONS: Record<ThemeAppearance, string> = {
17
+ system: 'monitor',
18
+ light: 'white-balance-sunny',
19
+ dark: 'weather-night'
20
+ }
21
+
11
22
  type Props = {
12
23
  value: ThemeAppearance
13
24
  onChange: (appearance: ThemeAppearance) => void
25
+ // Hides the text label, leaving just the icon per segment, useful in tight layouts where
26
+ // "System" truncates. accessibilityLabel is always set regardless, so screen readers still
27
+ // announce the full word either way.
28
+ showLabels?: boolean
29
+ // Per-value icon overrides, merged over DEFAULT_ICONS: pass just the ones you want to change.
30
+ icons?: AppearanceIcons
14
31
  }
15
32
 
16
- export const AppearancePicker = ({ value, onChange }: Props) => <SegmentedButtons value={value} onValueChange={(v) => onChange(v as ThemeAppearance)} buttons={APPEARANCES.map((a) => ({ value: a.value, label: a.label, icon: a.icon }))} />
33
+ export const AppearancePicker = ({ value, onChange, showLabels = true, icons }: Props) => {
34
+ const resolvedIcons = { ...DEFAULT_ICONS, ...icons }
35
+ return <SegmentedButtons value={value} onValueChange={(v) => onChange(v as ThemeAppearance)} buttons={APPEARANCES.map((a) => ({ value: a.value, label: showLabels ? a.label : undefined, icon: resolvedIcons[a.value], accessibilityLabel: a.label }))} />
36
+ }
@@ -2,21 +2,26 @@ import { type ComponentType, type ReactNode, type RefObject, useContext } from '
2
2
  import { type StyleProp, StyleSheet, View, type ViewProps, type ViewStyle } from 'react-native'
3
3
  import { useTheme } from 'react-native-paper'
4
4
 
5
+ import { useBlurModule } from '../ThemeProvider'
5
6
  import { ThemeSettingsContext } from '../ThemeSettingsContext'
6
7
 
7
- // Minimal local shape of expo-blur covering only the member used below — avoids forcing
8
+ // Minimal local shape of expo-blur covering only the member used below: avoids forcing
8
9
  // TypeScript to resolve the optional peer's real types for consumers who never installed it.
9
- interface ExpoBlurModule {
10
+ // tint is deliberately loose (not expo-blur's real narrow BlurTint union) so the real module
11
+ // stays structurally assignable to `<Provider expoBlur={...}>` without importing its types -
12
+ // this component only ever passes it 'dark'/'light', both valid BlurTint members at runtime.
13
+ export interface ExpoBlurModule {
10
14
  BlurView: ComponentType<{
11
15
  children?: ReactNode
12
16
  style?: StyleProp<ViewStyle>
13
- tint?: string
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ tint?: any
14
19
  [key: string]: unknown
15
20
  }>
16
21
  }
17
22
 
18
- // Local mirror of expo-blur's public BlurViewProps (node_modules/expo-blur/build/BlurView.types.d.ts)
19
- // — same reason as ExpoBlurModule above: the peer's real type must never be imported unconditionally.
23
+ // Local mirror of expo-blur's public BlurViewProps (node_modules/expo-blur/build/BlurView.types.d.ts),
24
+ // same reason as ExpoBlurModule above: the peer's real type must never be imported unconditionally.
20
25
  type ExpoBlurTint = 'light' | 'dark' | 'default' | 'extraLight' | 'regular' | 'prominent' | 'systemUltraThinMaterial' | 'systemThinMaterial' | 'systemMaterial' | 'systemThickMaterial' | 'systemChromeMaterial' | 'systemUltraThinMaterialLight' | 'systemThinMaterialLight' | 'systemMaterialLight' | 'systemThickMaterialLight' | 'systemChromeMaterialLight' | 'systemUltraThinMaterialDark' | 'systemThinMaterialDark' | 'systemMaterialDark' | 'systemThickMaterialDark' | 'systemChromeMaterialDark'
21
26
  type ExpoBlurMethod = 'none' | 'dimezisBlurView' | 'dimezisBlurViewSdk31Plus'
22
27
  type ExpoBlurViewProps = {
@@ -28,15 +33,6 @@ type ExpoBlurViewProps = {
28
33
  tint?: ExpoBlurTint
29
34
  } & ViewProps
30
35
 
31
- // expo-blur is an optional peer — when absent, BlurView renders its solid (non-blur) fallback
32
- const ExpoBlurView = (() => {
33
- try {
34
- return (require('expo-blur') as ExpoBlurModule).BlurView
35
- } catch {
36
- return null
37
- }
38
- })()
39
-
40
36
  const ELEVATION_OPACITY: Record<number, number> = { 0: 0, 1: 0.05, 2: 0.08, 3: 0.11, 4: 0.12, 5: 0.14 }
41
37
  const VARIANT_TINT_OPACITY = { light: 0.12, dark: 0.18 }
42
38
 
@@ -53,18 +49,19 @@ export type BlurViewProps = ExpoBlurViewProps & {
53
49
  export const BlurView = ({ blur = true, children, elevation, style, tintColor, tintOpacity, variant = 'surface', ...props }: BlurViewProps) => {
54
50
  const { dark, colors } = useTheme()
55
51
  const { settings } = useContext(ThemeSettingsContext)
52
+ const expoBlur = useBlurModule()
56
53
  const surfaceColor = variant === 'surfaceVariant' ? colors.surfaceVariant : colors.surface
57
54
  const variantColor = variant === 'primary' ? colors.primary : variant === 'secondary' ? colors.secondary : variant === 'tertiary' ? colors.tertiary : variant === 'error' ? colors.error : undefined
58
55
  const resolvedTintColor = tintColor ?? variantColor ?? (elevation !== undefined ? colors.primary : undefined)
59
56
  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)
60
57
  const tint = resolvedTintColor && resolvedTintOpacity > 0 ? <View style={[StyleSheet.absoluteFill, { backgroundColor: resolvedTintColor, opacity: resolvedTintOpacity }]} /> : null
61
- if (blur && ExpoBlurView)
58
+ if (blur && expoBlur)
62
59
  return (
63
- <ExpoBlurView {...props} tint={dark ? 'dark' : 'light'} style={style}>
60
+ <expoBlur.BlurView {...props} tint={dark ? 'dark' : 'light'} style={style}>
64
61
  <View style={[StyleSheet.absoluteFill, { backgroundColor: surfaceColor, opacity: settings.blurTint }]} />
65
62
  {tint}
66
63
  {children}
67
- </ExpoBlurView>
64
+ </expoBlur.BlurView>
68
65
  )
69
66
  return (
70
67
  <View {...props} style={[{ backgroundColor: surfaceColor }, style]}>
@@ -2,20 +2,20 @@ import React, { useEffect } from 'react'
2
2
  import { Platform } from 'react-native'
3
3
  import { BottomNavigation as PaperBottomNavigation, type BottomNavigationProps, type BottomNavigationRoute, useTheme } from 'react-native-paper'
4
4
 
5
- import { navigationBar, setNavigationBarStyle } from '../navigation-bar'
5
+ import { setNavigationBarStyle } from '../navigation-bar'
6
6
  import { usePaperDefaults } from '../PaperDefaultsContext'
7
7
  import { useNavBarContext } from '../ThemeProvider'
8
8
 
9
9
  function useNavigationBarSync() {
10
10
  const theme = useTheme()
11
- const { onNavBarChange } = useNavBarContext()
11
+ const { navigationBar, onNavBarChange } = useNavBarContext()
12
12
  useEffect(() => {
13
13
  if (Platform.OS !== 'android') return
14
14
  // An explicit onNavBarChange callback takes full control; otherwise sync the
15
- // nav bar icon style automatically when expo-navigation-bar is installed.
15
+ // nav bar icon style automatically when expo-navigation-bar was injected via <Provider>.
16
16
  if (onNavBarChange) onNavBarChange(theme.colors.surface, theme.dark)
17
- else if (navigationBar) setNavigationBarStyle(theme.dark)
18
- }, [theme, onNavBarChange])
17
+ else if (navigationBar) setNavigationBarStyle(navigationBar, theme.dark)
18
+ }, [theme, onNavBarChange, navigationBar])
19
19
  }
20
20
 
21
21
  function BottomNavigationComponent<Route extends BottomNavigationRoute>(props: BottomNavigationProps<Route>) {
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
- import { Icon, TouchableRipple } from 'react-native-paper'
3
+ import { Icon, TouchableRipple, useTheme } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
6
  import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import { getContrastColor, getSwatchRing } from '../utils/getSwatchContrast'
7
8
  import { Dialog } from './Dialog'
8
9
 
9
10
  const SIZE = 48
@@ -42,13 +43,14 @@ type Props = {
42
43
 
43
44
  export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blurProp }: Props) => {
44
45
  const blur = useBlur(blurProp)
46
+ const { colors: themeColors } = useTheme()
45
47
  const [open, setOpen] = useState(false)
46
48
  const { onLayout, size } = useSwatchSize(SIZE)
47
49
 
48
50
  return (
49
51
  <>
50
- <TouchableRipple onPress={() => setOpen(true)} style={[styles.trigger, { backgroundColor: value }]} borderless>
51
- <Icon source='palette' size={20} color='white' />
52
+ <TouchableRipple onPress={() => setOpen(true)} style={[styles.trigger, { backgroundColor: value }, getSwatchRing(value, false, themeColors.outlineVariant)]} borderless>
53
+ <Icon source='palette' size={20} color={getContrastColor(value)} />
52
54
  </TouchableRipple>
53
55
 
54
56
  <Dialog blur={blur} visible={open} onDismiss={() => setOpen(false)}>
@@ -61,9 +63,9 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
61
63
  onChange(hex)
62
64
  setOpen(false)
63
65
  }}
64
- style={[styles.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, value === hex && styles.selected]}
66
+ style={[styles.swatch, { backgroundColor: hex, borderRadius: size / 2, height: size, width: size }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)]}
65
67
  >
66
- {value === hex && <Icon source='check' size={20} color='white' />}
68
+ {value === hex && <Icon source='check' size={20} color={getContrastColor(hex)} />}
67
69
  </TouchableOpacity>
68
70
  ))}
69
71
  </View>
@@ -74,7 +76,6 @@ export const ColorPicker = ({ value, onChange, colors = defaultColors, blur: blu
74
76
  }
75
77
 
76
78
  const styles = StyleSheet.create({
77
- selected: { borderColor: 'white', borderWidth: 3 },
78
79
  swatch: { alignItems: 'center', justifyContent: 'center' },
79
80
  swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
80
81
  trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE }
@@ -1,9 +1,10 @@
1
1
  import { useState } from 'react'
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native'
3
- import { Icon, TouchableRipple } from 'react-native-paper'
3
+ import { Icon, TouchableRipple, useTheme } from 'react-native-paper'
4
4
 
5
5
  import { useBlur } from '../BlurContext'
6
6
  import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import { getContrastColor, getSwatchRing } from '../utils/getSwatchContrast'
7
8
  import type { ColorHarmony, TriadicPalette } from '../utils/getTriadicPalette'
8
9
  import { getTriadicPalette } from '../utils/getTriadicPalette'
9
10
  import type { SeedColor } from './ColorPicker'
@@ -18,9 +19,9 @@ const defaultWeights: PaletteWeights = { primary: 2, secondary: 1, tertiary: 1 }
18
19
 
19
20
  // Each wedge = a static right-half mask, doubly rotated: an outer spin positions the slice's
20
21
  // 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
+ // trailing edge, together covering exactly `share` of weights' total, for any share <= 50%.
22
23
  // 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
+ // (own-center) rotation pivot is already correct; transformOrigin is intentionally avoided
24
25
  // since it isn't reliably honored across RN renderers. Primary is centered at 12 o'clock,
25
26
  // tertiary fills clockwise from there (bottom-right), secondary fills the remainder (bottom-left).
26
27
  const buildWedges = (weights: PaletteWeights) => {
@@ -61,6 +62,7 @@ type Props = {
61
62
 
62
63
  export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', weights, colors = defaultColors, blur: blurProp }: Props) => {
63
64
  const blur = useBlur(blurProp)
65
+ const { colors: themeColors } = useTheme()
64
66
  const [open, setOpen] = useState(false)
65
67
  const { onLayout, size } = useSwatchSize(SIZE)
66
68
 
@@ -69,8 +71,8 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
69
71
  <TouchableRipple onPress={() => setOpen(true)} style={styles.trigger} borderless>
70
72
  <View style={styles.triggerContent}>
71
73
  <Pie palette={getTriadicPalette(value, harmony)} weights={weights} />
72
- <View pointerEvents='none' style={styles.triggerIcon}>
73
- <Icon source='palette' size={20} color='white' />
74
+ <View pointerEvents='none' style={[styles.triggerIcon, { borderRadius: SIZE / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)]}>
75
+ <Icon source='palette' size={20} color={getContrastColor(value)} />
74
76
  </View>
75
77
  </View>
76
78
  </TouchableRipple>
@@ -88,11 +90,9 @@ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary'
88
90
  style={[styles.swatch, { height: size, width: size }]}
89
91
  >
90
92
  <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
- )}
93
+ <View pointerEvents='none' style={[styles.ring, { borderRadius: size / 2 }, getSwatchRing(hex, value === hex, themeColors.outlineVariant)]}>
94
+ {value === hex && <Icon source='check' size={20} color={getContrastColor(hex)} />}
95
+ </View>
96
96
  </TouchableOpacity>
97
97
  ))}
98
98
  </View>
@@ -107,7 +107,7 @@ const styles = StyleSheet.create({
107
107
  innerRotator: { height: '100%', left: '-100%', overflow: 'hidden', position: 'absolute', top: 0, width: '200%' },
108
108
  pie: { overflow: 'hidden' },
109
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 },
110
+ ring: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0 },
111
111
  swatch: { alignItems: 'center', justifyContent: 'center' },
112
112
  swatches: { flexDirection: 'row', flexWrap: 'wrap', gap: SWATCH_GRID_GAP, paddingTop: 12 },
113
113
  trigger: { alignItems: 'center', alignSelf: 'flex-start', borderRadius: SIZE / 2, height: SIZE, justifyContent: 'center', width: SIZE },
package/src/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { useBlur } from './BlurContext'
2
2
  export { Appbar } from './components/Appbar'
3
+ export type { AppearanceIcons } from './components/AppearancePicker'
3
4
  export { AppearancePicker } from './components/AppearancePicker'
4
- export type { BlurViewProps } from './components/BlurView'
5
+ export type { BlurViewProps, ExpoBlurModule } from './components/BlurView'
5
6
  export { BlurView } from './components/BlurView'
6
7
  export { BottomNavigation } from './components/BottomNavigation'
7
8
  export { Button } from './components/Button'
@@ -20,21 +21,27 @@ export { Menu } from './components/Menu'
20
21
  export type { PaletteWeights } from './components/PalettePicker'
21
22
  export { PalettePicker } from './components/PalettePicker'
22
23
  export { TextInput } from './components/TextInput'
24
+ export type { ExpoNavigationBarModule } from './navigation-bar'
23
25
  export type { PaperDefaults } from './PaperDefaultsContext'
24
26
  export { usePaperDefaults } from './PaperDefaultsContext'
25
27
  export type { ThemeState } from './redux/themeSlice'
26
28
  export { createThemeReducer, selectThemeAppearance, selectThemeBlur, selectThemeColor, selectThemeHarmony, themeActions, themeReducer } from './redux/themeSlice'
27
29
  export type { ProviderProps } from './ThemeProvider'
28
- export { Provider } from './ThemeProvider'
30
+ export { Provider, useBlurModule, useNavBarContext } from './ThemeProvider'
29
31
  export type { ThemeSettings, ThemeSettingsContextType } from './ThemeSettingsContext'
30
32
  export { defaultThemeSettings, ThemeSettingsContext } from './ThemeSettingsContext'
31
- export type { ThemeAppearance } from './useComputedTheme'
32
- export { useComputedTheme } from './useComputedTheme'
33
+ export type { AutoPaperTheme, SemanticColorRoles, ThemeAppearance } from './useComputedTheme'
34
+ export { SEMANTIC_BASE_COLORS, useComputedTheme } from './useComputedTheme'
35
+ export { useAutoPaperTheme } from './useTheme'
33
36
  export { useThemeSettings } from './useThemeSettings'
34
37
  export { getBlendedColor } from './utils/getBlendedColor'
38
+ export type { ColorRoles } from './utils/getColorRoles'
39
+ export { getColorRoles } from './utils/getColorRoles'
35
40
  export { getHex } from './utils/getHex'
36
41
  export type { RGB } from './utils/getRgb'
37
42
  export { getRgb } from './utils/getRgb'
43
+ export { getContrastColor } from './utils/getSwatchContrast'
44
+ export { getThirdColor } from './utils/getThirdColor'
38
45
  export { getTintTextColor } from './utils/getTintTextColor'
39
46
  export { getTonalColor } from './utils/getTonalColor'
40
47
  export type { ColorHarmony, TriadicPalette } from './utils/getTriadicPalette'
@@ -1,22 +1,14 @@
1
- // Minimal local shape of expo-navigation-bar covering only the members used below —
1
+ // Minimal local shape of expo-navigation-bar covering only the members used below:
2
2
  // avoids forcing TypeScript to resolve the optional peer's real types for consumers
3
3
  // who never installed it.
4
- interface ExpoNavigationBarModule {
4
+ export interface ExpoNavigationBarModule {
5
5
  NavigationBar: {
6
6
  setStyle: (style: 'light' | 'dark') => void
7
7
  }
8
8
  }
9
9
 
10
- export const navigationBar = (() => {
11
- try {
12
- return require('expo-navigation-bar') as ExpoNavigationBarModule
13
- } catch {
14
- return null
15
- }
16
- })()
17
-
18
10
  /** Sets the Android navigation bar icon style (expo-navigation-bar >= 56, edge-to-edge API). */
19
- export const setNavigationBarStyle = (dark: boolean) => {
11
+ export const setNavigationBarStyle = (navigationBar: ExpoNavigationBarModule | undefined, dark: boolean) => {
20
12
  if (!navigationBar) return
21
13
  navigationBar.NavigationBar.setStyle(dark ? 'light' : 'dark')
22
14
  }
@@ -1,12 +1,12 @@
1
1
  import { ThemeAppearance } from '../useComputedTheme'
2
- import type { ColorHarmony } from '../utils/getTriadicPalette'
2
+ import type { ColorHarmony, TriadicPalette } from '../utils/getTriadicPalette'
3
3
 
4
4
  export type { ThemeAppearance }
5
5
 
6
6
  export type ThemeState = {
7
7
  appearance: ThemeAppearance
8
8
  blur: boolean
9
- color: string
9
+ color: string | TriadicPalette
10
10
  harmony: ColorHarmony
11
11
  }
12
12
 
@@ -17,7 +17,7 @@ const defaultInitialState: ThemeState = {
17
17
  harmony: 'split-complementary'
18
18
  }
19
19
 
20
- // Hand-rolled slice — no @reduxjs/toolkit dependency. Action types and creator
20
+ // Hand-rolled slice (no @reduxjs/toolkit dependency). Action types and creator
21
21
  // behavior match the previous createSlice implementation exactly, so this works
22
22
  // with RTK stores, vanilla Redux, or any reducer-based state container.
23
23
  type PayloadAction<P> = { payload: P; type: string }
@@ -32,7 +32,7 @@ const createAction = <P>(type: string) => {
32
32
  const initialize = createAction<Partial<ThemeState>>('theme/initialize')
33
33
  const setAppearance = createAction<ThemeAppearance>('theme/setAppearance')
34
34
  const setBlur = createAction<boolean>('theme/setBlur')
35
- const setColor = createAction<string>('theme/setColor')
35
+ const setColor = createAction<string | TriadicPalette>('theme/setColor')
36
36
  const setHarmony = createAction<ColorHarmony>('theme/setHarmony')
37
37
 
38
38
  export const themeActions = { initialize, setAppearance, setBlur, setColor, setHarmony }
@@ -3,54 +3,134 @@ import { Appearance } from 'react-native'
3
3
  import { MD3DarkTheme, MD3LightTheme, MD3Theme } from 'react-native-paper'
4
4
 
5
5
  import { getBlendedColor } from './utils/getBlendedColor'
6
- import { getTonalColor } from './utils/getTonalColor'
7
- import { type ColorHarmony, getTriadicPalette } from './utils/getTriadicPalette'
8
- import { isDarkColor } from './utils/isDarkColor'
6
+ import { getColorRoles } from './utils/getColorRoles'
7
+ import { getHex } from './utils/getHex'
8
+ import { getRgb } from './utils/getRgb'
9
+ import { type ColorHarmony, getTriadicPalette, type TriadicPalette } from './utils/getTriadicPalette'
10
+
11
+ function normalizeExplicitColor(value: string): string {
12
+ if (!getRgb(value)) throw new Error(`Invalid color format: ${value}`)
13
+ return getHex(value) ?? value
14
+ }
9
15
 
10
16
  export type ThemeAppearance = 'system' | 'light' | 'dark'
11
17
 
12
- export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): MD3Theme | null {
13
- const [theme, setTheme] = useState<MD3Theme | null>(null)
18
+ // Fixed hue/saturation, like MD3's own `error` role (see LightTheme.tsx/DarkTheme.tsx: error is a
19
+ // static Material palette constant, never derived from the seed color either). A "warning" amber
20
+ // shouldn't drift toward the app's accent hue any more than the built-in danger red does; only
21
+ // lightness adapts per light/dark mode below, via getColorRoles. #A15C00 (not the more obvious
22
+ // goldenrod #B8860B) was picked because its onWarning text fails WCAG AA (3.05:1) against the base
23
+ // color itself; see getColorRoles.test.ts's "onColor" caveat: the onColor heuristic (isDarkColor-
24
+ // based binary lightness pick) isn't a verified guarantee like onContainer is, so the base hex
25
+ // itself has to be chosen to land safely, not assumed to.
26
+ //
27
+ // `danger` is deliberately a separate namespace from MD3's own `error` (below, untouched) rather
28
+ // than a fourth tier folded into it: react-native-paper's own components (TextInput's error state,
29
+ // HelperText, Badge) read theme.colors.error/onError directly, so a consumer that wants a
30
+ // getColorRoles-derived "high severity" color without changing what those components render needs
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
33
+ // base above.
34
+ export const SEMANTIC_BASE_COLORS = {
35
+ success: '#2E7D32',
36
+ warning: '#A15C00',
37
+ danger: '#B00020'
38
+ }
39
+
40
+ export type SemanticColorRoles = {
41
+ success: string
42
+ onSuccess: string
43
+ successContainer: string
44
+ onSuccessContainer: string
45
+ warning: string
46
+ onWarning: string
47
+ warningContainer: string
48
+ onWarningContainer: string
49
+ danger: string
50
+ onDanger: string
51
+ dangerContainer: string
52
+ onDangerContainer: string
53
+ }
54
+
55
+ // MD3Theme's `colors` is a closed `type` (not an `interface`), so it can't be widened via
56
+ // TypeScript's declare-module augmentation: this is the extension point instead, meant to be used
57
+ // with react-native-paper's own `useTheme<T>()` generic (see useTheme.ts's useAutoPaperTheme).
58
+ export type AutoPaperTheme = Omit<MD3Theme, 'colors'> & { colors: MD3Theme['colors'] & SemanticColorRoles }
59
+
60
+ export function useComputedTheme(appearance: ThemeAppearance, color: string | TriadicPalette, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
61
+ const [theme, setTheme] = useState<AutoPaperTheme | null>(null)
62
+
63
+ // Destructured to primitives (rather than depending on `color` itself below) so that passing a
64
+ // fresh `{ primary, secondary, tertiary }` object literal inline on every render, the natural
65
+ // way to use this mode, doesn't retrigger computeTheme unless a field actually changed.
66
+ const primaryInput = typeof color === 'string' ? color : color.primary
67
+ const secondaryInput = typeof color === 'string' ? null : color.secondary
68
+ const tertiaryInput = typeof color === 'string' ? null : color.tertiary
14
69
 
15
70
  const computeTheme = useCallback(() => {
16
71
  let resolved: string | null | undefined = appearance
17
72
  if (resolved === 'system') resolved = Appearance.getColorScheme()
18
73
  if (resolved === 'no-preference' || !resolved) resolved = 'light'
19
74
  const base = resolved === 'dark' ? MD3DarkTheme : MD3LightTheme
75
+ const surface = base.colors.surface
20
76
 
21
- const _theme: MD3Theme = {
77
+ // error/onError/errorContainer/onErrorContainer are deliberately left untouched below (not run
78
+ // through getColorRoles like success/warning/primary/secondary/tertiary are): they're a fixed,
79
+ // Google-verified Material palette that react-native-paper's own components (TextInput's error
80
+ // state, HelperText, Badge) read directly, so re-deriving them would change form-validation
81
+ // colors across every consuming app and risk diverging from a contrast guarantee that's already
82
+ // correct as shipped.
83
+ const successRoles = getColorRoles(SEMANTIC_BASE_COLORS.success, surface)
84
+ const warningRoles = getColorRoles(SEMANTIC_BASE_COLORS.warning, surface)
85
+ const dangerRoles = getColorRoles(SEMANTIC_BASE_COLORS.danger, surface)
86
+
87
+ const _theme: AutoPaperTheme = {
22
88
  ...base,
23
89
  colors: {
24
90
  ...base.colors,
25
- elevation: { ...base.colors.elevation }
91
+ elevation: { ...base.colors.elevation },
92
+ success: successRoles.color,
93
+ onSuccess: successRoles.onColor,
94
+ successContainer: successRoles.container,
95
+ onSuccessContainer: successRoles.onContainer,
96
+ warning: warningRoles.color,
97
+ onWarning: warningRoles.onColor,
98
+ warningContainer: warningRoles.container,
99
+ onWarningContainer: warningRoles.onContainer,
100
+ danger: dangerRoles.color,
101
+ onDanger: dangerRoles.onColor,
102
+ dangerContainer: dangerRoles.container,
103
+ onDangerContainer: dangerRoles.onContainer
26
104
  }
27
105
  }
28
106
 
29
- const palette = getTriadicPalette(color, harmony)
30
- const surface = base.colors.surface
107
+ const palette: TriadicPalette = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony)
31
108
 
32
- _theme.colors.primary = palette.primary
33
- _theme.colors.onPrimary = getTonalColor(palette.primary, isDarkColor(palette.primary) ? 0.95 : 0.08)
34
- _theme.colors.primaryContainer = getBlendedColor(palette.primary, surface, 0.15)
35
- _theme.colors.onPrimaryContainer = getTonalColor(palette.primary, isDarkColor(_theme.colors.primaryContainer) ? 0.92 : 0.12)
109
+ const primaryRoles = getColorRoles(palette.primary, surface)
110
+ _theme.colors.primary = primaryRoles.color
111
+ _theme.colors.onPrimary = primaryRoles.onColor
112
+ _theme.colors.primaryContainer = primaryRoles.container
113
+ _theme.colors.onPrimaryContainer = primaryRoles.onContainer
36
114
 
37
- _theme.colors.secondary = palette.secondary
38
- _theme.colors.onSecondary = getTonalColor(palette.secondary, isDarkColor(palette.secondary) ? 0.95 : 0.08)
39
- _theme.colors.secondaryContainer = getBlendedColor(palette.secondary, surface, 0.15)
40
- _theme.colors.onSecondaryContainer = getTonalColor(palette.secondary, isDarkColor(_theme.colors.secondaryContainer) ? 0.92 : 0.12)
115
+ const secondaryRoles = getColorRoles(palette.secondary, surface)
116
+ _theme.colors.secondary = secondaryRoles.color
117
+ _theme.colors.onSecondary = secondaryRoles.onColor
118
+ _theme.colors.secondaryContainer = secondaryRoles.container
119
+ _theme.colors.onSecondaryContainer = secondaryRoles.onContainer
41
120
 
42
- _theme.colors.tertiary = palette.tertiary
43
- _theme.colors.onTertiary = getTonalColor(palette.tertiary, isDarkColor(palette.tertiary) ? 0.95 : 0.08)
44
- _theme.colors.tertiaryContainer = getBlendedColor(palette.tertiary, surface, 0.15)
45
- _theme.colors.onTertiaryContainer = getTonalColor(palette.tertiary, isDarkColor(_theme.colors.tertiaryContainer) ? 0.92 : 0.12)
121
+ const tertiaryRoles = getColorRoles(palette.tertiary, surface)
122
+ _theme.colors.tertiary = tertiaryRoles.color
123
+ _theme.colors.onTertiary = tertiaryRoles.onColor
124
+ _theme.colors.tertiaryContainer = tertiaryRoles.container
125
+ _theme.colors.onTertiaryContainer = tertiaryRoles.onContainer
46
126
 
47
- const blended = getBlendedColor(color, palette.secondary, 0.5)
127
+ const blended = getBlendedColor(palette.primary, palette.secondary, 0.5)
48
128
  const tintSurface = (alpha: number) => getBlendedColor(blended, surface, alpha)
49
129
 
50
130
  _theme.colors.background = base.dark ? '#0F0F0F' : '#F0F0F0'
51
131
  _theme.colors.surface = surface
52
132
  _theme.colors.surfaceVariant = tintSurface(0.2)
53
- _theme.colors.outline = getBlendedColor(color, base.colors.outline, 0.45)
133
+ _theme.colors.outline = getBlendedColor(palette.primary, base.colors.outline, 0.45)
54
134
 
55
135
  const grey = base.dark ? '#424242' : '#c2c2c2'
56
136
  const blendedGrey = getBlendedColor(blended, grey, 0.05)
@@ -64,7 +144,7 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string, har
64
144
  }
65
145
 
66
146
  setTheme(_theme)
67
- }, [appearance, color, harmony])
147
+ }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony])
68
148
 
69
149
  useEffect(() => {
70
150
  computeTheme()
@@ -2,7 +2,7 @@ import { useState } from 'react'
2
2
  import type { LayoutChangeEvent } from 'react-native'
3
3
 
4
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
5
+ // (phone vs tablet vs web): sizing the swatches to fit, rather than wrapping fixed-size swatches
6
6
  // and hoping the total divides evenly into whatever column count the width happens to produce.
7
7
  export const SWATCH_GRID_COLUMNS = 5
8
8
  export const SWATCH_GRID_GAP = 12
@@ -0,0 +1,11 @@
1
+ import { useTheme as usePaperTheme } from 'react-native-paper'
2
+
3
+ import type { AutoPaperTheme } from './useComputedTheme'
4
+
5
+ // react-native-paper's own useTheme is generic (`useTheme<T = MD3Theme>()`) specifically so
6
+ // consumers can extend MD3Theme with their own fields: this is that extension point, so any
7
+ // component can read theme.colors.success/warning (and their onX/Container variants) with full
8
+ // type safety, without redeclaring the type parameter at every call site. Named distinctly from
9
+ // react-native-paper's own `useTheme` (not re-exported under the same name) to avoid the two being
10
+ // silently interchangeable at an import statement.
11
+ export const useAutoPaperTheme = (): AutoPaperTheme => usePaperTheme<AutoPaperTheme>()
@@ -0,0 +1,29 @@
1
+ import { getBlendedColor } from './getBlendedColor'
2
+ import { getTonalColor } from './getTonalColor'
3
+ import { isDarkColor } from './isDarkColor'
4
+
5
+ export type ColorRoles = {
6
+ color: string
7
+ onColor: string
8
+ container: string
9
+ onContainer: string
10
+ }
11
+
12
+ // The MD3 "color / onColor / container / onContainer" quadruple, generalized from the math
13
+ // useComputedTheme already uses for primary/secondary/tertiary: onColor is tuned to sit on the
14
+ // vivid color itself, container is base blended toward the theme's real surface (so it inherits
15
+ // that surface's actual lightness rather than a fixed light/dark guess), and onContainer is tuned
16
+ // against the *computed* container rather than a light/dark assumption, so it stays readable
17
+ // whatever surface color it's handed. onContainer also reads safely directly on `surface` itself
18
+ // (not just on `container`), since container is only lightly blended toward it.
19
+ export const getColorRoles = (base: string, surface: string, containerAlpha = 0.15): ColorRoles => {
20
+ const container = getBlendedColor(base, surface, containerAlpha)
21
+ return {
22
+ color: base,
23
+ onColor: getTonalColor(base, isDarkColor(base) ? 0.95 : 0.08),
24
+ container,
25
+ onContainer: getTonalColor(base, isDarkColor(container) ? 0.92 : 0.12)
26
+ }
27
+ }
28
+
29
+ export default getColorRoles
@@ -0,0 +1,26 @@
1
+ import { isDarkColor } from './isDarkColor'
2
+
3
+ // Width of the outline every swatch carries so its edge is defined against the surface behind it.
4
+ const RING_WIDTH = 1
5
+ // The selected swatch trades that outline for a thicker one in a contrasting colour.
6
+ const SELECTED_RING_WIDTH = 3
7
+
8
+ // Colour for content sitting directly on a fill of `color` (a check mark or a 20px icon). Plain
9
+ // white/black rather than a tinted tone from getTonalColor, because this has to stay legible on
10
+ // fully saturated fills and on pure black/white alike.
11
+ export const getContrastColor = (color: string): string => (isDarkColor(color) ? '#ffffff' : '#000000')
12
+
13
+ // Two different jobs, which is why the unselected and selected rings resolve against different
14
+ // things:
15
+ //
16
+ // - Unselected: separate the swatch from the *surface* it sits on. A white swatch on a light
17
+ // dialog and a black swatch on a dark one are otherwise edgeless: invisible, not just subtle.
18
+ // The theme's outline colour tracks the surface, so it works in both appearances.
19
+ // - Selected: read as a marker against the *swatch's own fill*. A hardcoded white ring vanished on
20
+ // the white swatch, so picking white gave no feedback that anything had been picked.
21
+ export const getSwatchRing = (color: string, selected: boolean, outlineColor: string) => ({
22
+ borderColor: selected ? getContrastColor(color) : outlineColor,
23
+ borderWidth: selected ? SELECTED_RING_WIDTH : RING_WIDTH
24
+ })
25
+
26
+ export default getContrastColor