@klyftig/mobile-ui 1.4.0 → 1.6.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@klyftig/mobile-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"sync": "bash scripts/sync-to-kaelo.sh",
|
|
6
6
|
"pack-install": "npm pack && mv klyftig-mobile-ui-*.tgz /tmp/klyftig-mobile-ui-local.tgz && cd ../kaelo/mobile && npm install /tmp/klyftig-mobile-ui-local.tgz"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback, memo } from 'react';
|
|
2
2
|
import { View, TextInput as RNTextInput, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
3
|
-
import type { NativeSyntheticEvent, TextInputContentSizeChangeEventData } from 'react-native';
|
|
3
|
+
import type { NativeSyntheticEvent, StyleProp, TextInputContentSizeChangeEventData, ViewStyle } from 'react-native';
|
|
4
4
|
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
|
|
5
5
|
import { useTheme } from '../hooks/useTheme';
|
|
6
6
|
import { colors } from '../theme/colors';
|
|
@@ -35,6 +35,10 @@ interface GlassInputProps {
|
|
|
35
35
|
onFocus?: () => void;
|
|
36
36
|
onBlur?: () => void;
|
|
37
37
|
selectTextOnFocus?: boolean;
|
|
38
|
+
// Overrides the outer container (label + field). Its default `marginBottom: 16`
|
|
39
|
+
// suits stacked forms; screens with their own section rhythm pass
|
|
40
|
+
// `{ marginBottom: 0 }` so spacing is owned by the layout, not the input.
|
|
41
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
// When a single-line input is not focused, anchor the selection to the start so
|
|
@@ -73,6 +77,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
73
77
|
multiline = false,
|
|
74
78
|
numberOfLines,
|
|
75
79
|
autoGrow = false,
|
|
80
|
+
containerStyle,
|
|
76
81
|
}) => {
|
|
77
82
|
const { colors } = useTheme();
|
|
78
83
|
const [isSecure, setIsSecure] = useState(secureTextEntry);
|
|
@@ -106,7 +111,7 @@ const GlassInputComponent: React.FC<GlassInputProps> = ({
|
|
|
106
111
|
}, []);
|
|
107
112
|
|
|
108
113
|
return (
|
|
109
|
-
<View style={styles.container}>
|
|
114
|
+
<View style={[styles.container, containerStyle]}>
|
|
110
115
|
{label ? (
|
|
111
116
|
<Text style={[styles.label, { color: colors.text.primary }]}>{label}</Text>
|
|
112
117
|
) : null}
|
|
@@ -13,12 +13,23 @@ import { colors } from '../theme/colors';
|
|
|
13
13
|
export interface WheelPickerProps {
|
|
14
14
|
values: number[];
|
|
15
15
|
selected: number;
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `wraps` (only with `loop`): how many times this scroll went past the end
|
|
18
|
+
* of the list — +1 for 59 → 00 going down, -1 for 00 → 59 going up. A
|
|
19
|
+
* caller can carry it, e.g. bump the hour when the minutes wrap.
|
|
20
|
+
*/
|
|
21
|
+
onChange: (value: number, info: { wraps: number }) => void;
|
|
17
22
|
formatLabel?: (value: number) => string;
|
|
18
23
|
itemHeight?: number;
|
|
19
24
|
visibleCount?: number;
|
|
20
25
|
width?: number;
|
|
21
26
|
flex?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Wrap around (59 → 00 → 01, and back from 00 to 59), like a clock. The
|
|
29
|
+
* values are laid out LOOP_COPIES times and the wheel recentres on the
|
|
30
|
+
* middle copy after every scroll, so it never reaches an end in practice.
|
|
31
|
+
*/
|
|
32
|
+
loop?: boolean;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
const DEFAULT_ITEM_HEIGHT = 48;
|
|
@@ -32,6 +43,9 @@ const DEFAULT_VISIBLE_COUNT = 3;
|
|
|
32
43
|
// and momentum behave normally, but we don't mount 600 <Text> nodes on first
|
|
33
44
|
// paint of MeasurementsStep.
|
|
34
45
|
const WINDOW_OVERSCAN = 8;
|
|
46
|
+
// Odd, so there is a middle copy to start in and snap back to. 21 copies of
|
|
47
|
+
// 60 minutes is 1260 rows — only the overscan window is mounted.
|
|
48
|
+
const LOOP_COPIES = 21;
|
|
35
49
|
|
|
36
50
|
export function WheelPicker({
|
|
37
51
|
values,
|
|
@@ -42,6 +56,7 @@ export function WheelPicker({
|
|
|
42
56
|
visibleCount = DEFAULT_VISIBLE_COUNT,
|
|
43
57
|
width,
|
|
44
58
|
flex,
|
|
59
|
+
loop = false,
|
|
45
60
|
}: WheelPickerProps) {
|
|
46
61
|
const { colors } = useTheme();
|
|
47
62
|
const scrollRef = useRef<ScrollView>(null);
|
|
@@ -49,10 +64,18 @@ export function WheelPicker({
|
|
|
49
64
|
const padding = (containerHeight - itemHeight) / 2;
|
|
50
65
|
const lastIndexRef = useRef<number>(-1);
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
// Rows are virtual indices; with `loop` they span LOOP_COPIES copies of
|
|
68
|
+
// `values` and row i shows values[i mod n].
|
|
69
|
+
const n = values.length;
|
|
70
|
+
const rowCount = loop ? n * LOOP_COPIES : n;
|
|
71
|
+
const middleStart = loop ? Math.floor(LOOP_COPIES / 2) * n : 0;
|
|
72
|
+
const valueIndexOf = useCallback((row: number) => ((row % n) + n) % n, [n]);
|
|
73
|
+
|
|
74
|
+
const valueIndex = useMemo(() => {
|
|
53
75
|
const idx = values.indexOf(selected);
|
|
54
76
|
return idx >= 0 ? idx : 0;
|
|
55
77
|
}, [values, selected]);
|
|
78
|
+
const selectedIndex = middleStart + valueIndex;
|
|
56
79
|
|
|
57
80
|
// Track the row currently centered under the selection overlay during scroll
|
|
58
81
|
// so the rendered window follows the user's drag, not just the committed
|
|
@@ -73,38 +96,49 @@ export function WheelPicker({
|
|
|
73
96
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
74
97
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
75
98
|
const index = Math.round(offsetY / itemHeight);
|
|
76
|
-
const clamped = Math.max(0, Math.min(
|
|
99
|
+
const clamped = Math.max(0, Math.min(rowCount - 1, index));
|
|
77
100
|
setWindowCenter((prev) => (Math.abs(prev - clamped) >= 2 ? clamped : prev));
|
|
78
101
|
},
|
|
79
|
-
[itemHeight,
|
|
102
|
+
[itemHeight, rowCount],
|
|
80
103
|
);
|
|
81
104
|
|
|
82
105
|
const handleMomentumEnd = useCallback(
|
|
83
106
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
84
107
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
85
108
|
const index = Math.round(offsetY / itemHeight);
|
|
86
|
-
const clamped = Math.max(0, Math.min(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
const clamped = Math.max(0, Math.min(rowCount - 1, index));
|
|
110
|
+
const value = values[valueIndexOf(clamped)];
|
|
111
|
+
// Every scroll starts in the middle copy, so copies crossed = wraps.
|
|
112
|
+
const wraps = loop ? Math.floor(clamped / n) - Math.floor(LOOP_COPIES / 2) : 0;
|
|
113
|
+
let row = clamped;
|
|
114
|
+
if (loop) {
|
|
115
|
+
// Jump back to the same value in the middle copy (invisible: the rows
|
|
116
|
+
// look identical) so the next fling has room either way.
|
|
117
|
+
row = middleStart + valueIndexOf(clamped);
|
|
118
|
+
if (row !== clamped) scrollRef.current?.scrollTo({ y: row * itemHeight, animated: false });
|
|
119
|
+
lastIndexRef.current = row;
|
|
120
|
+
}
|
|
121
|
+
setWindowCenter(row);
|
|
122
|
+
if (value !== selected || wraps !== 0) {
|
|
123
|
+
onChange(value, { wraps });
|
|
90
124
|
}
|
|
91
125
|
},
|
|
92
|
-
[itemHeight, values, selected, onChange],
|
|
126
|
+
[itemHeight, n, rowCount, values, valueIndexOf, loop, middleStart, selected, onChange],
|
|
93
127
|
);
|
|
94
128
|
|
|
95
129
|
const windowStart = Math.max(0, windowCenter - WINDOW_OVERSCAN);
|
|
96
|
-
const windowEnd = Math.min(
|
|
130
|
+
const windowEnd = Math.min(rowCount - 1, windowCenter + WINDOW_OVERSCAN);
|
|
97
131
|
|
|
98
132
|
const dimColor = colors.text.tertiary;
|
|
99
133
|
const activeColor = colors.text.primary;
|
|
100
134
|
|
|
101
135
|
const items = [];
|
|
102
136
|
for (let i = windowStart; i <= windowEnd; i++) {
|
|
103
|
-
const value = values[i];
|
|
137
|
+
const value = values[valueIndexOf(i)];
|
|
104
138
|
const isSelected = i === selectedIndex;
|
|
105
139
|
items.push(
|
|
106
140
|
<View
|
|
107
|
-
key={
|
|
141
|
+
key={i}
|
|
108
142
|
style={[
|
|
109
143
|
styles.item,
|
|
110
144
|
{
|
|
@@ -128,7 +162,7 @@ export function WheelPicker({
|
|
|
128
162
|
);
|
|
129
163
|
}
|
|
130
164
|
|
|
131
|
-
const totalContentHeight =
|
|
165
|
+
const totalContentHeight = rowCount * itemHeight + padding * 2;
|
|
132
166
|
|
|
133
167
|
return (
|
|
134
168
|
<View style={[styles.wrapper, { height: containerHeight, width, flex }]}>
|