@opndev/react-native-events 0.0.13 → 0.0.15

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.15 2026-06-26 22:56:26Z
4
+
5
+ * Add debugRouteState() to debug export to debug route states more easily
6
+ * Throw shade at panels: I mean shadows, we not dissing panels here.
7
+
8
+ 0.0.14 2026-06-26 04:53:23Z
9
+
10
+ * Add Panel instead of a Tile, Tiles are more buttony thing. Panels are
11
+ panels. ha.
12
+
3
13
  0.0.13 2026-06-26 03:59:55Z
4
14
 
5
15
  * Update news item segment to be included as a widget.
@@ -84,10 +84,10 @@ export default function AppScreenTopBar({
84
84
  return (
85
85
  <View
86
86
  style={[
87
+ style,
87
88
  styles.bar,
88
89
  { height, justifyContent, paddingHorizontal: horizontalPadding, paddingBottom: verticalPadding },
89
90
  backgroundColor ? { backgroundColor } : null,
90
- style,
91
91
  ]}
92
92
  >
93
93
  {applyBarStyling(logo, { height, maxWidthPixels })}
@@ -1,7 +1,6 @@
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 { View, StyleSheet } from 'react-native';
6
5
  import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset';
7
6
  import { AppScreenOffsetContext } from '../hooks/use-app-screen-offset';
@@ -17,6 +16,7 @@ const defaultStyle = StyleSheet.create({
17
16
  left: 0,
18
17
  width: '100%',
19
18
  zIndex: 10,
19
+ elevation: 10,
20
20
  },
21
21
  });
22
22
 
@@ -67,6 +67,7 @@ export default function AppScreen({
67
67
  style={[
68
68
  defaultStyle.topBarWrap,
69
69
  { height: offset, paddingTop: insetTop },
70
+ topBar.props?.backgroundColor ? { backgroundColor: topBar.props.backgroundColor } : null,
70
71
  ]}
71
72
  >
72
73
  {topBar}
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
- renderHeaderImage,
8
7
  HeaderOverlay,
8
+ HeaderContent,
9
+ splitCarouselChildren,
9
10
  useHeroHeaderHeight,
10
11
  heroContentStyle,
11
12
  } from './hero-screen-header';
@@ -38,9 +39,12 @@ const defaultStyle = StyleSheet.create({
38
39
  * gap: 16 — so spacing matches the parallax variant exactly.
39
40
  *
40
41
  * @param {object} props
41
- * @param {React.ReactNode} props.children
42
- * @param {object} [props.headerImage]
42
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
43
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
43
44
  * @param {React.ReactNode} [props.headerOverlay]
45
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
46
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
47
+ * @param {boolean} [props.loop] Defaults to true.
44
48
  * @param {string} [props.backgroundColor]
45
49
  * @param {string} [props.headerBackgroundColor]
46
50
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -55,6 +59,9 @@ export default function HeroScreenFixed({
55
59
  children,
56
60
  headerImage,
57
61
  headerOverlay,
62
+ onSlidePress,
63
+ autoPlayInterval,
64
+ loop,
58
65
  backgroundColor,
59
66
  headerBackgroundColor,
60
67
  headerHeight = 220,
@@ -63,7 +70,8 @@ export default function HeroScreenFixed({
63
70
  headerStyle,
64
71
  contentStyle,
65
72
  }) {
66
- const { insetTop, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
73
+ const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
74
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
67
75
 
68
76
  return (
69
77
  <View
@@ -84,10 +92,16 @@ export default function HeroScreenFixed({
84
92
  <View
85
93
  style={[
86
94
  defaultStyle.imageInner,
87
- { top: insetTop, height: headerHeight },
95
+ { top: topOffset, height: headerHeight },
88
96
  ]}
89
97
  >
90
- {renderHeaderImage(headerImage)}
98
+ <HeaderContent
99
+ headerImage={headerImage}
100
+ slideElements={slideElements}
101
+ onSlidePress={onSlidePress}
102
+ autoPlayInterval={autoPlayInterval}
103
+ loop={loop}
104
+ />
91
105
  </View>
92
106
  </View>
93
107
 
@@ -97,7 +111,7 @@ export default function HeroScreenFixed({
97
111
  >
98
112
  <View style={[heroContentStyle, contentStyle]}>
99
113
  <HeaderOverlay headerOverlay={headerOverlay} />
100
- {children}
114
+ {bodyChildren}
101
115
  </View>
102
116
  </ContentComponent>
103
117
  </View>
@@ -1,9 +1,7 @@
1
- // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
- //
3
- // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
-
1
+ import { useState, isValidElement, Children } from 'react';
5
2
  import { View, Image, Pressable, StyleSheet } from 'react-native';
6
3
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
4
+ import Carousel from 'react-native-reanimated-carousel';
7
5
  import { useAppScreenOffset } from '../hooks/use-app-screen-offset';
8
6
 
9
7
  const styles = StyleSheet.create({
@@ -25,6 +23,27 @@ const styles = StyleSheet.create({
25
23
  shadowOpacity: 0.3,
26
24
  shadowRadius: 3,
27
25
  },
26
+ carouselFill: StyleSheet.absoluteFillObject,
27
+ slideFill: { flex: 1 },
28
+ dotsRow: {
29
+ position: 'absolute',
30
+ bottom: 12,
31
+ left: 0,
32
+ right: 0,
33
+ flexDirection: 'row',
34
+ justifyContent: 'center',
35
+ gap: 6,
36
+ },
37
+ dot: {
38
+ width: 6,
39
+ height: 6,
40
+ borderRadius: 3,
41
+ backgroundColor: 'rgba(255,255,255,0.5)',
42
+ },
43
+ dotActive: {
44
+ backgroundColor: '#FFFFFF',
45
+ width: 16,
46
+ },
28
47
  });
29
48
 
30
49
  /**
@@ -66,6 +85,140 @@ export function HeaderOverlay({ headerOverlay }) {
66
85
  return <View style={styles.titleWrap}>{headerOverlay}</View>;
67
86
  }
68
87
 
88
+ /**
89
+ * CarouselScreen
90
+ *
91
+ * A marker component, not a real standalone screen — used as a
92
+ * child of any Hero variant to declare one carousel slide:
93
+ *
94
+ * <HeroScreenFixed onSlidePress={(slide) => router.push(slide.route)}>
95
+ * <CarouselScreen item={fooData} route="/foo">
96
+ * ...whatever JSX you want for this slide...
97
+ * </CarouselScreen>
98
+ * <CarouselScreen item={barData} route="/bar">
99
+ * ...
100
+ * </CarouselScreen>
101
+ * </HeroScreenFixed>
102
+ *
103
+ * Every Hero variant scans its children for these by type (via
104
+ * splitCarouselChildren below) and pulls out `item`/`route` plus
105
+ * your own nested children for each slide. Any non-CarouselScreen
106
+ * children are treated as normal body content, same as always.
107
+ *
108
+ * If rendered standalone it just renders its own children, so it's
109
+ * harmless either way.
110
+ *
111
+ * @param {object} props
112
+ * @param {any} [props.item]
113
+ * @param {any} [props.route]
114
+ * @param {React.ReactNode} props.children
115
+ */
116
+ export function CarouselScreen({ children }) {
117
+ return children ?? null;
118
+ }
119
+
120
+ /**
121
+ * splitCarouselChildren
122
+ *
123
+ * Separates a Hero variant's children into carousel slides
124
+ * (CarouselScreen elements) and normal body content (everything
125
+ * else). Shared by every Hero variant so the splitting logic is
126
+ * identical across all of them.
127
+ *
128
+ * @param {React.ReactNode} children
129
+ * @returns {{ slideElements: React.ReactElement[], bodyChildren: React.ReactNode[] }}
130
+ */
131
+ export function splitCarouselChildren(children) {
132
+ const childArray = Children.toArray(children);
133
+
134
+ const slideElements = childArray.filter(
135
+ (child) => isValidElement(child) && child.type === CarouselScreen
136
+ );
137
+ const bodyChildren = childArray.filter(
138
+ (child) => !(isValidElement(child) && child.type === CarouselScreen)
139
+ );
140
+
141
+ return { slideElements, bodyChildren };
142
+ }
143
+
144
+ /**
145
+ * HeaderContent
146
+ *
147
+ * The single thing every Hero variant renders inside its header
148
+ * box — decides whether that's a static image (renderHeaderImage,
149
+ * unchanged default behaviour) or a swipeable/auto-advancing
150
+ * carousel (when CarouselScreen children were found), so a Hero
151
+ * variant only ever needs to swap one line to gain carousel
152
+ * support, with zero changes to its own scroll/positioning logic.
153
+ *
154
+ * Fills whatever box it's placed in — each variant owns its own
155
+ * positioning (Fixed/Overlay via their own imageInner wrapper,
156
+ * Parallax via ParallaxScrollView's existing wrapper) — this
157
+ * component doesn't position itself.
158
+ *
159
+ * @param {object} props
160
+ * @param {object} [props.headerImage] Used when there are no slides.
161
+ * @param {React.ReactElement[]} [props.slideElements] From splitCarouselChildren. When empty/absent, renders the static image instead.
162
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
163
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
164
+ * @param {boolean} [props.loop] Defaults to true.
165
+ *
166
+ * @returns {JSX.Element|null}
167
+ */
168
+ export function HeaderContent({
169
+ headerImage,
170
+ slideElements,
171
+ onSlidePress,
172
+ autoPlayInterval = 4000,
173
+ loop = true,
174
+ }) {
175
+ const [activeIndex, setActiveIndex] = useState(0);
176
+
177
+ if (!slideElements || slideElements.length === 0) {
178
+ return renderHeaderImage(headerImage);
179
+ }
180
+
181
+ return (
182
+ <View style={styles.carouselFill}>
183
+ <Carousel
184
+ style={{ width: '100%', height: '100%' }}
185
+ data={slideElements}
186
+ loop={loop}
187
+ autoPlay={autoPlayInterval > 0}
188
+ autoPlayInterval={autoPlayInterval}
189
+ onSnapToItem={setActiveIndex}
190
+ renderItem={({ item: slideElement, index }) => {
191
+ const { item, route } = slideElement.props;
192
+ const onPress = onSlidePress
193
+ ? () => onSlidePress({ item, route }, index)
194
+ : undefined;
195
+
196
+ if (!onPress) {
197
+ return <View style={styles.slideFill}>{slideElement}</View>;
198
+ }
199
+
200
+ return (
201
+ <Pressable style={styles.slideFill} onPress={onPress}>
202
+ {slideElement}
203
+ </Pressable>
204
+ );
205
+ }}
206
+ />
207
+
208
+ {slideElements.length > 1 ? (
209
+ <View style={styles.dotsRow}>
210
+ {slideElements.map((_, i) => (
211
+ <View
212
+ key={i}
213
+ style={[styles.dot, i === activeIndex ? styles.dotActive : null]}
214
+ />
215
+ ))}
216
+ </View>
217
+ ) : null}
218
+ </View>
219
+ );
220
+ }
221
+
69
222
  /**
70
223
  * useTopOffset
71
224
  *
@@ -2,49 +2,78 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- import { View, StyleSheet } from 'react-native';
5
+ import {
6
+ HeaderOverlay,
7
+ HeaderContent,
8
+ splitCarouselChildren,
9
+ } from './hero-screen-header';
6
10
  import ParallaxScrollView from './parallax-scroll-view';
7
- import { renderHeaderImage, HeaderOverlay } from './hero-screen-header';
8
11
 
9
- const defaultStyle = StyleSheet.create({
10
- headerImageWrap: {
11
- width: '100%',
12
- height: 220,
13
- bottom: 0,
14
- left: 0,
15
- position: 'absolute',
16
- },
17
- });
18
-
19
- export default function HeroScreen({
12
+ /**
13
+ * HeroScreenParallax
14
+ *
15
+ * Header image scales/translates with scroll (the classic parallax
16
+ * effect) via ParallaxScrollView. Carousel support needed no new
17
+ * positioning logic here at all — ParallaxScrollView already treats
18
+ * its header as an opaque element it animates, so swapping in
19
+ * HeaderContent (image-or-carousel) instead of a plain image just
20
+ * works, same one-line swap as Fixed/Overlay.
21
+ *
22
+ * @param {object} props
23
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
24
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
25
+ * @param {React.ReactNode} [props.headerOverlay]
26
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
27
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
28
+ * @param {boolean} [props.loop] Defaults to true.
29
+ * @param {string} [props.backgroundColor]
30
+ * @param {string} [props.headerBackgroundColor]
31
+ * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
32
+ * @param {React.ComponentType} [props.ContentComponent]
33
+ * @param {object} [props.containerStyle]
34
+ * @param {object} [props.headerStyle]
35
+ * @param {object} [props.contentStyle]
36
+ *
37
+ * @returns {JSX.Element}
38
+ */
39
+ export default function HeroScreenParallax({
20
40
  children,
21
41
  headerImage,
22
42
  headerOverlay,
43
+ onSlidePress,
44
+ autoPlayInterval,
45
+ loop,
23
46
  backgroundColor,
24
47
  headerBackgroundColor,
25
48
  headerHeight,
26
- ContentComponent,
27
49
  containerStyle,
28
50
  headerStyle,
29
51
  contentStyle,
52
+ ContentComponent,
30
53
  }) {
54
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
55
+
31
56
  return (
32
57
  <ParallaxScrollView
58
+ headerImage={
59
+ <HeaderContent
60
+ headerImage={headerImage}
61
+ slideElements={slideElements}
62
+ onSlidePress={onSlidePress}
63
+ autoPlayInterval={autoPlayInterval}
64
+ loop={loop}
65
+ />
66
+ }
33
67
  backgroundColor={backgroundColor}
34
68
  headerBackgroundColor={headerBackgroundColor}
35
69
  headerHeight={headerHeight}
36
- ContentComponent={ContentComponent}
37
70
  containerStyle={containerStyle}
38
71
  headerStyle={headerStyle}
39
72
  contentStyle={contentStyle}
40
- headerImage={
41
- <View style={defaultStyle.headerImageWrap}>
42
- {renderHeaderImage(headerImage)}
43
- </View>
44
- }
73
+ ContentComponent={ContentComponent}
45
74
  >
46
75
  <HeaderOverlay headerOverlay={headerOverlay} />
47
- {children}
76
+ {bodyChildren}
48
77
  </ParallaxScrollView>
49
78
  );
50
79
  }
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
- renderHeaderImage,
8
7
  HeaderOverlay,
8
+ HeaderContent,
9
+ splitCarouselChildren,
9
10
  BackButton,
10
11
  useHeroHeaderHeight,
11
12
  heroContentStyle,
@@ -44,10 +45,13 @@ const defaultStyle = StyleSheet.create({
44
45
  * own padded content area.
45
46
  *
46
47
  * @param {object} props
47
- * @param {React.ReactNode} props.children
48
- * @param {object} [props.headerImage]
48
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
49
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
49
50
  * @param {React.ReactNode} [props.headerOverlay]
50
- * @param {Function} [props.onBack] Renders a back button over the header image when provided. Omit for no back button.
51
+ * @param {Function} [props.onBack] Renders a back button over the header when provided. Omit for no back button.
52
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
53
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
54
+ * @param {boolean} [props.loop] Defaults to true.
51
55
  * @param {string} [props.backgroundColor]
52
56
  * @param {string} [props.headerBackgroundColor]
53
57
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -63,6 +67,9 @@ export default function HeroScreenOverlay({
63
67
  headerImage,
64
68
  headerOverlay,
65
69
  onBack,
70
+ onSlidePress,
71
+ autoPlayInterval,
72
+ loop,
66
73
  backgroundColor,
67
74
  headerBackgroundColor,
68
75
  headerHeight = 220,
@@ -72,6 +79,7 @@ export default function HeroScreenOverlay({
72
79
  contentStyle,
73
80
  }) {
74
81
  const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
82
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
75
83
 
76
84
  return (
77
85
  <View
@@ -89,7 +97,7 @@ export default function HeroScreenOverlay({
89
97
 
90
98
  <View style={[heroContentStyle, contentStyle]}>
91
99
  <HeaderOverlay headerOverlay={headerOverlay} />
92
- {children}
100
+ {bodyChildren}
93
101
  </View>
94
102
  </ContentComponent>
95
103
 
@@ -107,7 +115,13 @@ export default function HeroScreenOverlay({
107
115
  { top: topOffset, height: headerHeight },
108
116
  ]}
109
117
  >
110
- {renderHeaderImage(headerImage)}
118
+ <HeaderContent
119
+ headerImage={headerImage}
120
+ slideElements={slideElements}
121
+ onSlidePress={onSlidePress}
122
+ autoPlayInterval={autoPlayInterval}
123
+ loop={loop}
124
+ />
111
125
  </View>
112
126
 
113
127
  <BackButton
@@ -0,0 +1,99 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import React from 'react';
6
+ import { View, Pressable, StyleSheet } from 'react-native';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ shadowWrap: {
10
+ borderRadius: 16,
11
+ },
12
+ shadowOn: {
13
+ elevation: 4,
14
+ shadowColor: '#000',
15
+ shadowOffset: { width: 0, height: 2 },
16
+ shadowOpacity: 0.15,
17
+ shadowRadius: 6,
18
+ },
19
+ surface: {
20
+ borderRadius: 16,
21
+ overflow: 'hidden',
22
+ },
23
+ content: {
24
+ padding: 16,
25
+ },
26
+ });
27
+
28
+ /**
29
+ * Panel
30
+ *
31
+ * Simple rounded-corner surface that sizes to its content. Unlike
32
+ * Tile (built for a centered icon+label grid cell with flex: 1),
33
+ * Panel imposes no layout assumptions on its children at all — no
34
+ * forced centering, no forced flex sizing — so content-driven
35
+ * widgets (lists, text blocks, NewsWidget, etc.) render exactly as
36
+ * they would un-wrapped, just inside a rounded/backgrounded card.
37
+ *
38
+ * Internally split into two layers — an outer view that casts the
39
+ * shadow (when enabled) and an inner view that clips content to the
40
+ * rounded corners. This is deliberate: a shadow renders outside a
41
+ * view's own bounds, so a single view with both `overflow: hidden`
42
+ * and a shadow would clip its own shadow into invisibility. The
43
+ * split means `shadow` can be toggled on/off with no other changes
44
+ * needed, and panels that don't use it are unaffected.
45
+ *
46
+ * @param {object} props
47
+ * @param {React.ReactNode} props.children
48
+ * @param {string} [props.backgroundColor]
49
+ * @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
50
+ * @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
51
+ * @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
52
+ * @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
53
+ *
54
+ * @returns {JSX.Element}
55
+ */
56
+ export default function Panel({
57
+ children,
58
+ backgroundColor,
59
+ shadow = false,
60
+ onPress,
61
+ style,
62
+ contentStyle,
63
+ }) {
64
+ const surfaceStyle = [
65
+ defaultStyle.surface,
66
+ backgroundColor ? { backgroundColor } : null,
67
+ ];
68
+
69
+ const content = (
70
+ <View style={[defaultStyle.content, contentStyle]}>
71
+ {children}
72
+ </View>
73
+ );
74
+
75
+ const inner = onPress ? (
76
+ <Pressable onPress={onPress} style={surfaceStyle}>
77
+ {content}
78
+ </Pressable>
79
+ ) : (
80
+ <View style={surfaceStyle}>{content}</View>
81
+ );
82
+
83
+ return (
84
+ <View
85
+ style={[
86
+ defaultStyle.shadowWrap,
87
+ shadow ? defaultStyle.shadowOn : null,
88
+ // Android's elevation shadow needs an opaque shape with a
89
+ // matching borderRadius to cast a correctly-rounded shadow —
90
+ // a fully transparent outer view can shadow as a square box
91
+ // instead of following the rounded silhouette.
92
+ shadow && backgroundColor ? { backgroundColor } : null,
93
+ style,
94
+ ]}
95
+ >
96
+ {inner}
97
+ </View>
98
+ );
99
+ }
@@ -11,7 +11,7 @@ import Animated, {
11
11
  } from 'react-native-reanimated';
12
12
  import { useTopOffset, heroContentStyle } from './hero-screen-header';
13
13
 
14
- const DEFAULT_HEADER_HEIGHT = 250;
14
+ const DEFAULT_HEADER_HEIGHT = 220;
15
15
 
16
16
  const defaultStyle = StyleSheet.create({
17
17
  container: {
@@ -50,8 +50,11 @@ const defaultStyle = StyleSheet.create({
50
50
  * Background color for the scroll view container.
51
51
  * @param {string} [props.headerBackgroundColor]
52
52
  * Background color shown behind the header image.
53
- * @param {number} [props.headerHeight=250]
53
+ * @param {number} [props.headerHeight=220]
54
54
  * Visible height of the parallax header image (excludes the top offset).
55
+ * Matches HeroScreenFixed/HeroScreenOverlay's default — kept in
56
+ * sync deliberately so all three variants render identically when
57
+ * no explicit headerHeight is passed.
55
58
  * @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
56
59
  * [props.ContentComponent=View]
57
60
  * Wrapper used for the content area below the header.
package/lib/components.js CHANGED
@@ -11,6 +11,7 @@ export { default as Tile } from './components/tile.jsx';
11
11
  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
+ export { default as Panel } from './components/panel.jsx';
14
15
 
15
16
  export { openUrl, openApp, openExternal } from './utils/launch.js';
16
17
 
package/lib/debug.js ADDED
@@ -0,0 +1,5 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ export { debugRouteState } from './utils/route-debug.js'
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- const VERSION = "0.0.13";
5
+ const VERSION = "0.0.15";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -0,0 +1,21 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { useNavigationState } from '@react-navigation/native';
6
+ import { useRouter } from 'expo-router';
7
+
8
+ export function debugRouteState() {
9
+ const router = useRouter();
10
+ const navState = useNavigationState((state) => state);
11
+
12
+ console.log('[news stack]',
13
+ JSON.stringify(navState?.routes?.map((r) => (
14
+ { name: r.name, params: r.params, key: r.key }
15
+ )),
16
+ null,
17
+ 2
18
+ )
19
+ );
20
+
21
+ }
@@ -1,3 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import React, {
2
6
  forwardRef,
3
7
  useEffect,
@@ -22,6 +26,12 @@ const defaultStyle = StyleSheet.create({
22
26
  title: {
23
27
  fontWeight: '700',
24
28
  },
29
+ divider: {
30
+ height: 1,
31
+ backgroundColor: 'rgba(0,0,0,0.15)',
32
+ marginTop: 4,
33
+ marginBottom: 4,
34
+ },
25
35
  item: {
26
36
  flexDirection: 'row',
27
37
  gap: 8,
@@ -59,6 +69,8 @@ const defaultStyle = StyleSheet.create({
59
69
  * @param {string} [props.bearerToken]
60
70
  * @param {number} [props.limit] Max items shown. Defaults to 4.
61
71
  * @param {string} [props.title] Defaults to 'News'.
72
+ * @param {boolean} [props.showDivider] Thin line under the title, like 'News' / '-----'. Defaults to true. Ignored if title is falsy.
73
+ * @param {object} [props.dividerStyle]
62
74
  * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
63
75
  * @param {Function} [props.onPressItem]
64
76
  * @param {number} [props.refreshIntervalMs] Defaults to 15 minutes.
@@ -79,6 +91,8 @@ const NewsWidget = forwardRef(function NewsWidget(
79
91
  bearerToken,
80
92
  limit = 4,
81
93
  title = 'News',
94
+ showDivider = true,
95
+ dividerStyle,
82
96
  TextComponent = Text,
83
97
  onPressItem,
84
98
  refreshIntervalMs = 15 * 60 * 1000,
@@ -142,6 +156,10 @@ const NewsWidget = forwardRef(function NewsWidget(
142
156
  </TextComponent>
143
157
  ) : null}
144
158
 
159
+ {title && showDivider ? (
160
+ <View style={[defaultStyle.divider, dividerStyle]} />
161
+ ) : null}
162
+
145
163
  {loading ? <ActivityIndicator /> : null}
146
164
 
147
165
  {!loading && error ? (
@@ -169,3 +187,4 @@ const NewsWidget = forwardRef(function NewsWidget(
169
187
  });
170
188
 
171
189
  export default NewsWidget;
190
+
package/package.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "exports": {
12
12
  ".": "./lib/index.js",
13
13
  "./components": "./lib/components.js",
14
+ "./debug": "./lib/debug.js",
14
15
  "./globals": "./lib/screen-registry.js",
15
16
  "./hero-screen-registry": "./lib/hero-screen-registry.js",
16
17
  "./lite": "./lib/hero-screen-registry.js",
@@ -34,5 +35,5 @@
34
35
  },
35
36
  "sideEffects": false,
36
37
  "type": "module",
37
- "version": "0.0.13"
38
+ "version": "0.0.15"
38
39
  }