@proulr/sdui-components 0.1.69 → 0.1.71

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.
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!--
3
+ Chevron up  weighty open angle with soft echo.
4
+ -->
5
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
6
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" fill="none" stroke-width="1.6" d="M6.4 14.2L12 8.8l5.6 5.4" opacity="0.32" />
7
+ <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" fill="none" stroke-width="2.2" d="M5.4 14.8L12 8.2l6.6 6.6" />
8
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.69",
3
+ "version": "0.1.71",
4
4
  "type": "module",
5
5
  "main": "./src/renderer/index.ts",
6
6
  "types": "./src/renderer/index.ts",
@@ -47,7 +47,9 @@
47
47
  "expo-linear-gradient": ">=56",
48
48
  "react": ">=19",
49
49
  "react-native": ">=0.85",
50
+ "react-native-gesture-handler": ">=2.31",
50
51
  "react-native-google-mobile-ads": ">=16",
52
+ "react-native-reanimated": ">=4",
51
53
  "react-native-safe-area-context": ">=5",
52
54
  "react-native-svg": ">=15"
53
55
  },
@@ -64,9 +66,15 @@
64
66
  "expo-blur": {
65
67
  "optional": true
66
68
  },
69
+ "react-native-gesture-handler": {
70
+ "optional": true
71
+ },
67
72
  "react-native-google-mobile-ads": {
68
73
  "optional": true
69
74
  },
75
+ "react-native-reanimated": {
76
+ "optional": true
77
+ },
70
78
  "react-native-safe-area-context": {
71
79
  "optional": true
72
80
  },
@@ -87,7 +95,9 @@
87
95
  "lint-staged": "^16.4.0",
88
96
  "react": "19.2.3",
89
97
  "react-native": "0.85.3",
98
+ "react-native-gesture-handler": "~2.31.1",
90
99
  "react-native-google-mobile-ads": "^16.3.4",
100
+ "react-native-reanimated": "4.3.1",
91
101
  "react-native-safe-area-context": "~5.7.0",
92
102
  "react-native-svg": "^15.15.5",
93
103
  "typescript": "~6.0.3",
