@proulr/sdui-components 0.1.70 → 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.
- package/assets/icons/chevron-up.svg +8 -0
- package/package.json +11 -1
- package/src/catalog/app-icon-names.ts +1 -0
- package/src/catalog/catalog.test.ts +1 -1
- package/src/renderer/ui/RangeSlider.tsx +200 -0
- package/src/renderer/ui/app-icon.test.ts +2 -2
- package/src/renderer/ui/generated/chevron-up.tsx +16 -0
- package/src/renderer/ui/generated/index.ts +3 -0
- package/src/renderer/ui/index.ts +1 -0
- package/src/renderer/ui/primitives.ts +1 -0
|
@@ -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.
|
|
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",
|
|
@@ -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(
|
|
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);
|
|
@@ -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
|
|
13
|
-
expect(APP_ICON_NAMES).toHaveLength(
|
|
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';
|
package/src/renderer/ui/index.ts
CHANGED
|
@@ -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';
|