@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.
- package/README.md +77 -5
- package/dist/index.d.mts +63 -10
- package/dist/index.d.ts +63 -10
- package/dist/index.js +238 -63
- package/dist/index.mjs +222 -62
- package/package.json +4 -2
- package/src/components/AppearancePicker.tsx +25 -5
- package/src/components/BlurView.tsx +1 -1
- package/src/components/ColorPicker.tsx +17 -10
- package/src/components/Dialog.tsx +57 -22
- package/src/components/PalettePicker.tsx +117 -0
- package/src/index.ts +9 -2
- package/src/useComputedTheme.ts +87 -21
- package/src/useSwatchGrid.ts +18 -0
- package/src/useTheme.ts +11 -0
- package/src/utils/getColorRoles.ts +29 -0
- package/src/utils/getSwatchContrast.ts +26 -0
|
@@ -1,47 +1,83 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { Animated,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15
|
-
const
|
|
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, {
|
|
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, {
|
|
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
|
-
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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'
|
package/src/useComputedTheme.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
_theme.colors.
|
|
44
|
-
_theme.colors.
|
|
45
|
-
_theme.colors.
|
|
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
|
+
}
|
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
|