@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.
- package/README.md +121 -28
- package/dist/index.d.mts +75 -12
- package/dist/index.d.ts +75 -12
- package/dist/index.js +260 -116
- package/dist/index.mjs +241 -111
- package/package.json +5 -4
- package/src/ThemeProvider.tsx +22 -6
- package/src/ThemeSettingsContext.ts +2 -2
- package/src/components/AppearancePicker.tsx +25 -5
- package/src/components/BlurView.tsx +14 -17
- package/src/components/BottomNavigation.tsx +5 -5
- package/src/components/ColorPicker.tsx +7 -6
- package/src/components/PalettePicker.tsx +11 -11
- package/src/index.ts +11 -4
- package/src/navigation-bar.ts +3 -11
- package/src/redux/themeSlice.ts +4 -4
- package/src/useComputedTheme.ts +104 -24
- package/src/useSwatchGrid.ts +1 -1
- package/src/useTheme.ts +11 -0
- package/src/utils/getColorRoles.ts +29 -0
- package/src/utils/getSwatchContrast.ts +26 -0
- package/src/utils/getThirdColor.ts +103 -0
|
@@ -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
|
|
6
|
-
{ label: 'System', value: 'system'
|
|
7
|
-
{ label: 'Light', value: 'light'
|
|
8
|
-
{ label: 'Dark', value: 'dark'
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 &&
|
|
58
|
+
if (blur && expoBlur)
|
|
62
59
|
return (
|
|
63
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
<
|
|
93
|
-
|
|
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
|
-
|
|
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'
|
package/src/navigation-bar.ts
CHANGED
|
@@ -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
|
}
|
package/src/redux/themeSlice.ts
CHANGED
|
@@ -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
|
|
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 }
|
package/src/useComputedTheme.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
33
|
-
_theme.colors.
|
|
34
|
-
_theme.colors.
|
|
35
|
-
_theme.colors.
|
|
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
|
-
|
|
38
|
-
_theme.colors.
|
|
39
|
-
_theme.colors.
|
|
40
|
-
_theme.colors.
|
|
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
|
-
|
|
43
|
-
_theme.colors.
|
|
44
|
-
_theme.colors.
|
|
45
|
-
_theme.colors.
|
|
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(
|
|
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(
|
|
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,
|
|
147
|
+
}, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony])
|
|
68
148
|
|
|
69
149
|
useEffect(() => {
|
|
70
150
|
computeTheme()
|
package/src/useSwatchGrid.ts
CHANGED
|
@@ -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)
|
|
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
|
package/src/useTheme.ts
ADDED
|
@@ -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
|