@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.
@@ -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 { type ColorHarmony, getTriadicPalette } from './utils/getTriadicPalette'
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 — error is a
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 — see getColorRoles.test.ts's "onColor" caveat: the onColor heuristic (isDarkColor-
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 — react-native-paper's own components (TextInput's error state,
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) — verified the same way as warning's
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 — this is the extension point instead, meant to be used
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) — they're a fixed,
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(color, harmony)
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(color, palette.secondary, 0.5)
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(color, base.colors.outline, 0.45)
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, color, harmony])
147
+ }, [appearance, primaryInput, secondaryInput, tertiaryInput, harmony])
134
148
 
135
149
  useEffect(() => {
136
150
  computeTheme()
@@ -2,7 +2,7 @@ import { useState } from 'react'
2
2
  import type { LayoutChangeEvent } from 'react-native'
3
3
 
4
4
  // Fixed column count so the swatch grid always fills complete rows, regardless of dialog width
5
- // (phone vs tablet vs web) — sizing the swatches to fit, rather than wrapping fixed-size swatches
5
+ // (phone vs tablet vs web): sizing the swatches to fit, rather than wrapping fixed-size swatches
6
6
  // and hoping the total divides evenly into whatever column count the width happens to produce.
7
7
  export const SWATCH_GRID_COLUMNS = 5
8
8
  export const SWATCH_GRID_GAP = 12
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 — this is that extension point, so any
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 — so it stays readable
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` — a check mark or a 20px icon. Plain
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 — invisible, not just subtle.
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