@opndev/react-native-events 0.0.16 → 0.0.17
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/Changes +6 -0
- package/lib/components/hero-screen-fixed.jsx +27 -23
- package/lib/components/panel-gradient.jsx +150 -0
- package/lib/components/panel.jsx +21 -4
- package/lib/components.js +1 -0
- package/lib/hooks/use-panel-contrast.js +20 -0
- package/lib/index.js +1 -1
- package/lib/utils/colors.js +94 -11
- package/lib/widgets/data-list-widget.jsx +18 -5
- package/package.json +1 -1
package/Changes
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
Revision history for @opndev/opndev-react-native-events
|
|
2
2
|
|
|
3
|
+
0.0.17 2026-06-28 04:29:54Z
|
|
4
|
+
|
|
5
|
+
* Add gradient panel and proper implementation of contrast color, not just
|
|
6
|
+
"pick white or black"
|
|
7
|
+
* Fix HeroScreenFixed to be actually fixed to the content
|
|
8
|
+
|
|
3
9
|
0.0.16 2026-06-27 23:25:11Z
|
|
4
10
|
|
|
5
11
|
* Add widgets for dynamic and static content:
|
|
@@ -15,11 +15,8 @@ const defaultStyle = StyleSheet.create({
|
|
|
15
15
|
container: {
|
|
16
16
|
flex: 1,
|
|
17
17
|
},
|
|
18
|
-
|
|
18
|
+
header: {
|
|
19
19
|
width: '100%',
|
|
20
|
-
position: 'absolute',
|
|
21
|
-
top: 0,
|
|
22
|
-
left: 0,
|
|
23
20
|
},
|
|
24
21
|
imageInner: {
|
|
25
22
|
position: 'absolute',
|
|
@@ -31,12 +28,24 @@ const defaultStyle = StyleSheet.create({
|
|
|
31
28
|
/**
|
|
32
29
|
* HeroScreenFixed
|
|
33
30
|
*
|
|
34
|
-
* Header image
|
|
35
|
-
* scrolls
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
31
|
+
* Header image is a normal, in-flow element at the top of the
|
|
32
|
+
* scroll — it scrolls away together with the content below it, at
|
|
33
|
+
* the same rate, with no pinning and no animation. Same structural
|
|
34
|
+
* shape as HeroScreenParallax, just without the scroll-driven
|
|
35
|
+
* scale/translateY transform — "parallax minus the animation."
|
|
36
|
+
*
|
|
37
|
+
* "Fixed" means fixed to the content below it, not fixed to the
|
|
38
|
+
* screen — the previous version of this file pinned the header via
|
|
39
|
+
* position:absolute, which was actually the same idea as
|
|
40
|
+
* HeroScreenOverlay, not what "Fixed" was meant to mean. Rebuilt
|
|
41
|
+
* from scratch; this file had no usages anywhere yet.
|
|
42
|
+
*
|
|
43
|
+
* ContentComponent's role matches HeroScreenParallax's: it wraps
|
|
44
|
+
* the body content for layout purposes (defaults to View, NOT a
|
|
45
|
+
* scroll container) — the actual scrolling is owned by the outer
|
|
46
|
+
* ScrollView, hardcoded, not swappable. If you were expecting
|
|
47
|
+
* ContentComponent to default to ScrollView (the old behavior),
|
|
48
|
+
* that's the one real API change here.
|
|
40
49
|
*
|
|
41
50
|
* @param {object} props
|
|
42
51
|
* @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
|
|
@@ -48,7 +57,7 @@ const defaultStyle = StyleSheet.create({
|
|
|
48
57
|
* @param {string} [props.backgroundColor]
|
|
49
58
|
* @param {string} [props.headerBackgroundColor]
|
|
50
59
|
* @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
|
|
51
|
-
* @param {React.ComponentType} [props.ContentComponent]
|
|
60
|
+
* @param {React.ComponentType} [props.ContentComponent] Defaults to View — wraps body content, does NOT own scrolling.
|
|
52
61
|
* @param {object} [props.containerStyle]
|
|
53
62
|
* @param {object} [props.headerStyle]
|
|
54
63
|
* @param {object} [props.contentStyle]
|
|
@@ -65,7 +74,7 @@ export default function HeroScreenFixed({
|
|
|
65
74
|
backgroundColor,
|
|
66
75
|
headerBackgroundColor,
|
|
67
76
|
headerHeight = 220,
|
|
68
|
-
ContentComponent =
|
|
77
|
+
ContentComponent = View,
|
|
69
78
|
containerStyle,
|
|
70
79
|
headerStyle,
|
|
71
80
|
contentStyle,
|
|
@@ -74,7 +83,7 @@ export default function HeroScreenFixed({
|
|
|
74
83
|
const { slideElements, bodyChildren } = splitCarouselChildren(children);
|
|
75
84
|
|
|
76
85
|
return (
|
|
77
|
-
<
|
|
86
|
+
<ScrollView
|
|
78
87
|
style={[
|
|
79
88
|
defaultStyle.container,
|
|
80
89
|
backgroundColor ? { backgroundColor } : null,
|
|
@@ -83,7 +92,7 @@ export default function HeroScreenFixed({
|
|
|
83
92
|
>
|
|
84
93
|
<View
|
|
85
94
|
style={[
|
|
86
|
-
defaultStyle.
|
|
95
|
+
defaultStyle.header,
|
|
87
96
|
{ height: totalHeaderHeight },
|
|
88
97
|
headerBackgroundColor ? { backgroundColor: headerBackgroundColor } : null,
|
|
89
98
|
headerStyle,
|
|
@@ -105,15 +114,10 @@ export default function HeroScreenFixed({
|
|
|
105
114
|
</View>
|
|
106
115
|
</View>
|
|
107
116
|
|
|
108
|
-
<ContentComponent
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
>
|
|
112
|
-
<View style={[heroContentStyle, contentStyle]}>
|
|
113
|
-
<HeaderOverlay headerOverlay={headerOverlay} />
|
|
114
|
-
{bodyChildren}
|
|
115
|
-
</View>
|
|
117
|
+
<ContentComponent style={[heroContentStyle, contentStyle]}>
|
|
118
|
+
<HeaderOverlay headerOverlay={headerOverlay} />
|
|
119
|
+
{bodyChildren}
|
|
116
120
|
</ContentComponent>
|
|
117
|
-
</
|
|
121
|
+
</ScrollView>
|
|
118
122
|
);
|
|
119
123
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
4
|
+
import { contrastColor } from '../utils/colors';
|
|
5
|
+
import { PanelContrastContext } from '../hooks/use-panel-contrast';
|
|
6
|
+
|
|
7
|
+
// Six named directions — end is always the geometric opposite of
|
|
8
|
+
// start, so only one end of each axis needs naming. Want the
|
|
9
|
+
// reverse (e.g. bottom-left to top-right)? Swap the order of
|
|
10
|
+
// `colors` instead of asking for a 7th/8th direction keyword.
|
|
11
|
+
const DIRECTIONS = {
|
|
12
|
+
top: { start: { x: 0, y: 0 }, end: { x: 0, y: 1 } },
|
|
13
|
+
bottom: { start: { x: 0, y: 1 }, end: { x: 0, y: 0 } },
|
|
14
|
+
left: { start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
15
|
+
right: { start: { x: 1, y: 0 }, end: { x: 0, y: 0 } },
|
|
16
|
+
'top-left': { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } },
|
|
17
|
+
'top-right': { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Local for now, not added to lib/utils/colors.js since I haven't
|
|
21
|
+
// seen that file's full current content — move it there later if
|
|
22
|
+
// you want it shared. Only handles 6-digit hex (#RRGGBB); named
|
|
23
|
+
// colors ('white') or rgba() strings aren't parsed.
|
|
24
|
+
function hexToRgb(hex) {
|
|
25
|
+
const clean = hex.replace('#', '');
|
|
26
|
+
const value = parseInt(clean, 16);
|
|
27
|
+
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rgbToHex({ r, g, b }) {
|
|
31
|
+
return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function averageColor(colors) {
|
|
35
|
+
const rgbs = colors.map(hexToRgb);
|
|
36
|
+
const sum = rgbs.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
|
|
37
|
+
const n = rgbs.length;
|
|
38
|
+
return rgbToHex({ r: sum.r / n, g: sum.g / n, b: sum.b / n });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultStyle = StyleSheet.create({
|
|
42
|
+
shadowWrap: {
|
|
43
|
+
borderRadius: 16,
|
|
44
|
+
},
|
|
45
|
+
shadowOn: {
|
|
46
|
+
elevation: 4,
|
|
47
|
+
shadowColor: '#000',
|
|
48
|
+
shadowOffset: { width: 0, height: 2 },
|
|
49
|
+
shadowOpacity: 0.15,
|
|
50
|
+
shadowRadius: 6,
|
|
51
|
+
},
|
|
52
|
+
surface: {
|
|
53
|
+
borderRadius: 16,
|
|
54
|
+
overflow: 'hidden',
|
|
55
|
+
},
|
|
56
|
+
content: {
|
|
57
|
+
padding: 16,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* GradientPanel
|
|
63
|
+
*
|
|
64
|
+
* Same shape and behavior as Panel — sizes to its content, no
|
|
65
|
+
* forced layout on children, same shadow-split structure (an outer
|
|
66
|
+
* shadow-casting view, an inner clipping view, for the same reason
|
|
67
|
+
* Panel needs it: a shadow renders outside a view's own bounds, so
|
|
68
|
+
* it can't live on the same view that clips with overflow:hidden).
|
|
69
|
+
*
|
|
70
|
+
* The only difference: a gradient background instead of a flat
|
|
71
|
+
* `backgroundColor`. Stays theme-agnostic like Panel — pass raw
|
|
72
|
+
* resolved colors (e.g. ['#FFFFFF', theme.primary]), this component
|
|
73
|
+
* doesn't reach into your theme itself.
|
|
74
|
+
*
|
|
75
|
+
* Automatically derives a contrast text color from the AVERAGE of
|
|
76
|
+
* its `colors` stops (via contrastColor — same hue/saturation,
|
|
77
|
+
* contrasting lightness, not flat black/white), made available to
|
|
78
|
+
* anything rendered inside via PanelContrastContext — same
|
|
79
|
+
* mechanism Panel uses. This is a heuristic, not exact — a fade
|
|
80
|
+
* from white to a dark color averages to something mid-tone, which
|
|
81
|
+
* may not actually read well at either literal end of the gradient.
|
|
82
|
+
* Override with `contrastTextColor` if the heuristic guesses wrong.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} props
|
|
85
|
+
* @param {React.ReactNode} props.children
|
|
86
|
+
* @param {string[]} [props.colors] Gradient stops, e.g. ['#FFFFFF', theme.primary]. Defaults to ['#FFFFFF', '#FFFFFF'] (solid white) if omitted.
|
|
87
|
+
* @param {'top'|'bottom'|'left'|'right'|'top-left'|'top-right'} [props.direction] Defaults to 'top'. Takes precedence over start/end if both are given.
|
|
88
|
+
* @param {{x: number, y: number}} [props.start] Raw escape hatch, ignored if `direction` is set.
|
|
89
|
+
* @param {{x: number, y: number}} [props.end] Raw escape hatch, ignored if `direction` is set.
|
|
90
|
+
* @param {number[]} [props.locations] Where each color stop lands (0-1). Optional — omit for an even spread.
|
|
91
|
+
* @param {string} [props.contrastTextColor] Overrides the automatically-derived (average-based) contrast color.
|
|
92
|
+
* @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
|
|
93
|
+
* @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
|
|
94
|
+
* @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
|
|
95
|
+
* @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
|
|
96
|
+
*
|
|
97
|
+
* @returns {JSX.Element}
|
|
98
|
+
*/
|
|
99
|
+
export default function GradientPanel({
|
|
100
|
+
children,
|
|
101
|
+
colors = ['#FFFFFF', '#FFFFFF'],
|
|
102
|
+
direction = 'top',
|
|
103
|
+
start,
|
|
104
|
+
end,
|
|
105
|
+
locations,
|
|
106
|
+
contrastTextColor,
|
|
107
|
+
shadow = false,
|
|
108
|
+
onPress,
|
|
109
|
+
style,
|
|
110
|
+
contentStyle,
|
|
111
|
+
}) {
|
|
112
|
+
const resolvedDirection = DIRECTIONS[direction] ?? {
|
|
113
|
+
start: start ?? DIRECTIONS.top.start,
|
|
114
|
+
end: end ?? DIRECTIONS.top.end,
|
|
115
|
+
};
|
|
116
|
+
const resolvedContrast = contrastTextColor ?? contrastColor(averageColor(colors));
|
|
117
|
+
|
|
118
|
+
const content = (
|
|
119
|
+
<LinearGradient
|
|
120
|
+
colors={colors}
|
|
121
|
+
start={resolvedDirection.start}
|
|
122
|
+
end={resolvedDirection.end}
|
|
123
|
+
locations={locations}
|
|
124
|
+
style={defaultStyle.surface}
|
|
125
|
+
>
|
|
126
|
+
<PanelContrastContext.Provider value={resolvedContrast}>
|
|
127
|
+
<View style={[defaultStyle.content, contentStyle]}>
|
|
128
|
+
{children}
|
|
129
|
+
</View>
|
|
130
|
+
</PanelContrastContext.Provider>
|
|
131
|
+
</LinearGradient>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const wrapperStyle = [
|
|
135
|
+
defaultStyle.shadowWrap,
|
|
136
|
+
shadow ? defaultStyle.shadowOn : null,
|
|
137
|
+
shadow ? { backgroundColor: colors[0] } : null,
|
|
138
|
+
style,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
if (onPress) {
|
|
142
|
+
return (
|
|
143
|
+
<Pressable onPress={onPress} style={wrapperStyle}>
|
|
144
|
+
{content}
|
|
145
|
+
</Pressable>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return <View style={wrapperStyle}>{content}</View>;
|
|
150
|
+
}
|
package/lib/components/panel.jsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
|
-
|
|
5
4
|
import React from 'react';
|
|
6
5
|
import { View, Pressable, StyleSheet } from 'react-native';
|
|
6
|
+
import { contrastColor } from '../utils/colors';
|
|
7
|
+
import { PanelContrastContext } from '../hooks/use-panel-contrast';
|
|
7
8
|
|
|
8
9
|
const defaultStyle = StyleSheet.create({
|
|
9
10
|
shadowWrap: {
|
|
@@ -43,9 +44,19 @@ const defaultStyle = StyleSheet.create({
|
|
|
43
44
|
* split means `shadow` can be toggled on/off with no other changes
|
|
44
45
|
* needed, and panels that don't use it are unaffected.
|
|
45
46
|
*
|
|
47
|
+
* Automatically derives a contrast text color from `backgroundColor`
|
|
48
|
+
* (via contrastColor) and makes it available to anything rendered
|
|
49
|
+
* inside, via PanelContrastContext — so e.g. DataListWidget picks up
|
|
50
|
+
* a correct default text color for whatever Panel it's sitting in,
|
|
51
|
+
* with zero per-usage color wiring. Pass `contrastTextColor`
|
|
52
|
+
* yourself to override the automatic guess; anything inside can
|
|
53
|
+
* still override further for individual pieces of text (e.g.
|
|
54
|
+
* DataListWidget's own itemTextStyle/titleStyle still win over this).
|
|
55
|
+
*
|
|
46
56
|
* @param {object} props
|
|
47
57
|
* @param {React.ReactNode} props.children
|
|
48
58
|
* @param {string} [props.backgroundColor]
|
|
59
|
+
* @param {string} [props.contrastTextColor] Overrides the automatically-derived contrast color.
|
|
49
60
|
* @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
|
|
50
61
|
* @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
|
|
51
62
|
* @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
|
|
@@ -56,20 +67,26 @@ const defaultStyle = StyleSheet.create({
|
|
|
56
67
|
export default function Panel({
|
|
57
68
|
children,
|
|
58
69
|
backgroundColor,
|
|
70
|
+
contrastTextColor,
|
|
59
71
|
shadow = false,
|
|
60
72
|
onPress,
|
|
61
73
|
style,
|
|
62
74
|
contentStyle,
|
|
63
75
|
}) {
|
|
76
|
+
const resolvedContrast = contrastTextColor
|
|
77
|
+
?? (backgroundColor ? contrastColor(backgroundColor) : null);
|
|
78
|
+
|
|
64
79
|
const surfaceStyle = [
|
|
65
80
|
defaultStyle.surface,
|
|
66
81
|
backgroundColor ? { backgroundColor } : null,
|
|
67
82
|
];
|
|
68
83
|
|
|
69
84
|
const content = (
|
|
70
|
-
<
|
|
71
|
-
{
|
|
72
|
-
|
|
85
|
+
<PanelContrastContext.Provider value={resolvedContrast}>
|
|
86
|
+
<View style={[defaultStyle.content, contentStyle]}>
|
|
87
|
+
{children}
|
|
88
|
+
</View>
|
|
89
|
+
</PanelContrastContext.Provider>
|
|
73
90
|
);
|
|
74
91
|
|
|
75
92
|
const inner = onPress ? (
|
package/lib/components.js
CHANGED
|
@@ -12,6 +12,7 @@ export { default as GradientTile } from './components/gradient-tile.jsx';
|
|
|
12
12
|
export { default as QRCodeForm } from './components/qr-code-form.jsx';
|
|
13
13
|
export { default as ScaledLogo } from './components/scaled-logo.jsx';
|
|
14
14
|
export { default as Panel } from './components/panel.jsx';
|
|
15
|
+
export { default as GradientPanel } from './components/panel-gradient.jsx';
|
|
15
16
|
|
|
16
17
|
export { openUrl, openApp, openExternal } from './utils/launch.js';
|
|
17
18
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PanelContrastContext
|
|
5
|
+
*
|
|
6
|
+
* Provided by Panel — the contrast color automatically derived from
|
|
7
|
+
* its backgroundColor (or a manual override, if Panel was given
|
|
8
|
+
* one). Defaults to null, so anything reading this outside a Panel
|
|
9
|
+
* just gets "no opinion," not a wrong color.
|
|
10
|
+
*/
|
|
11
|
+
export const PanelContrastContext = createContext(null);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* usePanelContrast
|
|
15
|
+
*
|
|
16
|
+
* @returns {string|null} The enclosing Panel's contrast color, or null if there isn't one.
|
|
17
|
+
*/
|
|
18
|
+
export function usePanelContrast() {
|
|
19
|
+
return useContext(PanelContrastContext);
|
|
20
|
+
}
|
package/lib/index.js
CHANGED
package/lib/utils/colors.js
CHANGED
|
@@ -18,19 +18,15 @@
|
|
|
18
18
|
export function mixHexColors(a, b, amount = 0.5) {
|
|
19
19
|
const ah = a.replace('#', '');
|
|
20
20
|
const bh = b.replace('#', '');
|
|
21
|
-
|
|
22
21
|
const ar = parseInt(ah.slice(0, 2), 16);
|
|
23
22
|
const ag = parseInt(ah.slice(2, 4), 16);
|
|
24
23
|
const ab = parseInt(ah.slice(4, 6), 16);
|
|
25
|
-
|
|
26
24
|
const br = parseInt(bh.slice(0, 2), 16);
|
|
27
25
|
const bg = parseInt(bh.slice(2, 4), 16);
|
|
28
26
|
const bb = parseInt(bh.slice(4, 6), 16);
|
|
29
|
-
|
|
30
27
|
const r = Math.round(ar + (br - ar) * amount);
|
|
31
28
|
const g = Math.round(ag + (bg - ag) * amount);
|
|
32
29
|
const b2 = Math.round(ab + (bb - ab) * amount);
|
|
33
|
-
|
|
34
30
|
return (
|
|
35
31
|
'#' +
|
|
36
32
|
r.toString(16).padStart(2, '0') +
|
|
@@ -40,27 +36,116 @@ export function mixHexColors(a, b, amount = 0.5) {
|
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
/**
|
|
43
|
-
*
|
|
39
|
+
* Convert a hex color to HSL.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} hex
|
|
42
|
+
* Color as "#rrggbb".
|
|
43
|
+
*
|
|
44
|
+
* @returns {{h: number, s: number, l: number}}
|
|
45
|
+
* Each in the 0-1 range (h is fraction of 360°, not degrees).
|
|
46
|
+
*/
|
|
47
|
+
function hexToHsl(hex) {
|
|
48
|
+
const value = hex.replace('#', '');
|
|
49
|
+
const r = parseInt(value.slice(0, 2), 16) / 255;
|
|
50
|
+
const g = parseInt(value.slice(2, 4), 16) / 255;
|
|
51
|
+
const b = parseInt(value.slice(4, 6), 16) / 255;
|
|
52
|
+
|
|
53
|
+
const max = Math.max(r, g, b);
|
|
54
|
+
const min = Math.min(r, g, b);
|
|
55
|
+
const l = (max + min) / 2;
|
|
56
|
+
|
|
57
|
+
let h = 0;
|
|
58
|
+
let s = 0;
|
|
59
|
+
|
|
60
|
+
if (max !== min) {
|
|
61
|
+
const d = max - min;
|
|
62
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
63
|
+
|
|
64
|
+
switch (max) {
|
|
65
|
+
case r:
|
|
66
|
+
h = (g - b) / d + (g < b ? 6 : 0);
|
|
67
|
+
break;
|
|
68
|
+
case g:
|
|
69
|
+
h = (b - r) / d + 2;
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
h = (r - g) / d + 4;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
h /= 6;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { h, s, l };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert HSL back to a hex color.
|
|
83
|
+
*
|
|
84
|
+
* @param {{h: number, s: number, l: number}} hsl
|
|
85
|
+
* Each in the 0-1 range (h is fraction of 360°, not degrees).
|
|
86
|
+
*
|
|
87
|
+
* @returns {string}
|
|
88
|
+
* Color as "#rrggbb".
|
|
89
|
+
*/
|
|
90
|
+
function hslToHex({ h, s, l }) {
|
|
91
|
+
function hueToRgb(p, q, t) {
|
|
92
|
+
if (t < 0) t += 1;
|
|
93
|
+
if (t > 1) t -= 1;
|
|
94
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
95
|
+
if (t < 1 / 2) return q;
|
|
96
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let r;
|
|
101
|
+
let g;
|
|
102
|
+
let b;
|
|
103
|
+
|
|
104
|
+
if (s === 0) {
|
|
105
|
+
r = g = b = l;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
109
|
+
const p = 2 * l - q;
|
|
110
|
+
r = hueToRgb(p, q, h + 1 / 3);
|
|
111
|
+
g = hueToRgb(p, q, h);
|
|
112
|
+
b = hueToRgb(p, q, h - 1 / 3);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const toHex = (c) => Math.round(c * 255).toString(16).padStart(2, '0');
|
|
116
|
+
return '#' + toHex(r) + toHex(g) + toHex(b);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Return a contrasting color in the SAME hue/saturation family as
|
|
121
|
+
* the input, just pushed to a very different lightness — the same
|
|
122
|
+
* technique CSS color-ramp design systems use (Tailwind/Radix-style
|
|
123
|
+
* 50-950 scales): same color family, different shade, rather than
|
|
124
|
+
* jumping to flat black/white. A light, desaturated input still
|
|
125
|
+
* gets a colored (just very dark) result; a fully neutral input
|
|
126
|
+
* (pure white/black/gray, s=0) naturally falls back to plain
|
|
127
|
+
* gray at the target lightness, since there's no hue to preserve.
|
|
44
128
|
*
|
|
45
129
|
* @param {string} hex
|
|
46
130
|
* Background color as "#rrggbb".
|
|
47
131
|
*
|
|
48
132
|
* @returns {string}
|
|
49
|
-
*
|
|
133
|
+
* A same-hue, contrasting-lightness hex color.
|
|
50
134
|
*/
|
|
51
135
|
export function contrastColor(hex) {
|
|
52
136
|
const value = hex.replace('#', '');
|
|
53
|
-
|
|
54
137
|
const r = parseInt(value.slice(0, 2), 16) / 255;
|
|
55
138
|
const g = parseInt(value.slice(2, 4), 16) / 255;
|
|
56
139
|
const b = parseInt(value.slice(4, 6), 16) / 255;
|
|
57
|
-
|
|
58
140
|
const luminance =
|
|
59
141
|
0.2126 * toLinear(r) +
|
|
60
142
|
0.7152 * toLinear(g) +
|
|
61
143
|
0.0722 * toLinear(b);
|
|
62
144
|
|
|
63
|
-
|
|
145
|
+
const { h, s } = hexToHsl(hex);
|
|
146
|
+
const targetLightness = luminance > 0.179 ? 0.15 : 0.92;
|
|
147
|
+
|
|
148
|
+
return hslToHex({ h, s, l: targetLightness });
|
|
64
149
|
}
|
|
65
150
|
|
|
66
151
|
/**
|
|
@@ -76,7 +161,5 @@ export function toLinear(channel) {
|
|
|
76
161
|
if (channel <= 0.03928) {
|
|
77
162
|
return channel / 12.92;
|
|
78
163
|
}
|
|
79
|
-
|
|
80
164
|
return ((channel + 0.055) / 1.055) ** 2.4;
|
|
81
165
|
}
|
|
82
|
-
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
View,
|
|
11
11
|
} from 'react-native';
|
|
12
12
|
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
|
|
13
|
+
import { usePanelContrast } from '../hooks/use-panel-contrast';
|
|
13
14
|
|
|
14
15
|
const defaultStyle = StyleSheet.create({
|
|
15
16
|
container: {
|
|
@@ -111,6 +112,19 @@ export default function DataListWidget({
|
|
|
111
112
|
errorTextStyle,
|
|
112
113
|
}) {
|
|
113
114
|
const showTitleRow = title || showRefreshButton;
|
|
115
|
+
const panelContrast = usePanelContrast();
|
|
116
|
+
const contrastTextStyle = panelContrast ? { color: panelContrast } : null;
|
|
117
|
+
|
|
118
|
+
if (__DEV__ && !loading && !error && items.length) {
|
|
119
|
+
items.forEach((item, index) => {
|
|
120
|
+
if (item == null || item.id == null || item.title == null) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`DataListWidget: item at index ${index} is missing "id" or "title". ` +
|
|
123
|
+
`Check that mapItem returns { id, title, description? } — got: ${JSON.stringify(item)}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
114
128
|
|
|
115
129
|
return (
|
|
116
130
|
<View
|
|
@@ -127,7 +141,7 @@ export default function DataListWidget({
|
|
|
127
141
|
]}
|
|
128
142
|
>
|
|
129
143
|
{title ? (
|
|
130
|
-
<TextComponent style={[defaultStyle.title, titleStyle]}>
|
|
144
|
+
<TextComponent style={[defaultStyle.title, contrastTextStyle, titleStyle]}>
|
|
131
145
|
{title}
|
|
132
146
|
</TextComponent>
|
|
133
147
|
) : null}
|
|
@@ -169,14 +183,14 @@ export default function DataListWidget({
|
|
|
169
183
|
onPress={() => onPressItem?.(item)}
|
|
170
184
|
>
|
|
171
185
|
{showBullet ? (
|
|
172
|
-
<TextComponent style={[defaultStyle.bullet, itemTextStyle]}>{'\u2022'}</TextComponent>
|
|
186
|
+
<TextComponent style={[defaultStyle.bullet, contrastTextStyle, itemTextStyle]}>{'\u2022'}</TextComponent>
|
|
173
187
|
) : null}
|
|
174
188
|
<View style={defaultStyle.itemBody}>
|
|
175
|
-
<TextComponent style={itemTextStyle}>
|
|
189
|
+
<TextComponent style={[contrastTextStyle, itemTextStyle]}>
|
|
176
190
|
{item.title}
|
|
177
191
|
</TextComponent>
|
|
178
192
|
{item.description ? (
|
|
179
|
-
<TextComponent style={descriptionStyle}>
|
|
193
|
+
<TextComponent style={[contrastTextStyle, descriptionStyle]}>
|
|
180
194
|
{item.description}
|
|
181
195
|
</TextComponent>
|
|
182
196
|
) : null}
|
|
@@ -186,4 +200,3 @@ export default function DataListWidget({
|
|
|
186
200
|
</View>
|
|
187
201
|
);
|
|
188
202
|
}
|
|
189
|
-
|
package/package.json
CHANGED