@lotics/ui 5.8.0 → 5.10.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/AGENTS.md +184 -56
- package/examples/tpl_approvals.tsx +3 -3
- package/examples/tpl_convert.tsx +11 -23
- package/examples/tpl_dossier.tsx +5 -4
- package/examples/tpl_order.tsx +1 -1
- package/examples/tpl_pipeline.tsx +400 -0
- package/examples/tpl_ratedesk.tsx +1 -1
- package/examples/tpl_record.tsx +6 -5
- package/examples/tpl_shifts.tsx +7 -5
- package/examples/tpl_task_board.tsx +473 -0
- package/examples/tpl_tasks.tsx +440 -0
- package/package.json +19 -6
- package/src/accordion.tsx +1 -1
- package/src/agent_progress.tsx +1 -1
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +2 -2
- package/src/button.tsx +12 -3
- package/src/calendar/calendar_view.tsx +10 -10
- package/src/calendar/month_view.tsx +30 -11
- package/src/calendar/time_grid_view.tsx +25 -7
- package/src/card_select_item.tsx +11 -13
- package/src/check_circle.tsx +88 -0
- package/src/checkbox_input.tsx +5 -1
- package/src/chip.tsx +1 -1
- package/src/chip_group.tsx +1 -1
- package/src/column_filter.tsx +3 -2
- package/src/combobox.tsx +84 -219
- package/src/confidence.tsx +14 -3
- package/src/control_surface.ts +22 -7
- package/src/data_grid.tsx +159 -0
- package/src/date_calendar.tsx +46 -23
- package/src/date_field.tsx +10 -2
- package/src/date_filter.tsx +5 -9
- package/src/date_picker.tsx +78 -26
- package/src/date_picker_value.ts +11 -0
- package/src/date_range_filter_field.tsx +6 -4
- package/src/drawer.tsx +1 -1
- package/src/file_dropzone.tsx +5 -0
- package/src/file_gallery_modal.tsx +227 -139
- package/src/file_grid.tsx +184 -0
- package/src/file_preview.web.tsx +349 -12
- package/src/file_preview_types.ts +49 -0
- package/src/file_row.tsx +118 -0
- package/src/file_rows.tsx +109 -0
- package/src/file_thumbnail.tsx +132 -81
- package/src/file_thumbnail_grid.tsx +135 -55
- package/src/files_editor.tsx +232 -0
- package/src/filter_chip.tsx +25 -12
- package/src/focus_ring_pressable.tsx +34 -0
- package/src/form_picker.tsx +3 -3
- package/src/gantt/gantt_view.tsx +25 -7
- package/src/heatmap.tsx +5 -1
- package/src/icon_button.tsx +14 -3
- package/src/image_gallery.tsx +8 -14
- package/src/index.css +8 -12
- package/src/inline_date_picker.tsx +11 -11
- package/src/inline_edit.tsx +22 -6
- package/src/inline_member_select.tsx +1 -1
- package/src/inline_select.tsx +9 -8
- package/src/inline_text_input.tsx +4 -1
- package/src/link.tsx +11 -1
- package/src/link_button.tsx +1 -1
- package/src/list_item.tsx +2 -2
- package/src/member_select.tsx +9 -4
- package/src/menu_button.tsx +2 -2
- package/src/menu_list_item.tsx +2 -2
- package/src/number_input.tsx +11 -4
- package/src/option_list.tsx +211 -0
- package/src/pdfjs_worker.d.ts +5 -0
- package/src/peek.tsx +1 -1
- package/src/picker.tsx +24 -213
- package/src/pressable_highlight.tsx +42 -23
- package/src/radio_picker.tsx +2 -2
- package/src/range_slider.tsx +35 -7
- package/src/react_native.d.ts +3 -0
- package/src/scroll_to_bottom.tsx +1 -1
- package/src/section.tsx +7 -2
- package/src/segmented_control.tsx +2 -2
- package/src/select.tsx +299 -0
- package/src/sort_header.tsx +1 -1
- package/src/sources.tsx +1 -1
- package/src/spreadsheet_view.tsx +43 -17
- package/src/status_grid.tsx +4 -3
- package/src/stepper.tsx +1 -1
- package/src/switch.tsx +5 -1
- package/src/switch_button.tsx +1 -1
- package/src/switcher.tsx +1 -1
- package/src/table.tsx +5 -1
- package/src/tabs.tsx +2 -2
- package/src/text_input_field.tsx +15 -4
- package/src/time_picker.tsx +11 -4
- package/src/timeline.tsx +1 -1
- package/src/uploading_thumbnail.tsx +212 -0
- package/src/use_focus_ring.ts +80 -0
- package/src/use_hover.ts +26 -0
- package/src/use_list_keyboard_nav.test.ts +71 -0
- package/src/use_list_keyboard_nav.ts +52 -5
- package/src/use_option_list.test.ts +193 -0
- package/src/use_option_list.ts +354 -0
- package/src/command_menu.tsx +0 -205
- package/src/hover_action.tsx +0 -54
- package/src/picker_menu.tsx +0 -355
- package/src/tag_input.tsx +0 -203
- package/src/time_field.tsx +0 -297
package/src/tag_input.tsx
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
import { useMemo, useRef, useState } from "react";
|
|
2
|
-
import { Pressable, ScrollView, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
-
import { colors } from "./colors";
|
|
4
|
-
import { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
-
import { Text } from "./text";
|
|
6
|
-
import { Icon } from "./icon";
|
|
7
|
-
import { TextInputField } from "./text_input_field";
|
|
8
|
-
import { MenuButton } from "./menu_button";
|
|
9
|
-
import { Popover, PopoverContent } from "./popover";
|
|
10
|
-
|
|
11
|
-
export interface TagOption {
|
|
12
|
-
value: string;
|
|
13
|
-
label: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface TagInputProps {
|
|
17
|
-
/** Selected tags. */
|
|
18
|
-
value: TagOption[];
|
|
19
|
-
/** The suggestible set. */
|
|
20
|
-
options: TagOption[];
|
|
21
|
-
onChange: (next: TagOption[]) => void;
|
|
22
|
-
/** Offer a "Create …" row when the query matches no option. Default false. */
|
|
23
|
-
allowCreate?: boolean;
|
|
24
|
-
placeholder?: string;
|
|
25
|
-
accessibilityLabel?: string;
|
|
26
|
-
style?: StyleProp<ViewStyle>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Multi-select tag field. The CONTROL is a bordered box of removable chips plus
|
|
31
|
-
* an Add affordance; the typing/searching/creating happens in a POPOVER (a
|
|
32
|
-
* searchable, checkable list + an optional create row) — never an inline token
|
|
33
|
-
* input squeezed among the chips. So the field always reads as a clean container
|
|
34
|
-
* that matches its border, instead of a text box fighting the chips. Distinct
|
|
35
|
-
* from `Combobox multi` (a search field whose selection renders as in-input chips):
|
|
36
|
-
* reach for `TagInput` when the field's resting state should be a tidy chip box.
|
|
37
|
-
*/
|
|
38
|
-
export function TagInput(props: TagInputProps) {
|
|
39
|
-
const { value, options, onChange, allowCreate = false, placeholder = "Add tags", accessibilityLabel, style } = props;
|
|
40
|
-
const [open, setOpen] = useState(false);
|
|
41
|
-
const [query, setQuery] = useState("");
|
|
42
|
-
const anchorRef = useRef<View>(null);
|
|
43
|
-
|
|
44
|
-
const selected = useMemo(() => new Set(value.map((t) => t.value)), [value]);
|
|
45
|
-
|
|
46
|
-
const filtered = useMemo(() => {
|
|
47
|
-
const q = query.trim().toLowerCase();
|
|
48
|
-
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
|
|
49
|
-
}, [options, query]);
|
|
50
|
-
|
|
51
|
-
const exact = useMemo(() => {
|
|
52
|
-
const q = query.trim().toLowerCase();
|
|
53
|
-
return q.length > 0 && [...options, ...value].some((o) => o.label.toLowerCase() === q);
|
|
54
|
-
}, [options, value, query]);
|
|
55
|
-
|
|
56
|
-
const showCreate = allowCreate && query.trim().length > 0 && !exact;
|
|
57
|
-
|
|
58
|
-
const toggle = (o: TagOption) =>
|
|
59
|
-
onChange(selected.has(o.value) ? value.filter((t) => t.value !== o.value) : [...value, o]);
|
|
60
|
-
|
|
61
|
-
const create = () => {
|
|
62
|
-
const label = query.trim();
|
|
63
|
-
if (!label) return;
|
|
64
|
-
const v = label.toLowerCase().replaceAll(" ", "_");
|
|
65
|
-
if (!selected.has(v)) onChange([...value, { value: v, label }]);
|
|
66
|
-
setQuery("");
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
return (
|
|
70
|
-
<>
|
|
71
|
-
<View ref={anchorRef} style={[styles.box, style]} accessibilityLabel={accessibilityLabel}>
|
|
72
|
-
{value.map((t) => (
|
|
73
|
-
<View key={t.value} style={styles.chip}>
|
|
74
|
-
<Text size="sm">{t.label}</Text>
|
|
75
|
-
<Pressable
|
|
76
|
-
onPress={() => onChange(value.filter((x) => x.value !== t.value))}
|
|
77
|
-
accessibilityRole="button"
|
|
78
|
-
accessibilityLabel={`Remove ${t.label}`}
|
|
79
|
-
style={styles.chipRemove}
|
|
80
|
-
hitSlop={6}
|
|
81
|
-
>
|
|
82
|
-
<Icon name="x" size={12} color={colors.zinc[500]} />
|
|
83
|
-
</Pressable>
|
|
84
|
-
</View>
|
|
85
|
-
))}
|
|
86
|
-
<Pressable
|
|
87
|
-
onPress={() => setOpen(true)}
|
|
88
|
-
accessibilityRole="button"
|
|
89
|
-
accessibilityLabel={accessibilityLabel ?? "Add tag"}
|
|
90
|
-
style={styles.add}
|
|
91
|
-
>
|
|
92
|
-
<Icon name="plus" size={14} color={colors.zinc[500]} />
|
|
93
|
-
<Text size="sm" color="muted">
|
|
94
|
-
{value.length === 0 ? placeholder : "Add"}
|
|
95
|
-
</Text>
|
|
96
|
-
</Pressable>
|
|
97
|
-
</View>
|
|
98
|
-
|
|
99
|
-
<Popover
|
|
100
|
-
open={open}
|
|
101
|
-
onOpenChange={(o) => {
|
|
102
|
-
setOpen(o);
|
|
103
|
-
if (!o) setQuery("");
|
|
104
|
-
}}
|
|
105
|
-
triggerRef={anchorRef}
|
|
106
|
-
side="bottom"
|
|
107
|
-
align="start"
|
|
108
|
-
offset={4}
|
|
109
|
-
inheritTriggerWidth
|
|
110
|
-
>
|
|
111
|
-
<PopoverContent>
|
|
112
|
-
<View style={styles.menu}>
|
|
113
|
-
<TextInputField
|
|
114
|
-
value={query}
|
|
115
|
-
onChangeText={setQuery}
|
|
116
|
-
icon="search"
|
|
117
|
-
placeholder={allowCreate ? "Search or create…" : "Search…"}
|
|
118
|
-
autoFocus
|
|
119
|
-
autoCapitalize="none"
|
|
120
|
-
autoCorrect={false}
|
|
121
|
-
onKeyPress={(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
122
|
-
if (e.nativeEvent.key === "Enter") {
|
|
123
|
-
e.preventDefault();
|
|
124
|
-
if (showCreate) create();
|
|
125
|
-
else if (filtered.length === 1) toggle(filtered[0]);
|
|
126
|
-
}
|
|
127
|
-
}}
|
|
128
|
-
/>
|
|
129
|
-
<ScrollView style={styles.list} keyboardShouldPersistTaps="handled">
|
|
130
|
-
{filtered.map((o) => (
|
|
131
|
-
<MenuButton
|
|
132
|
-
key={o.value}
|
|
133
|
-
title={o.label}
|
|
134
|
-
right={selected.has(o.value) ? <Icon name="check" size={16} color={colors.zinc[900]} /> : undefined}
|
|
135
|
-
onPress={() => toggle(o)}
|
|
136
|
-
/>
|
|
137
|
-
))}
|
|
138
|
-
{showCreate ? <MenuButton title={`Create “${query.trim()}”`} icon="plus" onPress={create} /> : null}
|
|
139
|
-
{filtered.length === 0 && !showCreate ? (
|
|
140
|
-
<View style={styles.empty}>
|
|
141
|
-
<Text size="sm" color="muted">
|
|
142
|
-
No tags found
|
|
143
|
-
</Text>
|
|
144
|
-
</View>
|
|
145
|
-
) : null}
|
|
146
|
-
</ScrollView>
|
|
147
|
-
</View>
|
|
148
|
-
</PopoverContent>
|
|
149
|
-
</Popover>
|
|
150
|
-
</>
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const styles = StyleSheet.create({
|
|
155
|
-
box: {
|
|
156
|
-
flexDirection: "row",
|
|
157
|
-
flexWrap: "wrap",
|
|
158
|
-
alignItems: "center",
|
|
159
|
-
gap: 6,
|
|
160
|
-
minHeight: 40,
|
|
161
|
-
paddingHorizontal: 6,
|
|
162
|
-
paddingVertical: 5,
|
|
163
|
-
borderWidth: 1,
|
|
164
|
-
borderColor: colors.border,
|
|
165
|
-
borderRadius: CONTROL_RADIUS,
|
|
166
|
-
backgroundColor: colors.background,
|
|
167
|
-
},
|
|
168
|
-
chip: {
|
|
169
|
-
flexDirection: "row",
|
|
170
|
-
alignItems: "center",
|
|
171
|
-
gap: 4,
|
|
172
|
-
paddingLeft: 8,
|
|
173
|
-
paddingRight: 4,
|
|
174
|
-
paddingVertical: 3,
|
|
175
|
-
borderRadius: 6,
|
|
176
|
-
backgroundColor: colors.zinc[100],
|
|
177
|
-
},
|
|
178
|
-
chipRemove: {
|
|
179
|
-
padding: 2,
|
|
180
|
-
borderRadius: 4,
|
|
181
|
-
cursor: "pointer",
|
|
182
|
-
},
|
|
183
|
-
add: {
|
|
184
|
-
flexDirection: "row",
|
|
185
|
-
alignItems: "center",
|
|
186
|
-
gap: 4,
|
|
187
|
-
paddingHorizontal: 6,
|
|
188
|
-
paddingVertical: 4,
|
|
189
|
-
cursor: "pointer",
|
|
190
|
-
},
|
|
191
|
-
menu: {
|
|
192
|
-
gap: 6,
|
|
193
|
-
padding: 6,
|
|
194
|
-
minWidth: 240,
|
|
195
|
-
},
|
|
196
|
-
list: {
|
|
197
|
-
maxHeight: 240,
|
|
198
|
-
},
|
|
199
|
-
empty: {
|
|
200
|
-
paddingVertical: 12,
|
|
201
|
-
alignItems: "center",
|
|
202
|
-
},
|
|
203
|
-
});
|
package/src/time_field.tsx
DELETED
|
@@ -1,297 +0,0 @@
|
|
|
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 { CONTROL_RADIUS } from "./control_surface";
|
|
5
|
-
import { Text } from "./text";
|
|
6
|
-
import { Icon } from "./icon";
|
|
7
|
-
import { Popover, PopoverContent } from "./popover";
|
|
8
|
-
import { pad } from "./date_picker_value";
|
|
9
|
-
import {
|
|
10
|
-
SegmentLabels,
|
|
11
|
-
fieldOrder,
|
|
12
|
-
from12h,
|
|
13
|
-
getTimeLayout,
|
|
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: 78 },
|
|
123
|
-
minute: { options: minuteOptions, onSelect: setMinute, label: segmentLabels.minute, width: 78 },
|
|
124
|
-
dayPeriod: {
|
|
125
|
-
options: periodOptions,
|
|
126
|
-
onSelect: setPeriod,
|
|
127
|
-
label: segmentLabels.dayPeriod,
|
|
128
|
-
width: 78,
|
|
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={c.label}
|
|
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: CONTROL_RADIUS,
|
|
271
|
-
borderWidth: 1,
|
|
272
|
-
borderColor: colors.border,
|
|
273
|
-
backgroundColor: colors.background,
|
|
274
|
-
},
|
|
275
|
-
triggerOpen: {
|
|
276
|
-
borderColor: colors.zinc["900"],
|
|
277
|
-
},
|
|
278
|
-
triggerDisabled: {
|
|
279
|
-
backgroundColor: colors.zinc["50"],
|
|
280
|
-
},
|
|
281
|
-
list: {
|
|
282
|
-
maxHeight: 220,
|
|
283
|
-
minWidth: 72,
|
|
284
|
-
},
|
|
285
|
-
optionRow: {
|
|
286
|
-
height: ROW_HEIGHT,
|
|
287
|
-
justifyContent: "center",
|
|
288
|
-
paddingHorizontal: 12,
|
|
289
|
-
borderRadius: 6,
|
|
290
|
-
},
|
|
291
|
-
optionHovered: {
|
|
292
|
-
backgroundColor: colors.zinc["100"],
|
|
293
|
-
},
|
|
294
|
-
optionSelected: {
|
|
295
|
-
backgroundColor: colors.zinc["900"],
|
|
296
|
-
},
|
|
297
|
-
});
|