@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/command_menu.tsx
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView } from "react-native";
|
|
2
|
-
import { useState, useCallback, useMemo, useRef, useId } from "react";
|
|
3
|
-
import { colors } from "./colors";
|
|
4
|
-
import { TextInputField } from "./text_input_field";
|
|
5
|
-
import { MenuButton } from "./menu_button";
|
|
6
|
-
import { Text } from "./text";
|
|
7
|
-
import { useScreenSize } from "./use_screen_size";
|
|
8
|
-
|
|
9
|
-
export interface CommandOption {
|
|
10
|
-
value: string;
|
|
11
|
-
label: string;
|
|
12
|
-
disabled?: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface CommandMenuProps {
|
|
16
|
-
options: CommandOption[];
|
|
17
|
-
onSelect: (value: string) => void;
|
|
18
|
-
onRequestClose?: () => void;
|
|
19
|
-
enableSearch?: boolean;
|
|
20
|
-
/** Search input placeholder. Default: "Search...". Pass a translated string from the consumer. */
|
|
21
|
-
searchPlaceholder?: string;
|
|
22
|
-
/** Accessible name for the list. Default: "Options". Pass a translated string from the consumer. */
|
|
23
|
-
accessibilityLabel?: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function CommandMenu(props: CommandMenuProps) {
|
|
27
|
-
const {
|
|
28
|
-
options,
|
|
29
|
-
onSelect,
|
|
30
|
-
onRequestClose,
|
|
31
|
-
enableSearch,
|
|
32
|
-
searchPlaceholder = "Search...",
|
|
33
|
-
accessibilityLabel,
|
|
34
|
-
} = props;
|
|
35
|
-
const scrollViewRef = useRef<ScrollView>(null);
|
|
36
|
-
const screenSize = useScreenSize();
|
|
37
|
-
const OPTION_HEIGHT = 40;
|
|
38
|
-
|
|
39
|
-
const baseId = useId();
|
|
40
|
-
const listboxId = `${baseId}-listbox`;
|
|
41
|
-
const optionId = (index: number) => `${baseId}-option-${index}`;
|
|
42
|
-
|
|
43
|
-
const [searchQuery, setSearchQuery] = useState("");
|
|
44
|
-
|
|
45
|
-
const filteredOptions = useMemo(() => {
|
|
46
|
-
if (!enableSearch || !searchQuery) return options;
|
|
47
|
-
const query = searchQuery.toLowerCase();
|
|
48
|
-
return options.filter((opt) => opt.label.toLowerCase().includes(query));
|
|
49
|
-
}, [options, enableSearch, searchQuery]);
|
|
50
|
-
|
|
51
|
-
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
52
|
-
|
|
53
|
-
const handleSearchChange = useCallback((text: string) => {
|
|
54
|
-
setSearchQuery(text);
|
|
55
|
-
setFocusedIndex(0);
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
|
-
const handleSelect = useCallback(
|
|
59
|
-
(value: string) => {
|
|
60
|
-
onSelect(value);
|
|
61
|
-
},
|
|
62
|
-
[onSelect],
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const scrollToIndex = useCallback((index: number) => {
|
|
66
|
-
if (scrollViewRef.current && index >= 0) {
|
|
67
|
-
scrollViewRef.current.scrollTo({ y: index * OPTION_HEIGHT - 80, animated: false });
|
|
68
|
-
}
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
const handleKeyNavigation = useCallback(
|
|
72
|
-
(key: string): boolean => {
|
|
73
|
-
switch (key) {
|
|
74
|
-
case "ArrowDown": {
|
|
75
|
-
let next = focusedIndex + 1;
|
|
76
|
-
while (next < filteredOptions.length) {
|
|
77
|
-
if (!filteredOptions[next].disabled) {
|
|
78
|
-
setFocusedIndex(next);
|
|
79
|
-
scrollToIndex(next);
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
next++;
|
|
83
|
-
}
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
case "ArrowUp": {
|
|
87
|
-
let next = focusedIndex - 1;
|
|
88
|
-
while (next >= 0) {
|
|
89
|
-
if (!filteredOptions[next].disabled) {
|
|
90
|
-
setFocusedIndex(next);
|
|
91
|
-
scrollToIndex(next);
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
next--;
|
|
95
|
-
}
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
case "Home":
|
|
99
|
-
setFocusedIndex(0);
|
|
100
|
-
scrollToIndex(0);
|
|
101
|
-
return true;
|
|
102
|
-
case "End": {
|
|
103
|
-
const last = filteredOptions.length - 1;
|
|
104
|
-
setFocusedIndex(last);
|
|
105
|
-
scrollToIndex(last);
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
case "Enter":
|
|
109
|
-
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
|
110
|
-
const opt = filteredOptions[focusedIndex];
|
|
111
|
-
if (opt && !opt.disabled) handleSelect(opt.value);
|
|
112
|
-
}
|
|
113
|
-
return true;
|
|
114
|
-
case "Escape":
|
|
115
|
-
onRequestClose?.();
|
|
116
|
-
return true;
|
|
117
|
-
case "Tab":
|
|
118
|
-
onRequestClose?.();
|
|
119
|
-
return false;
|
|
120
|
-
default:
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
[focusedIndex, filteredOptions, handleSelect, onRequestClose, scrollToIndex],
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const handleSearchKeyPress = useCallback(
|
|
128
|
-
(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
129
|
-
if (handleKeyNavigation(e.nativeEvent.key)) {
|
|
130
|
-
e.preventDefault();
|
|
131
|
-
}
|
|
132
|
-
},
|
|
133
|
-
[handleKeyNavigation],
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const listLabel = accessibilityLabel ?? "Options";
|
|
137
|
-
const activeOptionId =
|
|
138
|
-
focusedIndex >= 0 && focusedIndex < filteredOptions.length ? optionId(focusedIndex) : undefined;
|
|
139
|
-
|
|
140
|
-
return (
|
|
141
|
-
<View style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}>
|
|
142
|
-
{enableSearch && (
|
|
143
|
-
<TextInputField
|
|
144
|
-
autoFocus={!screenSize.small}
|
|
145
|
-
icon="search"
|
|
146
|
-
value={searchQuery}
|
|
147
|
-
onChangeText={handleSearchChange}
|
|
148
|
-
placeholder={searchPlaceholder}
|
|
149
|
-
placeholderTextColor={colors.zinc["400"]}
|
|
150
|
-
autoCapitalize="none"
|
|
151
|
-
autoCorrect={false}
|
|
152
|
-
onKeyPress={handleSearchKeyPress}
|
|
153
|
-
accessibilityLabel={listLabel}
|
|
154
|
-
role="combobox"
|
|
155
|
-
aria-expanded
|
|
156
|
-
aria-controls={listboxId}
|
|
157
|
-
aria-activedescendant={activeOptionId}
|
|
158
|
-
aria-autocomplete="list"
|
|
159
|
-
/>
|
|
160
|
-
)}
|
|
161
|
-
<ScrollView
|
|
162
|
-
ref={scrollViewRef}
|
|
163
|
-
style={styles.optionsList}
|
|
164
|
-
nativeID={listboxId}
|
|
165
|
-
accessibilityLabel={listLabel}
|
|
166
|
-
// Boundary adapter: `listbox` is a valid ARIA role but missing from
|
|
167
|
-
// React Native's `Role` enum, so TypeScript cannot accept the literal
|
|
168
|
-
// here. `react-native-web` forwards the attribute verbatim, which is
|
|
169
|
-
// what assistive technology reads on web.
|
|
170
|
-
role={"listbox" as "list"}
|
|
171
|
-
>
|
|
172
|
-
{filteredOptions.map((item, index) => (
|
|
173
|
-
<MenuButton
|
|
174
|
-
key={item.value}
|
|
175
|
-
nativeID={optionId(index)}
|
|
176
|
-
testID={`command-option-${item.value}`}
|
|
177
|
-
title={
|
|
178
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
179
|
-
{item.label}
|
|
180
|
-
</Text>
|
|
181
|
-
}
|
|
182
|
-
accessibilityLabel={item.label}
|
|
183
|
-
role="option"
|
|
184
|
-
right={undefined}
|
|
185
|
-
focused={index === focusedIndex}
|
|
186
|
-
selected={index === focusedIndex}
|
|
187
|
-
disabled={item.disabled}
|
|
188
|
-
onPress={() => handleSelect(item.value)}
|
|
189
|
-
onHoverIn={() => setFocusedIndex(index)}
|
|
190
|
-
/>
|
|
191
|
-
))}
|
|
192
|
-
</ScrollView>
|
|
193
|
-
</View>
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const styles = StyleSheet.create({
|
|
198
|
-
container: {
|
|
199
|
-
gap: 4,
|
|
200
|
-
},
|
|
201
|
-
optionsList: {
|
|
202
|
-
flex: 1,
|
|
203
|
-
gap: 4,
|
|
204
|
-
},
|
|
205
|
-
});
|
package/src/hover_action.tsx
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { View, ViewStyle } from "react-native";
|
|
2
|
-
import { ReactNode, useState, useCallback } from "react";
|
|
3
|
-
import { useScreenSize } from "./use_screen_size";
|
|
4
|
-
|
|
5
|
-
interface HoverActionProps {
|
|
6
|
-
children: ReactNode;
|
|
7
|
-
/** Content to show on hover (e.g., IconButton with Popover) */
|
|
8
|
-
action: ReactNode;
|
|
9
|
-
/** Additional condition for showing the action (combined with hover state) */
|
|
10
|
-
visible?: boolean;
|
|
11
|
-
hover?: boolean;
|
|
12
|
-
|
|
13
|
-
/** Style for the outer wrapper */
|
|
14
|
-
style?: ViewStyle;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function HoverAction(props: HoverActionProps) {
|
|
18
|
-
const { children, action, visible = true, hover = true, style } = props;
|
|
19
|
-
const [hovered, setHovered] = useState(false);
|
|
20
|
-
const screenSize = useScreenSize();
|
|
21
|
-
|
|
22
|
-
// Disable hover on small screens (mobile) to prevent tap issues
|
|
23
|
-
const enableHover = hover && !screenSize.small;
|
|
24
|
-
|
|
25
|
-
const handleMouseEnter = useCallback(() => setHovered(true), []);
|
|
26
|
-
const handleMouseLeave = useCallback(() => setHovered(false), []);
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<View
|
|
30
|
-
style={[{ position: "relative" }, style]}
|
|
31
|
-
{...(enableHover
|
|
32
|
-
? {
|
|
33
|
-
onMouseEnter: handleMouseEnter,
|
|
34
|
-
onMouseLeave: handleMouseLeave,
|
|
35
|
-
}
|
|
36
|
-
: {})}
|
|
37
|
-
>
|
|
38
|
-
{children}
|
|
39
|
-
{(hovered || !enableHover) && visible && (
|
|
40
|
-
<View
|
|
41
|
-
style={{
|
|
42
|
-
position: "absolute",
|
|
43
|
-
zIndex: 1,
|
|
44
|
-
top: "50%",
|
|
45
|
-
transform: [{ translateY: "-50%" }],
|
|
46
|
-
right: 8,
|
|
47
|
-
}}
|
|
48
|
-
>
|
|
49
|
-
{action}
|
|
50
|
-
</View>
|
|
51
|
-
)}
|
|
52
|
-
</View>
|
|
53
|
-
);
|
|
54
|
-
}
|
package/src/picker_menu.tsx
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
1
|
-
import { StyleSheet, View, ScrollView, TextInput } from "react-native";
|
|
2
|
-
import { useState, useCallback, useMemo, useRef } from "react";
|
|
3
|
-
import { colors } from "./colors";
|
|
4
|
-
import { Text } from "./text";
|
|
5
|
-
import { Icon } from "./icon";
|
|
6
|
-
import { Checkbox } from "./checkbox";
|
|
7
|
-
import { MenuButton } from "./menu_button";
|
|
8
|
-
import { LinkButton } from "./link_button";
|
|
9
|
-
import { ActivityIndicator } from "./activity_indicator";
|
|
10
|
-
import { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
11
|
-
import { useScreenSize } from "./use_screen_size";
|
|
12
|
-
|
|
13
|
-
export interface PickerMenuProps<T extends string = string, MULTI extends boolean = false> {
|
|
14
|
-
testID?: string;
|
|
15
|
-
options: (PickerOption<T> | undefined | false)[];
|
|
16
|
-
multi?: MULTI;
|
|
17
|
-
value?: PickerValue<T, MULTI> | null;
|
|
18
|
-
onValueChange?: PickerOnValueChange<T, MULTI>;
|
|
19
|
-
onClose?: PickerOnClose<T, MULTI>;
|
|
20
|
-
onRequestClose?: () => void;
|
|
21
|
-
renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
|
|
22
|
-
enableSelectAll?: boolean;
|
|
23
|
-
includeEmptyOption?: boolean;
|
|
24
|
-
/** Show a loading indicator (e.g. while async results are in flight — the
|
|
25
|
-
* parent owns fetching and passes the resulting `options`). */
|
|
26
|
-
loading?: boolean;
|
|
27
|
-
/** Shown when there are no options and not loading. Default: "No results". */
|
|
28
|
-
emptyText?: string;
|
|
29
|
-
/** Label for the "select all" link in multi-select mode. Default: "Select all" */
|
|
30
|
-
selectAllLabel?: string;
|
|
31
|
-
/** Label for the "deselect all" link in multi-select mode. Default: "Deselect all" */
|
|
32
|
-
deselectAllLabel?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The option list for a Picker — a keyboard-navigable list with native-`<select>`
|
|
37
|
-
* typeahead (type to jump focus). It has NO search box: filtering a long/async
|
|
38
|
-
* list is the job of `Combobox`, whose input drives the search and passes the
|
|
39
|
-
* already-filtered `options` to render. Use standalone inside a Popover, or via
|
|
40
|
-
* the `Picker` component.
|
|
41
|
-
*/
|
|
42
|
-
export function PickerMenu<T extends string, MULTI extends boolean = false>(
|
|
43
|
-
props: PickerMenuProps<T, MULTI>,
|
|
44
|
-
) {
|
|
45
|
-
const {
|
|
46
|
-
testID,
|
|
47
|
-
options,
|
|
48
|
-
multi = false as MULTI,
|
|
49
|
-
value,
|
|
50
|
-
renderOptionContent,
|
|
51
|
-
onValueChange,
|
|
52
|
-
onClose,
|
|
53
|
-
onRequestClose,
|
|
54
|
-
enableSelectAll,
|
|
55
|
-
includeEmptyOption,
|
|
56
|
-
loading,
|
|
57
|
-
emptyText = "No results",
|
|
58
|
-
selectAllLabel = "Select all",
|
|
59
|
-
deselectAllLabel = "Deselect all",
|
|
60
|
-
} = props;
|
|
61
|
-
const scrollViewRef = useRef<ScrollView>(null);
|
|
62
|
-
const OPTION_HEIGHT = 40;
|
|
63
|
-
const screenSize = useScreenSize();
|
|
64
|
-
|
|
65
|
-
const { multiValue, singleValue } = useMemo((): {
|
|
66
|
-
multiValue: T[];
|
|
67
|
-
singleValue: T | undefined | null;
|
|
68
|
-
} => {
|
|
69
|
-
if (multi) {
|
|
70
|
-
return {
|
|
71
|
-
multiValue: Array.isArray(value) ? (value as T[]) : [],
|
|
72
|
-
singleValue: undefined,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
multiValue: [],
|
|
77
|
-
singleValue: Array.isArray(value) ? (value as T[])[0] : (value as T | undefined | null),
|
|
78
|
-
};
|
|
79
|
-
}, [multi, value]);
|
|
80
|
-
|
|
81
|
-
const displayOptions = useMemo(() => {
|
|
82
|
-
if (includeEmptyOption) {
|
|
83
|
-
const emptyOption: PickerOption<T> = { label: "", value: "" as T };
|
|
84
|
-
return [emptyOption, ...options];
|
|
85
|
-
}
|
|
86
|
-
return options;
|
|
87
|
-
}, [options, includeEmptyOption]);
|
|
88
|
-
|
|
89
|
-
const [focusedIndex, setFocusedIndex] = useState<number>(() => {
|
|
90
|
-
const selectedIndex = displayOptions.findIndex((opt) => {
|
|
91
|
-
if (!opt || opt.disabled) return false;
|
|
92
|
-
if (multi && Array.isArray(multiValue)) {
|
|
93
|
-
return multiValue.includes(opt.value);
|
|
94
|
-
}
|
|
95
|
-
return opt.value === singleValue;
|
|
96
|
-
});
|
|
97
|
-
if (selectedIndex >= 0) return selectedIndex;
|
|
98
|
-
const firstValid = displayOptions.findIndex((opt) => opt && !opt.disabled);
|
|
99
|
-
return firstValid >= 0 ? firstValid : 0;
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const handleClose = useCallback(() => {
|
|
103
|
-
onRequestClose?.();
|
|
104
|
-
if (onClose) {
|
|
105
|
-
if (multi) {
|
|
106
|
-
(onClose as (value: T[]) => void)((multiValue ?? []) as T[]);
|
|
107
|
-
} else {
|
|
108
|
-
(onClose as (value: T) => void)((singleValue ?? "") as T);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}, [multi, multiValue, singleValue, onClose, onRequestClose]);
|
|
112
|
-
|
|
113
|
-
const handleMultiSelect = useCallback(
|
|
114
|
-
(itemValue: T) => {
|
|
115
|
-
if (!multi || !onValueChange) return;
|
|
116
|
-
const currentValues = multiValue ?? [];
|
|
117
|
-
const newValue = currentValues.includes(itemValue)
|
|
118
|
-
? currentValues.filter((v) => v !== itemValue)
|
|
119
|
-
: [...currentValues, itemValue];
|
|
120
|
-
(onValueChange as (value: T[]) => void)(newValue);
|
|
121
|
-
},
|
|
122
|
-
[multi, multiValue, onValueChange],
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const handleSingleSelect = useCallback(
|
|
126
|
-
(itemValue: T) => {
|
|
127
|
-
if (multi) return;
|
|
128
|
-
if (onValueChange) {
|
|
129
|
-
(onValueChange as (value: T) => void)(itemValue);
|
|
130
|
-
}
|
|
131
|
-
handleClose();
|
|
132
|
-
},
|
|
133
|
-
[multi, onValueChange, handleClose],
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const handleSelectAll = useCallback(() => {
|
|
137
|
-
if (!multi || !onValueChange) return;
|
|
138
|
-
const allValues = options
|
|
139
|
-
.filter((opt): opt is PickerOption<T> => !!opt && !opt.disabled)
|
|
140
|
-
.map((opt) => opt.value);
|
|
141
|
-
(onValueChange as (value: T[]) => void)(allValues);
|
|
142
|
-
}, [multi, onValueChange, options]);
|
|
143
|
-
|
|
144
|
-
const handleDeselectAll = useCallback(() => {
|
|
145
|
-
if (!multi || !onValueChange) return;
|
|
146
|
-
(onValueChange as (value: T[]) => void)([]);
|
|
147
|
-
}, [multi, onValueChange]);
|
|
148
|
-
|
|
149
|
-
const scrollToIndex = useCallback((index: number) => {
|
|
150
|
-
if (scrollViewRef.current && index >= 0) {
|
|
151
|
-
const scrollY = index * OPTION_HEIGHT;
|
|
152
|
-
scrollViewRef.current.scrollTo({ y: scrollY - 80, animated: false });
|
|
153
|
-
}
|
|
154
|
-
}, []);
|
|
155
|
-
|
|
156
|
-
const handleKeyNavigation = useCallback(
|
|
157
|
-
(key: string): boolean => {
|
|
158
|
-
switch (key) {
|
|
159
|
-
case "ArrowDown": {
|
|
160
|
-
let next = focusedIndex + 1;
|
|
161
|
-
while (next < displayOptions.length) {
|
|
162
|
-
const opt = displayOptions[next];
|
|
163
|
-
if (opt && !opt.disabled) {
|
|
164
|
-
setFocusedIndex(next);
|
|
165
|
-
scrollToIndex(next);
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
next++;
|
|
169
|
-
}
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
case "ArrowUp": {
|
|
173
|
-
let next = focusedIndex - 1;
|
|
174
|
-
while (next >= 0) {
|
|
175
|
-
const opt = displayOptions[next];
|
|
176
|
-
if (opt && !opt.disabled) {
|
|
177
|
-
setFocusedIndex(next);
|
|
178
|
-
scrollToIndex(next);
|
|
179
|
-
break;
|
|
180
|
-
}
|
|
181
|
-
next--;
|
|
182
|
-
}
|
|
183
|
-
return true;
|
|
184
|
-
}
|
|
185
|
-
case "Enter":
|
|
186
|
-
if (focusedIndex >= 0 && focusedIndex < displayOptions.length) {
|
|
187
|
-
const focusedOption = displayOptions[focusedIndex];
|
|
188
|
-
if (focusedOption && !focusedOption.disabled) {
|
|
189
|
-
if (multi) handleMultiSelect(focusedOption.value);
|
|
190
|
-
else handleSingleSelect(focusedOption.value);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
case "Escape":
|
|
195
|
-
handleClose();
|
|
196
|
-
return true;
|
|
197
|
-
case "Tab":
|
|
198
|
-
handleClose();
|
|
199
|
-
return false;
|
|
200
|
-
default:
|
|
201
|
-
return false;
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
[focusedIndex, displayOptions, multi, handleMultiSelect, handleSingleSelect, handleClose, scrollToIndex],
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
// Native-select typeahead: accumulate printable keys into a short-lived buffer
|
|
208
|
-
// and jump focus to the first option whose label starts with it.
|
|
209
|
-
const typeaheadBufferRef = useRef("");
|
|
210
|
-
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
211
|
-
|
|
212
|
-
const handleTypeahead = useCallback(
|
|
213
|
-
(char: string) => {
|
|
214
|
-
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
|
|
215
|
-
typeaheadBufferRef.current += char.toLowerCase();
|
|
216
|
-
const buffer = typeaheadBufferRef.current;
|
|
217
|
-
const match = displayOptions.findIndex(
|
|
218
|
-
(opt) => opt && !opt.disabled && (opt.label ?? "").toLowerCase().startsWith(buffer),
|
|
219
|
-
);
|
|
220
|
-
if (match >= 0) {
|
|
221
|
-
setFocusedIndex(match);
|
|
222
|
-
scrollToIndex(match);
|
|
223
|
-
}
|
|
224
|
-
typeaheadTimerRef.current = setTimeout(() => {
|
|
225
|
-
typeaheadBufferRef.current = "";
|
|
226
|
-
}, 600);
|
|
227
|
-
},
|
|
228
|
-
[displayOptions, scrollToIndex],
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
const handleKeyPress = useCallback(
|
|
232
|
-
(e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
|
|
233
|
-
const key = e.nativeEvent.key;
|
|
234
|
-
if (handleKeyNavigation(key)) {
|
|
235
|
-
e.preventDefault();
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (key.length === 1) handleTypeahead(key);
|
|
239
|
-
},
|
|
240
|
-
[handleKeyNavigation, handleTypeahead],
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
const hasOptions = displayOptions.some((opt) => opt && !opt.disabled);
|
|
244
|
-
const hasSelectedOptions = multi && Array.isArray(multiValue) && multiValue.length > 0;
|
|
245
|
-
const showSelectAllLink = multi && hasOptions && enableSelectAll;
|
|
246
|
-
const showDeselectAllLink = multi && hasOptions && hasSelectedOptions && enableSelectAll;
|
|
247
|
-
const showLinks = showSelectAllLink || showDeselectAllLink;
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<View
|
|
251
|
-
testID={testID}
|
|
252
|
-
style={[styles.container, screenSize.small ? { flex: 1 } : { maxHeight: 480 }]}
|
|
253
|
-
>
|
|
254
|
-
{/* Visually hidden but focusable: captures typeahead + arrow/enter keys
|
|
255
|
-
while the popover is open, giving native-<select> behavior. */}
|
|
256
|
-
<TextInput
|
|
257
|
-
autoFocus={!screenSize.small}
|
|
258
|
-
value=""
|
|
259
|
-
onChangeText={() => {}}
|
|
260
|
-
onKeyPress={handleKeyPress}
|
|
261
|
-
style={styles.typeaheadCapture}
|
|
262
|
-
/>
|
|
263
|
-
<ScrollView ref={scrollViewRef} style={styles.optionsList}>
|
|
264
|
-
{loading && (
|
|
265
|
-
<View style={styles.statusRow}>
|
|
266
|
-
<ActivityIndicator />
|
|
267
|
-
</View>
|
|
268
|
-
)}
|
|
269
|
-
{!loading && displayOptions.length === 0 && (
|
|
270
|
-
<View style={styles.statusRow}>
|
|
271
|
-
<Text color="zinc-500" size="sm">
|
|
272
|
-
{emptyText}
|
|
273
|
-
</Text>
|
|
274
|
-
</View>
|
|
275
|
-
)}
|
|
276
|
-
{displayOptions.map((item, index) => {
|
|
277
|
-
if (!item) return null;
|
|
278
|
-
|
|
279
|
-
const isSelected = multi
|
|
280
|
-
? Array.isArray(multiValue) && multiValue.includes(item.value)
|
|
281
|
-
: item.value === singleValue;
|
|
282
|
-
const isFocused = index === focusedIndex;
|
|
283
|
-
|
|
284
|
-
return (
|
|
285
|
-
<MenuButton
|
|
286
|
-
key={item.value}
|
|
287
|
-
testID={item.testID || `picker-option-${item.value}`}
|
|
288
|
-
icon={multi ? <Checkbox checked={isSelected} /> : undefined}
|
|
289
|
-
title={
|
|
290
|
-
renderOptionContent ? (
|
|
291
|
-
renderOptionContent(item)
|
|
292
|
-
) : (
|
|
293
|
-
<Text userSelect="none" numberOfLines={1}>
|
|
294
|
-
{item.label}
|
|
295
|
-
</Text>
|
|
296
|
-
)
|
|
297
|
-
}
|
|
298
|
-
right={
|
|
299
|
-
!multi && isSelected ? (
|
|
300
|
-
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
301
|
-
) : undefined
|
|
302
|
-
}
|
|
303
|
-
focused={isFocused}
|
|
304
|
-
disabled={item.disabled}
|
|
305
|
-
onPress={() => (multi ? handleMultiSelect(item.value) : handleSingleSelect(item.value))}
|
|
306
|
-
onHoverIn={() => setFocusedIndex(index)}
|
|
307
|
-
style={styles.option}
|
|
308
|
-
/>
|
|
309
|
-
);
|
|
310
|
-
})}
|
|
311
|
-
</ScrollView>
|
|
312
|
-
{showLinks && (
|
|
313
|
-
<View style={styles.selectAllContainer}>
|
|
314
|
-
{showSelectAllLink && <LinkButton title={selectAllLabel} onPress={handleSelectAll} />}
|
|
315
|
-
{showDeselectAllLink && <LinkButton title={deselectAllLabel} onPress={handleDeselectAll} />}
|
|
316
|
-
</View>
|
|
317
|
-
)}
|
|
318
|
-
</View>
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const styles = StyleSheet.create({
|
|
323
|
-
container: {
|
|
324
|
-
gap: 4,
|
|
325
|
-
},
|
|
326
|
-
optionsList: {
|
|
327
|
-
flex: 1,
|
|
328
|
-
gap: 4,
|
|
329
|
-
},
|
|
330
|
-
selectAllContainer: {
|
|
331
|
-
flexDirection: "row",
|
|
332
|
-
alignItems: "center",
|
|
333
|
-
gap: 16,
|
|
334
|
-
paddingVertical: 4,
|
|
335
|
-
// Matches a MenuButton option's 8px text inset, so the bleeding-wash
|
|
336
|
-
// LinkButtons line up under the option labels above.
|
|
337
|
-
paddingHorizontal: 8,
|
|
338
|
-
borderTopWidth: 1,
|
|
339
|
-
borderTopColor: colors.border,
|
|
340
|
-
},
|
|
341
|
-
option: {
|
|
342
|
-
marginHorizontal: 0,
|
|
343
|
-
},
|
|
344
|
-
statusRow: {
|
|
345
|
-
alignItems: "center",
|
|
346
|
-
justifyContent: "center",
|
|
347
|
-
paddingVertical: 16,
|
|
348
|
-
},
|
|
349
|
-
typeaheadCapture: {
|
|
350
|
-
position: "absolute",
|
|
351
|
-
width: 1,
|
|
352
|
-
height: 1,
|
|
353
|
-
opacity: 0,
|
|
354
|
-
},
|
|
355
|
-
});
|