@rific/auto-paper 0.8.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 +76 -33
- package/dist/index.d.mts +37 -11
- package/dist/index.d.ts +37 -11
- package/dist/index.js +162 -82
- package/dist/index.mjs +142 -72
- package/package.json +5 -4
- package/src/ThemeProvider.tsx +22 -6
- package/src/ThemeSettingsContext.ts +2 -2
- package/src/components/AppearancePicker.tsx +3 -3
- package/src/components/BlurView.tsx +14 -17
- package/src/components/BottomNavigation.tsx +5 -5
- package/src/components/PalettePicker.tsx +2 -2
- package/src/index.ts +4 -2
- package/src/navigation-bar.ts +3 -11
- package/src/redux/themeSlice.ts +4 -4
- package/src/useComputedTheme.ts +26 -12
- package/src/useSwatchGrid.ts +1 -1
- package/src/useTheme.ts +1 -1
- package/src/utils/getColorRoles.ts +1 -1
- package/src/utils/getSwatchContrast.ts +2 -2
- package/src/utils/getThirdColor.ts +103 -0
package/src/useComputedTheme.ts
CHANGED
|
@@ -4,25 +4,32 @@ import { MD3DarkTheme, MD3LightTheme, MD3Theme } from 'react-native-paper'
|
|
|
4
4
|
|
|
5
5
|
import { getBlendedColor } from './utils/getBlendedColor'
|
|
6
6
|
import { getColorRoles } from './utils/getColorRoles'
|
|
7
|
-
import {
|
|
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
|
+
}
|
|
8
15
|
|
|
9
16
|
export type ThemeAppearance = 'system' | 'light' | 'dark'
|
|
10
17
|
|
|
11
|
-
// Fixed hue/saturation, like MD3's own `error` role (see LightTheme.tsx/DarkTheme.tsx
|
|
18
|
+
// Fixed hue/saturation, like MD3's own `error` role (see LightTheme.tsx/DarkTheme.tsx: error is a
|
|
12
19
|
// static Material palette constant, never derived from the seed color either). A "warning" amber
|
|
13
20
|
// shouldn't drift toward the app's accent hue any more than the built-in danger red does; only
|
|
14
21
|
// lightness adapts per light/dark mode below, via getColorRoles. #A15C00 (not the more obvious
|
|
15
22
|
// goldenrod #B8860B) was picked because its onWarning text fails WCAG AA (3.05:1) against the base
|
|
16
|
-
// color itself
|
|
23
|
+
// color itself; see getColorRoles.test.ts's "onColor" caveat: the onColor heuristic (isDarkColor-
|
|
17
24
|
// based binary lightness pick) isn't a verified guarantee like onContainer is, so the base hex
|
|
18
25
|
// itself has to be chosen to land safely, not assumed to.
|
|
19
26
|
//
|
|
20
27
|
// `danger` is deliberately a separate namespace from MD3's own `error` (below, untouched) rather
|
|
21
|
-
// than a fourth tier folded into it
|
|
28
|
+
// than a fourth tier folded into it: react-native-paper's own components (TextInput's error state,
|
|
22
29
|
// HelperText, Badge) read theme.colors.error/onError directly, so a consumer that wants a
|
|
23
30
|
// getColorRoles-derived "high severity" color without changing what those components render needs
|
|
24
31
|
// 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)
|
|
32
|
+
// dangerContainer/onDangerContainer (12–17:1 across modes), verified the same way as warning's
|
|
26
33
|
// base above.
|
|
27
34
|
export const SEMANTIC_BASE_COLORS = {
|
|
28
35
|
success: '#2E7D32',
|
|
@@ -46,13 +53,20 @@ export type SemanticColorRoles = {
|
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
// MD3Theme's `colors` is a closed `type` (not an `interface`), so it can't be widened via
|
|
49
|
-
// TypeScript's declare-module augmentation
|
|
56
|
+
// TypeScript's declare-module augmentation: this is the extension point instead, meant to be used
|
|
50
57
|
// with react-native-paper's own `useTheme<T>()` generic (see useTheme.ts's useAutoPaperTheme).
|
|
51
58
|
export type AutoPaperTheme = Omit<MD3Theme, 'colors'> & { colors: MD3Theme['colors'] & SemanticColorRoles }
|
|
52
59
|
|
|
53
|
-
export function useComputedTheme(appearance: ThemeAppearance, color: string, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
|
|
60
|
+
export function useComputedTheme(appearance: ThemeAppearance, color: string | TriadicPalette, harmony: ColorHarmony = 'split-complementary'): AutoPaperTheme | null {
|
|
54
61
|
const [theme, setTheme] = useState<AutoPaperTheme | null>(null)
|
|
55
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
|
|
69
|
+
|
|
56
70
|
const computeTheme = useCallback(() => {
|
|
57
71
|
let resolved: string | null | undefined = appearance
|
|
58
72
|
if (resolved === 'system') resolved = Appearance.getColorScheme()
|
|
@@ -61,7 +75,7 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string, har
|
|
|
61
75
|
const surface = base.colors.surface
|
|
62
76
|
|
|
63
77
|
// error/onError/errorContainer/onErrorContainer are deliberately left untouched below (not run
|
|
64
|
-
// through getColorRoles like success/warning/primary/secondary/tertiary are)
|
|
78
|
+
// through getColorRoles like success/warning/primary/secondary/tertiary are): they're a fixed,
|
|
65
79
|
// Google-verified Material palette that react-native-paper's own components (TextInput's error
|
|
66
80
|
// state, HelperText, Badge) read directly, so re-deriving them would change form-validation
|
|
67
81
|
// colors across every consuming app and risk diverging from a contrast guarantee that's already
|
|
@@ -90,7 +104,7 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string, har
|
|
|
90
104
|
}
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
const palette = getTriadicPalette(
|
|
107
|
+
const palette: TriadicPalette = secondaryInput !== null && tertiaryInput !== null ? { primary: normalizeExplicitColor(primaryInput), secondary: normalizeExplicitColor(secondaryInput), tertiary: normalizeExplicitColor(tertiaryInput) } : getTriadicPalette(primaryInput, harmony)
|
|
94
108
|
|
|
95
109
|
const primaryRoles = getColorRoles(palette.primary, surface)
|
|
96
110
|
_theme.colors.primary = primaryRoles.color
|
|
@@ -110,13 +124,13 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string, har
|
|
|
110
124
|
_theme.colors.tertiaryContainer = tertiaryRoles.container
|
|
111
125
|
_theme.colors.onTertiaryContainer = tertiaryRoles.onContainer
|
|
112
126
|
|
|
113
|
-
const blended = getBlendedColor(
|
|
127
|
+
const blended = getBlendedColor(palette.primary, palette.secondary, 0.5)
|
|
114
128
|
const tintSurface = (alpha: number) => getBlendedColor(blended, surface, alpha)
|
|
115
129
|
|
|
116
130
|
_theme.colors.background = base.dark ? '#0F0F0F' : '#F0F0F0'
|
|
117
131
|
_theme.colors.surface = surface
|
|
118
132
|
_theme.colors.surfaceVariant = tintSurface(0.2)
|
|
119
|
-
_theme.colors.outline = getBlendedColor(
|
|
133
|
+
_theme.colors.outline = getBlendedColor(palette.primary, base.colors.outline, 0.45)
|
|
120
134
|
|
|
121
135
|
const grey = base.dark ? '#424242' : '#c2c2c2'
|
|
122
136
|
const blendedGrey = getBlendedColor(blended, grey, 0.05)
|
|
@@ -130,7 +144,7 @@ export function useComputedTheme(appearance: ThemeAppearance, color: string, har
|
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
setTheme(_theme)
|
|
133
|
-
}, [appearance,
|
|
147
|
+
}, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony])
|
|
134
148
|
|
|
135
149
|
useEffect(() => {
|
|
136
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
CHANGED
|
@@ -3,7 +3,7 @@ import { useTheme as usePaperTheme } from 'react-native-paper'
|
|
|
3
3
|
import type { AutoPaperTheme } from './useComputedTheme'
|
|
4
4
|
|
|
5
5
|
// react-native-paper's own useTheme is generic (`useTheme<T = MD3Theme>()`) specifically so
|
|
6
|
-
// consumers can extend MD3Theme with their own fields
|
|
6
|
+
// consumers can extend MD3Theme with their own fields: this is that extension point, so any
|
|
7
7
|
// component can read theme.colors.success/warning (and their onX/Container variants) with full
|
|
8
8
|
// type safety, without redeclaring the type parameter at every call site. Named distinctly from
|
|
9
9
|
// react-native-paper's own `useTheme` (not re-exported under the same name) to avoid the two being
|
|
@@ -13,7 +13,7 @@ export type ColorRoles = {
|
|
|
13
13
|
// useComputedTheme already uses for primary/secondary/tertiary: onColor is tuned to sit on the
|
|
14
14
|
// vivid color itself, container is base blended toward the theme's real surface (so it inherits
|
|
15
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
|
|
16
|
+
// against the *computed* container rather than a light/dark assumption, so it stays readable
|
|
17
17
|
// whatever surface color it's handed. onContainer also reads safely directly on `surface` itself
|
|
18
18
|
// (not just on `container`), since container is only lightly blended toward it.
|
|
19
19
|
export const getColorRoles = (base: string, surface: string, containerAlpha = 0.15): ColorRoles => {
|
|
@@ -5,7 +5,7 @@ const RING_WIDTH = 1
|
|
|
5
5
|
// The selected swatch trades that outline for a thicker one in a contrasting colour.
|
|
6
6
|
const SELECTED_RING_WIDTH = 3
|
|
7
7
|
|
|
8
|
-
// Colour for content sitting directly on a fill of `color`
|
|
8
|
+
// Colour for content sitting directly on a fill of `color` (a check mark or a 20px icon). Plain
|
|
9
9
|
// white/black rather than a tinted tone from getTonalColor, because this has to stay legible on
|
|
10
10
|
// fully saturated fills and on pure black/white alike.
|
|
11
11
|
export const getContrastColor = (color: string): string => (isDarkColor(color) ? '#ffffff' : '#000000')
|
|
@@ -14,7 +14,7 @@ export const getContrastColor = (color: string): string => (isDarkColor(color) ?
|
|
|
14
14
|
// things:
|
|
15
15
|
//
|
|
16
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
|
|
17
|
+
// dialog and a black swatch on a dark one are otherwise edgeless: invisible, not just subtle.
|
|
18
18
|
// The theme's outline colour tracks the surface, so it works in both appearances.
|
|
19
19
|
// - Selected: read as a marker against the *swatch's own fill*. A hardcoded white ring vanished on
|
|
20
20
|
// the white swatch, so picking white gave no feedback that anything had been picked.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { getHex } from './getHex'
|
|
2
|
+
import { getRgb } from './getRgb'
|
|
3
|
+
|
|
4
|
+
type HSL = { h: number; s: number; l: number }
|
|
5
|
+
|
|
6
|
+
function rgbToHsl(r: number, g: number, b: number): HSL {
|
|
7
|
+
r /= 255
|
|
8
|
+
g /= 255
|
|
9
|
+
b /= 255
|
|
10
|
+
|
|
11
|
+
const max = Math.max(r, g, b)
|
|
12
|
+
const min = Math.min(r, g, b)
|
|
13
|
+
const delta = max - min
|
|
14
|
+
|
|
15
|
+
let h = 0
|
|
16
|
+
if (delta !== 0) {
|
|
17
|
+
if (max === r) h = ((g - b) / delta) % 6
|
|
18
|
+
else if (max === g) h = (b - r) / delta + 2
|
|
19
|
+
else h = (r - g) / delta + 4
|
|
20
|
+
h = (h * 60 + 360) % 360
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const l = (max + min) / 2
|
|
24
|
+
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1))
|
|
25
|
+
|
|
26
|
+
return { h, s, l }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hslToHex(h: number, s: number, l: number): string {
|
|
30
|
+
const c = (1 - Math.abs(2 * l - 1)) * s
|
|
31
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
|
32
|
+
const m = l - c / 2
|
|
33
|
+
|
|
34
|
+
let r = 0
|
|
35
|
+
let g = 0
|
|
36
|
+
let b = 0
|
|
37
|
+
|
|
38
|
+
if (h < 60) {
|
|
39
|
+
r = c
|
|
40
|
+
g = x
|
|
41
|
+
b = 0
|
|
42
|
+
} else if (h < 120) {
|
|
43
|
+
r = x
|
|
44
|
+
g = c
|
|
45
|
+
b = 0
|
|
46
|
+
} else if (h < 180) {
|
|
47
|
+
r = 0
|
|
48
|
+
g = c
|
|
49
|
+
b = x
|
|
50
|
+
} else if (h < 240) {
|
|
51
|
+
r = 0
|
|
52
|
+
g = x
|
|
53
|
+
b = c
|
|
54
|
+
} else if (h < 300) {
|
|
55
|
+
r = x
|
|
56
|
+
g = 0
|
|
57
|
+
b = c
|
|
58
|
+
} else {
|
|
59
|
+
r = c
|
|
60
|
+
g = 0
|
|
61
|
+
b = x
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const toInt = (value: number) => Math.round((value + m) * 255)
|
|
65
|
+
return getHex(`rgb(${toInt(r)}, ${toInt(g)}, ${toInt(b)})`) ?? '#000000'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const toRad = (deg: number) => (deg * Math.PI) / 180
|
|
69
|
+
const toDeg = (rad: number) => (rad * 180) / Math.PI
|
|
70
|
+
|
|
71
|
+
// The hue maximally distant from both inputs: the midpoint of the longer arc between them.
|
|
72
|
+
// Falls back to a perpendicular hue when the inputs are exact complements, since both arcs are
|
|
73
|
+
// equal length there, so every point on the perpendicular bisector is equally valid.
|
|
74
|
+
function thirdHue(hA: number, hB: number): number {
|
|
75
|
+
const x = Math.cos(toRad(hA)) + Math.cos(toRad(hB))
|
|
76
|
+
const y = Math.sin(toRad(hA)) + Math.sin(toRad(hB))
|
|
77
|
+
|
|
78
|
+
if (Math.abs(x) < 1e-9 && Math.abs(y) < 1e-9) return (hA + 90) % 360
|
|
79
|
+
|
|
80
|
+
const meanHue = (toDeg(Math.atan2(y, x)) + 360) % 360
|
|
81
|
+
return (meanHue + 180) % 360
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Given two arbitrary colors, derives a third that's maximally distinct in hue from both, useful
|
|
85
|
+
// for a neutral element that shouldn't visually clash with either. Saturation and lightness are
|
|
86
|
+
// averaged from the two inputs.
|
|
87
|
+
export function getThirdColor(colorA: string, colorB: string): string {
|
|
88
|
+
const rgbA = getRgb(colorA)
|
|
89
|
+
const rgbB = getRgb(colorB)
|
|
90
|
+
if (!rgbA) throw new Error(`Invalid color format: ${colorA}`)
|
|
91
|
+
if (!rgbB) throw new Error(`Invalid color format: ${colorB}`)
|
|
92
|
+
|
|
93
|
+
const hslA = rgbToHsl(rgbA.r, rgbA.g, rgbA.b)
|
|
94
|
+
const hslB = rgbToHsl(rgbB.r, rgbB.g, rgbB.b)
|
|
95
|
+
|
|
96
|
+
const h = thirdHue(hslA.h, hslB.h)
|
|
97
|
+
const s = (hslA.s + hslB.s) / 2
|
|
98
|
+
const l = (hslA.l + hslB.l) / 2
|
|
99
|
+
|
|
100
|
+
return hslToHex(h, s, l)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default getThirdColor
|