@rific/auto-paper 0.6.4 → 0.8.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.
@@ -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, useTheme } from 'react-native-paper'
4
+
5
+ import { useBlur } from '../BlurContext'
6
+ import { SWATCH_GRID_GAP, useSwatchSize } from '../useSwatchGrid'
7
+ import { getContrastColor, getSwatchRing } from '../utils/getSwatchContrast'
8
+ import type { ColorHarmony, TriadicPalette } from '../utils/getTriadicPalette'
9
+ import { getTriadicPalette } from '../utils/getTriadicPalette'
10
+ import type { SeedColor } from './ColorPicker'
11
+ import { defaultColors } from './ColorPicker'
12
+ import { Dialog } from './Dialog'
13
+
14
+ const SIZE = 48
15
+
16
+ export type PaletteWeights = { primary: number; secondary: number; tertiary: number }
17
+
18
+ const defaultWeights: PaletteWeights = { primary: 2, secondary: 1, tertiary: 1 }
19
+
20
+ // Each wedge = a static right-half mask, doubly rotated: an outer spin positions the slice's
21
+ // leading edge around the circle, then an inner spin (its own share, minus 180deg) carves the
22
+ // trailing edge — together covering exactly `share` of weights' total, for any share <= 50%.
23
+ // Both rotators are sized to exactly overlap the full circle so React Native's default
24
+ // (own-center) rotation pivot is already correct — transformOrigin is intentionally avoided
25
+ // since it isn't reliably honored across RN renderers. Primary is centered at 12 o'clock,
26
+ // tertiary fills clockwise from there (bottom-right), secondary fills the remainder (bottom-left).
27
+ const buildWedges = (weights: PaletteWeights) => {
28
+ const total = weights.primary + weights.secondary + weights.tertiary
29
+ const primary = (weights.primary / total) * 360
30
+ const secondary = (weights.secondary / total) * 360
31
+ const tertiary = (weights.tertiary / total) * 360
32
+
33
+ return [
34
+ { key: 'primary' as const, rotate: -primary / 2, share: primary },
35
+ { key: 'tertiary' as const, rotate: primary / 2, share: tertiary },
36
+ { key: 'secondary' as const, rotate: primary / 2 + tertiary, share: secondary }
37
+ ]
38
+ }
39
+
40
+ const Pie = ({ palette, weights = defaultWeights, size = SIZE }: { palette: TriadicPalette; weights?: PaletteWeights; size?: number }) => (
41
+ <View style={[styles.pie, { borderRadius: size / 2, height: size, width: size }]}>
42
+ {buildWedges(weights).map(({ key, rotate, share }) => (
43
+ <View key={key} style={[styles.rotator, { transform: [{ rotate: `${rotate}deg` }] }]}>
44
+ <View style={styles.halfMask}>
45
+ <View style={[styles.innerRotator, { transform: [{ rotate: `${share - 180}deg` }] }]}>
46
+ <View style={[styles.wedgeFill, { backgroundColor: palette[key] }]} />
47
+ </View>
48
+ </View>
49
+ </View>
50
+ ))}
51
+ </View>
52
+ )
53
+
54
+ type Props = {
55
+ value: string
56
+ onChange: (color: string) => void
57
+ harmony?: ColorHarmony
58
+ weights?: PaletteWeights
59
+ colors?: SeedColor[]
60
+ blur?: boolean
61
+ }
62
+
63
+ export const PalettePicker = ({ value, onChange, harmony = 'split-complementary', weights, colors = defaultColors, blur: blurProp }: Props) => {
64
+ const blur = useBlur(blurProp)
65
+ const { colors: themeColors } = useTheme()
66
+ const [open, setOpen] = useState(false)
67
+ const { onLayout, size } = useSwatchSize(SIZE)
68
+
69
+ return (
70
+ <>
71
+ <TouchableRipple onPress={() => setOpen(true)} style={styles.trigger} borderless>
72
+ <View style={styles.triggerContent}>
73
+ <Pie palette={getTriadicPalette(value, harmony)} weights={weights} />
74
+ <View pointerEvents='none' style={[styles.triggerIcon, { borderRadius: SIZE / 2 }, getSwatchRing(value, false, themeColors.outlineVariant)]}>
75
+ <Icon source='palette' size={20} color={getContrastColor(value)} />
76
+ </View>
77
+ </View>
78
+ </TouchableRipple>
79
+
80
+ <Dialog blur={blur} visible={open} onDismiss={() => setOpen(false)}>
81
+ <Dialog.Content>
82
+ <View style={styles.swatches} onLayout={onLayout}>
83
+ {colors.map(({ value: hex }) => (
84
+ <TouchableOpacity
85
+ key={hex}
86
+ onPress={() => {
87
+ onChange(hex)
88
+ setOpen(false)
89
+ }}
90
+ style={[styles.swatch, { height: size, width: size }]}
91
+ >
92
+ <Pie palette={getTriadicPalette(hex, harmony)} weights={weights} size={size} />
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
+ </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
+ ring: { alignItems: 'center', 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
@@ -1,5 +1,6 @@
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
5
  export type { BlurViewProps } from './components/BlurView'
5
6
  export { BlurView } from './components/BlurView'
@@ -17,6 +18,8 @@ export type { IconButtonProps } from './components/IconButton'
17
18
  export { IconButton } from './components/IconButton'
18
19
  export type { MenuProps } from './components/Menu'
19
20
  export { Menu } from './components/Menu'
21
+ export type { PaletteWeights } from './components/PalettePicker'
22
+ export { PalettePicker } from './components/PalettePicker'
20
23
  export { TextInput } from './components/TextInput'
21
24
  export type { PaperDefaults } from './PaperDefaultsContext'
22
25
  export { usePaperDefaults } from './PaperDefaultsContext'
@@ -26,13 +29,17 @@ export type { ProviderProps } from './ThemeProvider'
26
29
  export { Provider } from './ThemeProvider'
27
30
  export type { ThemeSettings, ThemeSettingsContextType } from './ThemeSettingsContext'
28
31
  export { defaultThemeSettings, ThemeSettingsContext } from './ThemeSettingsContext'
29
- export type { ThemeAppearance } from './useComputedTheme'
30
- export { useComputedTheme } from './useComputedTheme'
32
+ export type { AutoPaperTheme, SemanticColorRoles, ThemeAppearance } from './useComputedTheme'
33
+ export { SEMANTIC_BASE_COLORS, useComputedTheme } from './useComputedTheme'
34
+ export { useAutoPaperTheme } from './useTheme'
31
35
  export { useThemeSettings } from './useThemeSettings'
32
36
  export { getBlendedColor } from './utils/getBlendedColor'
37
+ export type { ColorRoles } from './utils/getColorRoles'
38
+ export { getColorRoles } from './utils/getColorRoles'
33
39
  export { getHex } from './utils/getHex'
34
40
  export type { RGB } from './utils/getRgb'
35
41
  export { getRgb } from './utils/getRgb'
42
+ export { getContrastColor } from './utils/getSwatchContrast'
36
43
  export { getTintTextColor } from './utils/getTintTextColor'
37
44
  export { getTonalColor } from './utils/getTonalColor'
38
45
  export type { ColorHarmony, TriadicPalette } from './utils/getTriadicPalette'
@@ -3,46 +3,112 @@ 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'
6
+ import { getColorRoles } from './utils/getColorRoles'
7
7
  import { type ColorHarmony, getTriadicPalette } from './utils/getTriadicPalette'
8
- import { isDarkColor } from './utils/isDarkColor'
9
8
 
10
9
  export type ThemeAppearance = 'system' | 'light' | 'dark'
11
10
 
12
- export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): MD3Theme | null {
13
- const [theme, setTheme] = useState<MD3Theme | null>(null)
11
+ // Fixed hue/saturation, like MD3's own `error` role (see LightTheme.tsx/DarkTheme.tsx — error is a
12
+ // static Material palette constant, never derived from the seed color either). A "warning" amber
13
+ // shouldn't drift toward the app's accent hue any more than the built-in danger red does; only
14
+ // lightness adapts per light/dark mode below, via getColorRoles. #A15C00 (not the more obvious
15
+ // goldenrod #B8860B) was picked because its onWarning text fails WCAG AA (3.05:1) against the base
16
+ // color itself — see getColorRoles.test.ts's "onColor" caveat: the onColor heuristic (isDarkColor-
17
+ // based binary lightness pick) isn't a verified guarantee like onContainer is, so the base hex
18
+ // itself has to be chosen to land safely, not assumed to.
19
+ //
20
+ // `danger` is deliberately a separate namespace from MD3's own `error` (below, untouched) rather
21
+ // than a fourth tier folded into it — react-native-paper's own components (TextInput's error state,
22
+ // HelperText, Badge) read theme.colors.error/onError directly, so a consumer that wants a
23
+ // getColorRoles-derived "high severity" color without changing what those components render needs
24
+ // its own name. #B00020 already clears WCAG AA for both onDanger-vs-danger (6.16:1) and
25
+ // dangerContainer/onDangerContainer (12–17:1 across modes) — verified the same way as warning's
26
+ // base above.
27
+ export const SEMANTIC_BASE_COLORS = {
28
+ success: '#2E7D32',
29
+ warning: '#A15C00',
30
+ danger: '#B00020'
31
+ }
32
+
33
+ export type SemanticColorRoles = {
34
+ success: string
35
+ onSuccess: string
36
+ successContainer: string
37
+ onSuccessContainer: string
38
+ warning: string
39
+ onWarning: string
40
+ warningContainer: string
41
+ onWarningContainer: string
42
+ danger: string
43
+ onDanger: string
44
+ dangerContainer: string
45
+ onDangerContainer: string
46
+ }
47
+
48
+ // MD3Theme's `colors` is a closed `type` (not an `interface`), so it can't be widened via
49
+ // TypeScript's declare-module augmentation — this is the extension point instead, meant to be used
50
+ // with react-native-paper's own `useTheme<T>()` generic (see useTheme.ts's useAutoPaperTheme).
51
+ export type AutoPaperTheme = Omit<MD3Theme, 'colors'> & { colors: MD3Theme['colors'] & SemanticColorRoles }
52
+
53
+ export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
54
+ const [theme, setTheme] = useState<AutoPaperTheme | null>(null)
14
55
 
15
56
  const computeTheme = useCallback(() => {
16
57
  let resolved: string | null | undefined = appearance
17
58
  if (resolved === 'system') resolved = Appearance.getColorScheme()
18
59
  if (resolved === 'no-preference' || !resolved) resolved = 'light'
19
60
  const base = resolved === 'dark' ? MD3DarkTheme : MD3LightTheme
61
+ const surface = base.colors.surface
20
62
 
21
- const _theme: MD3Theme = {
63
+ // error/onError/errorContainer/onErrorContainer are deliberately left untouched below (not run
64
+ // through getColorRoles like success/warning/primary/secondary/tertiary are) — they're a fixed,
65
+ // Google-verified Material palette that react-native-paper's own components (TextInput's error
66
+ // state, HelperText, Badge) read directly, so re-deriving them would change form-validation
67
+ // colors across every consuming app and risk diverging from a contrast guarantee that's already
68
+ // correct as shipped.
69
+ const successRoles = getColorRoles(SEMANTIC_BASE_COLORS.success, surface)
70
+ const warningRoles = getColorRoles(SEMANTIC_BASE_COLORS.warning, surface)
71
+ const dangerRoles = getColorRoles(SEMANTIC_BASE_COLORS.danger, surface)
72
+
73
+ const _theme: AutoPaperTheme = {
22
74
  ...base,
23
75
  colors: {
24
76
  ...base.colors,
25
- elevation: { ...base.colors.elevation }
77
+ elevation: { ...base.colors.elevation },
78
+ success: successRoles.color,
79
+ onSuccess: successRoles.onColor,
80
+ successContainer: successRoles.container,
81
+ onSuccessContainer: successRoles.onContainer,
82
+ warning: warningRoles.color,
83
+ onWarning: warningRoles.onColor,
84
+ warningContainer: warningRoles.container,
85
+ onWarningContainer: warningRoles.onContainer,
86
+ danger: dangerRoles.color,
87
+ onDanger: dangerRoles.onColor,
88
+ dangerContainer: dangerRoles.container,
89
+ onDangerContainer: dangerRoles.onContainer
26
90
  }
27
91
  }
28
92
 
29
93
  const palette = getTriadicPalette(color, harmony)
30
- const surface = base.colors.surface
31
-
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)
36
-
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)
41
94
 
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)
95
+ const primaryRoles = getColorRoles(palette.primary, surface)
96
+ _theme.colors.primary = primaryRoles.color
97
+ _theme.colors.onPrimary = primaryRoles.onColor
98
+ _theme.colors.primaryContainer = primaryRoles.container
99
+ _theme.colors.onPrimaryContainer = primaryRoles.onContainer
100
+
101
+ const secondaryRoles = getColorRoles(palette.secondary, surface)
102
+ _theme.colors.secondary = secondaryRoles.color
103
+ _theme.colors.onSecondary = secondaryRoles.onColor
104
+ _theme.colors.secondaryContainer = secondaryRoles.container
105
+ _theme.colors.onSecondaryContainer = secondaryRoles.onContainer
106
+
107
+ const tertiaryRoles = getColorRoles(palette.tertiary, surface)
108
+ _theme.colors.tertiary = tertiaryRoles.color
109
+ _theme.colors.onTertiary = tertiaryRoles.onColor
110
+ _theme.colors.tertiaryContainer = tertiaryRoles.container
111
+ _theme.colors.onTertiaryContainer = tertiaryRoles.onContainer
46
112
 
47
113
  const blended = getBlendedColor(color, palette.secondary, 0.5)
48
114
  const tintSurface = (alpha: number) => getBlendedColor(blended, surface, alpha)
@@ -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
+ }
@@ -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