@@ -10,6 +10,7 @@ export const APP_ICON_NAMES = [
10
10
  'chat',
11
11
  'close',
12
12
  'chevron-down',
13
+ 'chevron-up',
13
14
  'chevron-right',
14
15
  'flip-camera',
15
16
  'flash',
@@ -41,7 +41,7 @@ describe('block-types', () => {
41
41
 
42
42
  describe('app-icon-names', () => {
43
43
  it('validates icon tokens', () => {
44
- expect(APP_ICON_NAMES).toHaveLength(79);
44
+ expect(APP_ICON_NAMES).toHaveLength(80);
45
45
  expect(isAppIconName('search')).toBe(true);
46
46
  expect(isAppIconName('heart-filled')).toBe(true);
47
47
  expect(isAppIconName('tab-discover')).toBe(true);
@@ -282,11 +282,11 @@ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
282
282
  const active = readBooleanProp(block, 'active');
283
283
  const variant = readStringProp(block, 'variant') ?? 'default';
284
284
  const isHeader = variant === 'on_header';
285
- const displayLabel = glyph ? `${glyph} ${label}` : label;
286
285
 
287
286
  return (
288
287
  <Chip
289
- label={displayLabel}
288
+ label={label}
289
+ icon={glyph}
290
290
  active={active}
291
291
  disabled={!actionId}
292
292
  variant={isHeader ? 'onHeader' : 'default'}
@@ -1,5 +1,6 @@
1
1
  import { Pressable, StyleSheet, Text } from 'react-native';
2
2
  import { colors, fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
3
+ import { AppIcon, isAppIconName } from './app-icon';
3
4
 
4
5
  export function Chip({
5
6
  label,
@@ -7,12 +8,14 @@ export function Chip({
7
8
  onPress,
8
9
  variant = 'default',
9
10
  disabled,
11
+ icon,
10
12
  }: {
11
13
  label: string;
12
14
  active?: boolean;
13
15
  onPress?: () => void;
14
16
  variant?: 'default' | 'onHeader';
15
17
  disabled?: boolean;
18
+ icon?: string;
16
19
  }) {
17
20
  const isHeader = variant === 'onHeader';
18
21
 
@@ -32,12 +35,15 @@ export function Chip({
32
35
  ? colors.textOnAccent
33
36
  : m3Colors.onSurfaceVariant;
34
37
 
38
+ const iconName = icon && isAppIconName(icon) ? icon : null;
39
+
35
40
  return (
36
41
  <Pressable
37
42
  onPress={onPress}
38
43
  disabled={disabled}
39
44
  style={[styles.chip, isHeader && styles.chipOnHeader, containerStyle]}
40
45
  >
46
+ {iconName ? <AppIcon name={iconName} size={isHeader ? 14 : 15} color={textColor} /> : null}
41
47
  <Text
42
48
  style={{
43
49
  color: textColor,
@@ -53,6 +59,9 @@ export function Chip({
53
59
 
54
60
  const styles = StyleSheet.create({
55
61
  chip: {
62
+ flexDirection: 'row',
63
+ alignItems: 'center',
64
+ gap: spacing.xs,
56
65
  paddingHorizontal: spacing.sm,
57
66
  paddingVertical: spacing.sm,
58
67
  borderRadius: m3Shape.cornerSmall,
@@ -0,0 +1,200 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { LayoutChangeEvent, StyleSheet, Text, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated, {
5
+ runOnJS,
6
+ useAnimatedStyle,
7
+ useSharedValue,
8
+ } from 'react-native-reanimated';
9
+ import { m3Colors, m3Shape, m3Type, spacing } from '../theme/proulr-theme';
10
+
11
+ export type RangeSliderProps = {
12
+ min: number;
13
+ max: number;
14
+ step?: number;
15
+ value: [number, number];
16
+ onChange: (value: [number, number]) => void;
17
+ formatLabel?: (n: number) => string;
18
+ };
19
+
20
+ const THUMB_SIZE = 28;
21
+ const TRACK_HEIGHT = 4;
22
+
23
+ function clamp(value: number, lo: number, hi: number): number {
24
+ 'worklet';
25
+ return Math.min(hi, Math.max(lo, value));
26
+ }
27
+
28
+ /**
29
+ * Generic double-thumb range slider (reanimated + gesture-handler).
30
+ * Fully theme-driven — no product logic. Emits stepped `[low, high]` values.
31
+ */
32
+ export function RangeSlider({
33
+ min,
34
+ max,
35
+ step = 1,
36
+ value,
37
+ onChange,
38
+ formatLabel,
39
+ }: RangeSliderProps) {
40
+ const [trackWidth, setTrackWidth] = useState(0);
41
+ const [labels, setLabels] = useState<[number, number]>(value);
42
+
43
+ const usable = Math.max(0, trackWidth - THUMB_SIZE);
44
+ const span = max - min || 1;
45
+
46
+ const lowX = useSharedValue(0);
47
+ const highX = useSharedValue(0);
48
+ const lowStart = useSharedValue(0);
49
+ const highStart = useSharedValue(0);
50
+
51
+ const valueToX = useCallback(
52
+ (v: number) => ((clampJs(v, min, max) - min) / span) * usable,
53
+ [min, max, span, usable],
54
+ );
55
+
56
+ // Sync thumbs when external value or track width changes.
57
+ useEffect(() => {
58
+ lowX.value = valueToX(value[0]);
59
+ highX.value = valueToX(value[1]);
60
+ setLabels(value);
61
+ }, [value, valueToX, lowX, highX]);
62
+
63
+ const emit = useCallback(
64
+ (low: number, high: number) => {
65
+ setLabels([low, high]);
66
+ onChange([low, high]);
67
+ },
68
+ [onChange],
69
+ );
70
+
71
+ const updateLabels = useCallback((low: number, high: number) => {
72
+ setLabels([low, high]);
73
+ }, []);
74
+
75
+ const lowGesture = Gesture.Pan()
76
+ .onBegin(() => {
77
+ lowStart.value = lowX.value;
78
+ })
79
+ .onUpdate((event) => {
80
+ const next = clamp(lowStart.value + event.translationX, 0, highX.value);
81
+ lowX.value = next;
82
+ runOnJS(updateLabels)(jsXToValue(next, min, max, span, step, usable), jsXToValue(highX.value, min, max, span, step, usable));
83
+ })
84
+ .onEnd(() => {
85
+ runOnJS(emit)(
86
+ jsXToValue(lowX.value, min, max, span, step, usable),
87
+ jsXToValue(highX.value, min, max, span, step, usable),
88
+ );
89
+ });
90
+
91
+ const highGesture = Gesture.Pan()
92
+ .onBegin(() => {
93
+ highStart.value = highX.value;
94
+ })
95
+ .onUpdate((event) => {
96
+ const next = clamp(highStart.value + event.translationX, lowX.value, usable);
97
+ highX.value = next;
98
+ runOnJS(updateLabels)(jsXToValue(lowX.value, min, max, span, step, usable), jsXToValue(next, min, max, span, step, usable));
99
+ })
100
+ .onEnd(() => {
101
+ runOnJS(emit)(
102
+ jsXToValue(lowX.value, min, max, span, step, usable),
103
+ jsXToValue(highX.value, min, max, span, step, usable),
104
+ );
105
+ });
106
+
107
+ const lowThumbStyle = useAnimatedStyle(() => ({
108
+ transform: [{ translateX: lowX.value }],
109
+ }));
110
+ const highThumbStyle = useAnimatedStyle(() => ({
111
+ transform: [{ translateX: highX.value }],
112
+ }));
113
+ const activeTrackStyle = useAnimatedStyle(() => ({
114
+ left: lowX.value + THUMB_SIZE / 2,
115
+ width: Math.max(0, highX.value - lowX.value),
116
+ }));
117
+
118
+ const onTrackLayout = useCallback((event: LayoutChangeEvent) => {
119
+ setTrackWidth(event.nativeEvent.layout.width);
120
+ }, []);
121
+
122
+ const format = formatLabel ?? ((n: number) => String(n));
123
+
124
+ return (
125
+ <View style={styles.container}>
126
+ <View style={styles.labelsRow}>
127
+ <Text style={styles.label}>{format(labels[0])}</Text>
128
+ <Text style={styles.label}>{format(labels[1])}</Text>
129
+ </View>
130
+ <View style={styles.trackArea} onLayout={onTrackLayout}>
131
+ <View style={styles.trackBase} />
132
+ <Animated.View style={[styles.trackActive, activeTrackStyle]} />
133
+ <GestureDetector gesture={lowGesture}>
134
+ <Animated.View style={[styles.thumb, lowThumbStyle]} hitSlop={12} />
135
+ </GestureDetector>
136
+ <GestureDetector gesture={highGesture}>
137
+ <Animated.View style={[styles.thumb, highThumbStyle]} hitSlop={12} />
138
+ </GestureDetector>
139
+ </View>
140
+ </View>
141
+ );
142
+ }
143
+
144
+ function clampJs(value: number, lo: number, hi: number): number {
145
+ return Math.min(hi, Math.max(lo, value));
146
+ }
147
+
148
+ function jsXToValue(
149
+ x: number,
150
+ min: number,
151
+ max: number,
152
+ span: number,
153
+ step: number,
154
+ usable: number,
155
+ ): number {
156
+ const raw = min + (x / (usable || 1)) * span;
157
+ const stepped = Math.round(raw / step) * step;
158
+ return clampJs(stepped, min, max);
159
+ }
160
+
161
+ const styles = StyleSheet.create({
162
+ container: {
163
+ gap: spacing.sm,
164
+ paddingVertical: spacing.xs,
165
+ },
166
+ labelsRow: {
167
+ flexDirection: 'row',
168
+ justifyContent: 'space-between',
169
+ },
170
+ label: {
171
+ color: m3Colors.onSurface,
172
+ fontFamily: m3Type.label.fontFamily,
173
+ fontSize: m3Type.label.fontSize,
174
+ },
175
+ trackArea: {
176
+ height: THUMB_SIZE,
177
+ justifyContent: 'center',
178
+ },
179
+ trackBase: {
180
+ height: TRACK_HEIGHT,
181
+ borderRadius: m3Shape.cornerFull,
182
+ backgroundColor: m3Colors.surfaceContainerHighest,
183
+ },
184
+ trackActive: {
185
+ position: 'absolute',
186
+ height: TRACK_HEIGHT,
187
+ borderRadius: m3Shape.cornerFull,
188
+ backgroundColor: m3Colors.primary,
189
+ },
190
+ thumb: {
191
+ position: 'absolute',
192
+ left: 0,
193
+ width: THUMB_SIZE,
194
+ height: THUMB_SIZE,
195
+ borderRadius: m3Shape.cornerFull,
196
+ backgroundColor: m3Colors.primary,
197
+ borderWidth: 3,
198
+ borderColor: m3Colors.onPrimary,
199
+ },
200
+ });
@@ -9,8 +9,8 @@ const ICONS_DIR = path.join(ROOT, 'assets', 'icons');
9
9
  const GENERATED_DIR = path.join(ROOT, 'src', 'renderer', 'ui', 'generated');
10
10
 
11
11
  describe('AppIcon SVG ↔ catalog 1:1', () => {
12
- it('has exactly 79 AppIconName tokens', () => {
13
- expect(APP_ICON_NAMES).toHaveLength(79);
12
+ it('has exactly 80 AppIconName tokens', () => {
13
+ expect(APP_ICON_NAMES).toHaveLength(80);
14
14
  });
15
15
 
16
16
  it('has one SVG file per AppIconName and no orphan SVGs', () => {
@@ -0,0 +1,16 @@
1
+ /* Generated by scripts/generate-app-icons.mjs — do not edit. */
2
+ import Svg, { Path } from 'react-native-svg';
3
+ import { m3Colors } from '../../theme/proulr-theme';
4
+ import type { AppIconGlyphProps } from '../app-icon-glyph-props';
5
+
6
+ export function ChevronUpIcon({
7
+ color = m3Colors.primary,
8
+ size = 24,
9
+ }: AppIconGlyphProps) {
10
+ return (
11
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
12
+ <Path stroke={color} strokeLinecap="round" strokeLinejoin="round" fill="none" strokeWidth={1.6} d="M6.4 14.2L12 8.8l5.6 5.4" opacity={0.32} />
13
+ <Path stroke={color} strokeLinecap="round" strokeLinejoin="round" fill="none" strokeWidth={2.2} d="M5.4 14.8L12 8.2l6.6 6.6" />
14
+ </Svg>
15
+ );
16
+ }
@@ -12,6 +12,7 @@ import { SendIcon } from './send';
12
12
  import { ChatIcon } from './chat';
13
13
  import { CloseIcon } from './close';
14
14
  import { ChevronDownIcon } from './chevron-down';
15
+ import { ChevronUpIcon } from './chevron-up';
15
16
  import { ChevronRightIcon } from './chevron-right';
16
17
  import { FlipCameraIcon } from './flip-camera';
17
18
  import { FlashIcon } from './flash';
@@ -96,6 +97,7 @@ export const GENERATED_ICON_REGISTRY: Record<
96
97
  'chat': ChatIcon,
97
98
  'close': CloseIcon,
98
99
  'chevron-down': ChevronDownIcon,
100
+ 'chevron-up': ChevronUpIcon,
99
101
  'chevron-right': ChevronRightIcon,
100
102
  'flip-camera': FlipCameraIcon,
101
103
  'flash': FlashIcon,
@@ -177,6 +179,7 @@ export { SendIcon } from './send';
177
179
  export { ChatIcon } from './chat';
178
180
  export { CloseIcon } from './close';
179
181
  export { ChevronDownIcon } from './chevron-down';
182
+ export { ChevronUpIcon } from './chevron-up';
180
183
  export { ChevronRightIcon } from './chevron-right';
181
184
  export { FlipCameraIcon } from './flip-camera';
182
185
  export { FlashIcon } from './flash';
@@ -56,6 +56,7 @@ export { InfoBanner } from './InfoBanner';
56
56
  export { Label } from './Label';
57
57
  export { ListItem } from './ListItem';
58
58
  export { ProgressBar } from './ProgressBar';
59
+ export { RangeSlider, type RangeSliderProps } from './RangeSlider';
59
60
  export { SectionHeader } from './SectionHeader';
60
61
  export { SelectRow, MultiSelectRow } from './SelectRow';
61
62
  export { StatRow } from './StatRow';
@@ -10,6 +10,7 @@ export { InfoBanner } from './InfoBanner';
10
10
  export { Label } from './Label';
11
11
  export { ListItem } from './ListItem';
12
12
  export { ProgressBar } from './ProgressBar';
13
+ export { RangeSlider, type RangeSliderProps } from './RangeSlider';
13
14
  export { SectionHeader } from './SectionHeader';
14
15
  export { SelectRow, MultiSelectRow } from './SelectRow';
15
16
  export { StatRow } from './StatRow';