@lotics/ui 1.12.0 → 1.13.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.
@@ -0,0 +1,300 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Pressable, ScrollView, StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Icon } from "./icon";
6
+ import { Popover, PopoverContent } from "./popover";
7
+ import { pad } from "./date_picker_value";
8
+ import {
9
+ SegmentLabels,
10
+ fieldOrder,
11
+ from12h,
12
+ getTimeLayout,
13
+ placeholderFor,
14
+ to12h,
15
+ } from "./date_segments";
16
+
17
+ export interface TimeFieldProps {
18
+ /** Canonical 24-hour "HH:mm" value, "" when empty. */
19
+ value: string;
20
+ onChange: (value: string) => void;
21
+ segmentLabels: SegmentLabels;
22
+ /** BCP-47 locale for hour/minute order + 12/24h. Default "en-US". */
23
+ locale?: string;
24
+ disabled?: boolean;
25
+ /** Names the group of selects (e.g. "Start time"). */
26
+ accessibilityLabel?: string;
27
+ testID?: string;
28
+ }
29
+
30
+ interface Option {
31
+ value: string;
32
+ label: string;
33
+ }
34
+
35
+ const ROW_HEIGHT = 32;
36
+
37
+ /**
38
+ * A time picker built from independent hour / minute (/ AM-PM) selects, ordered
39
+ * and 12-vs-24h'd by the locale. Each segment is its own dropdown so any hour and
40
+ * any minute is one pick — the minute list covers all 60. Canonical value is the
41
+ * 24-hour "HH:mm"; the AM/PM split is derived for display only.
42
+ */
43
+ export function TimeField(props: TimeFieldProps) {
44
+ const {
45
+ value,
46
+ onChange,
47
+ segmentLabels,
48
+ locale = "en-US",
49
+ disabled,
50
+ accessibilityLabel,
51
+ testID,
52
+ } = props;
53
+
54
+ const layout = useMemo(() => getTimeLayout(locale), [locale]);
55
+ const order = useMemo(() => fieldOrder(layout), [layout]);
56
+
57
+ const parsed = useMemo(() => {
58
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
59
+ if (!match) return null;
60
+ const h = Number(match[1]);
61
+ const mi = Number(match[2]);
62
+ return h <= 23 && mi <= 59 ? { h, mi } : null;
63
+ }, [value]);
64
+
65
+ const hourOptions = useMemo<Option[]>(() => {
66
+ const hours = layout.hour12
67
+ ? [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
68
+ : Array.from({ length: 24 }, (_, h) => h);
69
+ return hours.map((h) => ({ value: String(h), label: pad(h) }));
70
+ }, [layout.hour12]);
71
+
72
+ const minuteOptions = useMemo<Option[]>(
73
+ () => Array.from({ length: 60 }, (_, m) => ({ value: String(m), label: pad(m) })),
74
+ [],
75
+ );
76
+
77
+ const periodOptions = useMemo<Option[]>(
78
+ () => [
79
+ { value: "am", label: layout.amText },
80
+ { value: "pm", label: layout.pmText },
81
+ ],
82
+ [layout.amText, layout.pmText],
83
+ );
84
+
85
+ const emit = useCallback((h: number, mi: number) => onChange(`${pad(h)}:${pad(mi)}`), [onChange]);
86
+
87
+ // Each select fills in the others from the current value (defaulting the
88
+ // untouched halves to 0) so any single pick yields a complete "HH:mm".
89
+ const setHour = useCallback(
90
+ (next: string) => {
91
+ const h = layout.hour12
92
+ ? from12h(Number(next), parsed ? to12h(parsed.h).pm : false)
93
+ : Number(next);
94
+ emit(h, parsed?.mi ?? 0);
95
+ },
96
+ [parsed, layout.hour12, emit],
97
+ );
98
+
99
+ const setMinute = useCallback(
100
+ (next: string) => emit(parsed?.h ?? 0, Number(next)),
101
+ [parsed, emit],
102
+ );
103
+
104
+ const setPeriod = useCallback(
105
+ (next: string) => {
106
+ const h12 = parsed ? to12h(parsed.h).h12 : 12;
107
+ emit(from12h(h12, next === "pm"), parsed?.mi ?? 0);
108
+ },
109
+ [parsed, emit],
110
+ );
111
+
112
+ const selected: Record<"hour" | "minute" | "dayPeriod", string | null> = {
113
+ hour: parsed ? String(layout.hour12 ? to12h(parsed.h).h12 : parsed.h) : null,
114
+ minute: parsed ? String(parsed.mi) : null,
115
+ dayPeriod: parsed ? (to12h(parsed.h).pm ? "pm" : "am") : null,
116
+ };
117
+
118
+ const config: Record<
119
+ "hour" | "minute" | "dayPeriod",
120
+ { options: Option[]; onSelect: (v: string) => void; label: string; width: number }
121
+ > = {
122
+ hour: { options: hourOptions, onSelect: setHour, label: segmentLabels.hour, width: 58 },
123
+ minute: { options: minuteOptions, onSelect: setMinute, label: segmentLabels.minute, width: 58 },
124
+ dayPeriod: {
125
+ options: periodOptions,
126
+ onSelect: setPeriod,
127
+ label: segmentLabels.dayPeriod,
128
+ width: 66,
129
+ },
130
+ };
131
+
132
+ return (
133
+ <View style={styles.row} accessibilityLabel={accessibilityLabel}>
134
+ {order.map((type) => {
135
+ if (type !== "hour" && type !== "minute" && type !== "dayPeriod") return null;
136
+ const c = config[type];
137
+ return (
138
+ <TimeSelect
139
+ key={type}
140
+ testID={testID ? `${testID}_${type}` : undefined}
141
+ value={selected[type]}
142
+ options={c.options}
143
+ onSelect={c.onSelect}
144
+ accessibilityLabel={c.label}
145
+ placeholder={placeholderFor(type)}
146
+ disabled={disabled}
147
+ width={c.width}
148
+ />
149
+ );
150
+ })}
151
+ </View>
152
+ );
153
+ }
154
+
155
+ interface TimeSelectProps {
156
+ value: string | null;
157
+ options: Option[];
158
+ onSelect: (value: string) => void;
159
+ accessibilityLabel: string;
160
+ placeholder: string;
161
+ disabled?: boolean;
162
+ testID?: string;
163
+ width: number;
164
+ }
165
+
166
+ /** One borderless-trigger dropdown select; the whole trigger is pressable. */
167
+ function TimeSelect(props: TimeSelectProps) {
168
+ const { value, options, onSelect, accessibilityLabel, placeholder, disabled, testID, width } =
169
+ props;
170
+
171
+ const [open, setOpen] = useState(false);
172
+ const triggerRef = useRef<View>(null);
173
+ const scrollRef = useRef<ScrollView>(null);
174
+
175
+ const selectedLabel = options.find((o) => o.value === value)?.label ?? null;
176
+ const selectedIndex = options.findIndex((o) => o.value === value);
177
+
178
+ // Open the list scrolled to the current value (the minute list is 60 long).
179
+ useEffect(() => {
180
+ if (!open || selectedIndex < 0) return;
181
+ const y = Math.max(0, selectedIndex * ROW_HEIGHT - ROW_HEIGHT * 2);
182
+ const id = setTimeout(() => scrollRef.current?.scrollTo({ y, animated: false }), 0);
183
+ return () => clearTimeout(id);
184
+ }, [open, selectedIndex]);
185
+
186
+ const openList = useCallback(() => {
187
+ if (!disabled) setOpen(true);
188
+ }, [disabled]);
189
+
190
+ const trigger = (
191
+ <View
192
+ ref={triggerRef}
193
+ onPointerDown={openList}
194
+ testID={testID}
195
+ accessibilityRole="button"
196
+ accessibilityLabel={accessibilityLabel}
197
+ style={[
198
+ styles.trigger,
199
+ { width },
200
+ open && !disabled && styles.triggerOpen,
201
+ disabled && styles.triggerDisabled,
202
+ ]}
203
+ >
204
+ <Text size="sm" color={selectedLabel ? "default" : "muted"}>
205
+ {selectedLabel ?? placeholder}
206
+ </Text>
207
+ <Icon
208
+ name="chevron-down"
209
+ size={16}
210
+ color={disabled ? colors.zinc["300"] : colors.zinc["400"]}
211
+ />
212
+ </View>
213
+ );
214
+
215
+ return (
216
+ <>
217
+ {trigger}
218
+ <Popover
219
+ open={open && !disabled}
220
+ onOpenChange={setOpen}
221
+ triggerRef={triggerRef}
222
+ side="bottom"
223
+ align="start"
224
+ >
225
+ <PopoverContent testID={testID ? `${testID}_list` : undefined} disableBodyScroll>
226
+ <ScrollView ref={scrollRef} style={styles.list} keyboardShouldPersistTaps="handled">
227
+ {options.map((option) => {
228
+ const isSelected = option.value === value;
229
+ return (
230
+ <Pressable
231
+ key={option.value}
232
+ accessibilityRole="button"
233
+ accessibilityState={{ selected: isSelected }}
234
+ onPress={() => {
235
+ onSelect(option.value);
236
+ setOpen(false);
237
+ }}
238
+ style={({ hovered }) => [
239
+ styles.optionRow,
240
+ hovered && !isSelected && styles.optionHovered,
241
+ isSelected && styles.optionSelected,
242
+ ]}
243
+ >
244
+ <Text size="sm" color={isSelected ? "inverted" : "default"}>
245
+ {option.label}
246
+ </Text>
247
+ </Pressable>
248
+ );
249
+ })}
250
+ </ScrollView>
251
+ </PopoverContent>
252
+ </Popover>
253
+ </>
254
+ );
255
+ }
256
+
257
+ const styles = StyleSheet.create({
258
+ row: {
259
+ flexDirection: "row",
260
+ alignItems: "center",
261
+ gap: 6,
262
+ },
263
+ trigger: {
264
+ flexDirection: "row",
265
+ alignItems: "center",
266
+ justifyContent: "space-between",
267
+ height: 40,
268
+ paddingHorizontal: 8,
269
+ gap: 2,
270
+ borderRadius: 8,
271
+ borderWidth: 1,
272
+ borderColor: colors.border,
273
+ backgroundColor: colors.background,
274
+ },
275
+ triggerOpen: {
276
+ outlineColor: colors.black,
277
+ outlineWidth: 2,
278
+ outlineStyle: "solid",
279
+ outlineOffset: -2,
280
+ },
281
+ triggerDisabled: {
282
+ backgroundColor: colors.zinc["50"],
283
+ },
284
+ list: {
285
+ maxHeight: 220,
286
+ minWidth: 72,
287
+ },
288
+ optionRow: {
289
+ height: ROW_HEIGHT,
290
+ justifyContent: "center",
291
+ paddingHorizontal: 12,
292
+ borderRadius: 6,
293
+ },
294
+ optionHovered: {
295
+ backgroundColor: colors.zinc["100"],
296
+ },
297
+ optionSelected: {
298
+ backgroundColor: colors.zinc["900"],
299
+ },
300
+ });
@@ -1,44 +0,0 @@
1
- import { colors } from "@lotics/ui/colors";
2
- import { fontFamilyRegular, inputTextStyleWeb } from "@lotics/ui/text_utils";
3
-
4
- export interface DateTimePickerProps {
5
- value?: string | null;
6
- onValueChange: (value: string) => void;
7
- disabled?: boolean;
8
- onBlur?: () => void;
9
- }
10
-
11
- /**
12
- * A unified datetime picker component that allows selecting both date and time.
13
- * Value format: ISO 8601 datetime string (e.g., "2025-03-01T14:00")
14
- */
15
- export function DateTimePicker(props: DateTimePickerProps) {
16
- const { value, onValueChange, disabled, onBlur } = props;
17
-
18
- return (
19
- <input
20
- type="datetime-local"
21
- value={value || ""}
22
- onChange={(e) => {
23
- onValueChange(e.target.value);
24
- }}
25
- disabled={disabled}
26
- onBlur={onBlur}
27
- style={{
28
- height: 40,
29
- borderRadius: 8,
30
- paddingLeft: 8,
31
- paddingRight: 8,
32
- borderWidth: 1,
33
- boxShadow: "none",
34
- borderStyle: "solid",
35
- boxSizing: "border-box",
36
- borderColor: colors.border,
37
- backgroundColor: colors.background,
38
- fontFamily: fontFamilyRegular,
39
- ...inputTextStyleWeb,
40
- letterSpacing: -0.4,
41
- }}
42
- />
43
- );
44
- }