@lotics/ui 1.11.1 → 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.
- package/package.json +7 -4
- package/src/accordion.tsx +97 -0
- package/src/button.tsx +1 -1
- package/src/calendar/calendar_view.tsx +5 -1
- package/src/calendar/month_view.tsx +40 -4
- package/src/cell_date_format.test.ts +32 -0
- package/src/cell_date_format.ts +28 -3
- package/src/checkbox_input.tsx +8 -3
- package/src/css_modules.d.ts +2 -0
- package/src/date_calendar.tsx +679 -0
- package/src/date_field.tsx +172 -0
- package/src/date_picker.tsx +403 -28
- package/src/date_picker_value.test.ts +167 -0
- package/src/date_picker_value.ts +128 -0
- package/src/date_segments.test.ts +206 -0
- package/src/date_segments.ts +347 -0
- package/src/date_segments_field.tsx +418 -0
- package/src/gantt/gantt_view.tsx +31 -5
- package/src/icon.tsx +2 -0
- package/src/menu_button.tsx +1 -1
- package/src/menu_list_item.tsx +1 -1
- package/src/popover.tsx +28 -2
- package/src/radio_picker.tsx +1 -1
- package/src/stepper.tsx +83 -0
- package/src/switch.tsx +1 -1
- package/src/switch_button.tsx +1 -1
- package/src/tabs.tsx +1 -1
- package/src/time_field.tsx +300 -0
- package/src/use_pointer_drag.ts +99 -0
- package/src/datetime_picker.tsx +0 -44
package/src/switch.tsx
CHANGED
|
@@ -76,7 +76,7 @@ export function Switch(props: SwitchProps) {
|
|
|
76
76
|
disabled={disabled}
|
|
77
77
|
accessibilityRole="switch"
|
|
78
78
|
accessibilityLabel={accessibilityLabel}
|
|
79
|
-
|
|
79
|
+
aria-checked={!!value} aria-disabled={disabled || undefined}
|
|
80
80
|
>
|
|
81
81
|
{content}
|
|
82
82
|
</Pressable>
|
package/src/switch_button.tsx
CHANGED
|
@@ -28,7 +28,7 @@ export function SwitchButton(props: SwitchButtonProps) {
|
|
|
28
28
|
tooltip={tooltip}
|
|
29
29
|
accessibilityRole="switch"
|
|
30
30
|
accessibilityLabel={title}
|
|
31
|
-
|
|
31
|
+
aria-checked={!!value}
|
|
32
32
|
>
|
|
33
33
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "center", flex: 1 }}>
|
|
34
34
|
{!!icon && <Icon name={icon} size={20} />}
|
package/src/tabs.tsx
CHANGED
|
@@ -121,7 +121,7 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
121
121
|
testID={option.testID}
|
|
122
122
|
accessibilityRole="tab"
|
|
123
123
|
accessibilityLabel={option.label}
|
|
124
|
-
|
|
124
|
+
aria-selected={selected}
|
|
125
125
|
// Roving tabindex: the selected tab is the tab-stop, others are reachable
|
|
126
126
|
// via arrow keys. When no tab matches the current selection (props.value
|
|
127
127
|
// is stale), the first tab is the fallback so the group stays keyboard-
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
|
|
4
|
+
const DRAG_THRESHOLD = 4; // px the pointer must travel before a press becomes a drag
|
|
5
|
+
|
|
6
|
+
/** Live state of an in-progress drag — the pointer delta from where it started. */
|
|
7
|
+
export interface DragLive {
|
|
8
|
+
id: string;
|
|
9
|
+
dx: number;
|
|
10
|
+
dy: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PointerDragApi {
|
|
14
|
+
/** Non-null only while a drag is active (past the threshold). Consumers render
|
|
15
|
+
* live feedback (translate / resize) from `dx`/`dy`. */
|
|
16
|
+
live: DragLive | null;
|
|
17
|
+
/** Ref for a draggable element — attaches a pointerdown listener tagged with
|
|
18
|
+
* `id` and sets the web-only `cursor` + `touch-action: none` on the node (so
|
|
19
|
+
* touch-drag doesn't scroll). Stable per id+cursor, so it doesn't thrash
|
|
20
|
+
* listeners across renders. */
|
|
21
|
+
bind: (id: string, cursor?: string) => (node: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Self-contained pointer-event drag for time-axis primitives (calendar event
|
|
26
|
+
* chips, gantt bar handles). Mirrors the kanban's proven approach — pointerdown
|
|
27
|
+
* on the element, a movement threshold to distinguish taps, then window-level
|
|
28
|
+
* pointermove/up — but with no floating clone and no column/card coupling. On
|
|
29
|
+
* release, `onDrop` receives the final client pointer position plus the total
|
|
30
|
+
* delta; the consumer maps that to a date. Web-only (RN has no pointer events);
|
|
31
|
+
* on native it's an inert no-op so taps/press handlers still work.
|
|
32
|
+
*/
|
|
33
|
+
export function usePointerDrag(
|
|
34
|
+
onDrop: (id: string, pointer: { x: number; y: number }, delta: { dx: number; dy: number }) => void,
|
|
35
|
+
): PointerDragApi {
|
|
36
|
+
const [live, setLive] = useState<DragLive | null>(null);
|
|
37
|
+
const press = useRef<{ id: string; x: number; y: number; active: boolean } | null>(null);
|
|
38
|
+
const onDropRef = useRef(onDrop);
|
|
39
|
+
onDropRef.current = onDrop;
|
|
40
|
+
|
|
41
|
+
// Per-id DOM node + its pointerdown handler, so we can detach on unmount/rebind.
|
|
42
|
+
const bound = useRef<Map<string, { el: HTMLElement; handler: (e: PointerEvent) => void }>>(new Map());
|
|
43
|
+
const refCache = useRef<Map<string, (node: unknown) => void>>(new Map());
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (Platform.OS !== "web") return;
|
|
47
|
+
const move = (e: PointerEvent) => {
|
|
48
|
+
const p = press.current;
|
|
49
|
+
if (!p) return;
|
|
50
|
+
const dx = e.clientX - p.x;
|
|
51
|
+
const dy = e.clientY - p.y;
|
|
52
|
+
if (!p.active && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
|
53
|
+
p.active = true;
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setLive({ id: p.id, dx, dy });
|
|
56
|
+
};
|
|
57
|
+
const up = (e: PointerEvent) => {
|
|
58
|
+
const p = press.current;
|
|
59
|
+
press.current = null;
|
|
60
|
+
setLive(null);
|
|
61
|
+
if (p?.active) {
|
|
62
|
+
onDropRef.current(p.id, { x: e.clientX, y: e.clientY }, { dx: e.clientX - p.x, dy: e.clientY - p.y });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
window.addEventListener("pointermove", move);
|
|
66
|
+
window.addEventListener("pointerup", up);
|
|
67
|
+
return () => {
|
|
68
|
+
window.removeEventListener("pointermove", move);
|
|
69
|
+
window.removeEventListener("pointerup", up);
|
|
70
|
+
};
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const bind = useCallback((id: string, cursor = "grab") => {
|
|
74
|
+
const key = `${id}:${cursor}`;
|
|
75
|
+
const cached = refCache.current.get(key);
|
|
76
|
+
if (cached) return cached;
|
|
77
|
+
const cb = (node: unknown) => {
|
|
78
|
+
const el = node as HTMLElement | null;
|
|
79
|
+
const prev = bound.current.get(id);
|
|
80
|
+
if (prev && prev.el !== el) {
|
|
81
|
+
prev.el.removeEventListener("pointerdown", prev.handler);
|
|
82
|
+
bound.current.delete(id);
|
|
83
|
+
}
|
|
84
|
+
if (!el || Platform.OS !== "web") return;
|
|
85
|
+
if (bound.current.has(id)) return;
|
|
86
|
+
el.style.touchAction = "none";
|
|
87
|
+
el.style.cursor = cursor;
|
|
88
|
+
const handler = (e: PointerEvent) => {
|
|
89
|
+
press.current = { id, x: e.clientX, y: e.clientY, active: false };
|
|
90
|
+
};
|
|
91
|
+
el.addEventListener("pointerdown", handler);
|
|
92
|
+
bound.current.set(id, { el, handler });
|
|
93
|
+
};
|
|
94
|
+
refCache.current.set(key, cb);
|
|
95
|
+
return cb;
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
return { live, bind };
|
|
99
|
+
}
|
package/src/datetime_picker.tsx
DELETED
|
@@ -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
|
-
}
|