@lotics/ui 1.14.0 → 1.15.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 +1 -1
- package/src/menu_list_item.tsx +43 -3
- package/src/search_select.tsx +47 -47
package/package.json
CHANGED
package/src/menu_list_item.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Ref } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
|
+
import { colors } from "./colors";
|
|
4
5
|
import { Icon, IconName } from "./icon";
|
|
5
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
6
7
|
|
|
@@ -11,13 +12,38 @@ export interface MenuListItemProps {
|
|
|
11
12
|
description?: string;
|
|
12
13
|
right?: React.ReactNode;
|
|
13
14
|
onPress?: () => void;
|
|
15
|
+
onHoverIn?: () => void;
|
|
16
|
+
/** Persistent selection highlight (e.g. the current value in a listbox). */
|
|
17
|
+
selected?: boolean;
|
|
18
|
+
/** Roving keyboard highlight, driven by the controlling input via
|
|
19
|
+
* `aria-activedescendant`. Distinct from `selected`. */
|
|
20
|
+
focused?: boolean;
|
|
14
21
|
disabled?: boolean;
|
|
15
22
|
style?: StyleProp<ViewStyle>;
|
|
16
23
|
testID?: string;
|
|
24
|
+
/** DOM id, referenced by a combobox input's `aria-activedescendant`. */
|
|
25
|
+
nativeID?: string;
|
|
26
|
+
/** ARIA role. Defaults to `button`; pass `option` for a listbox row. */
|
|
27
|
+
role?: "menuitem" | "button" | "option";
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export function MenuListItem(props: MenuListItemProps) {
|
|
20
|
-
const {
|
|
31
|
+
const {
|
|
32
|
+
ref,
|
|
33
|
+
icon,
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
right,
|
|
37
|
+
onPress,
|
|
38
|
+
onHoverIn,
|
|
39
|
+
selected,
|
|
40
|
+
focused,
|
|
41
|
+
disabled,
|
|
42
|
+
style,
|
|
43
|
+
testID,
|
|
44
|
+
nativeID,
|
|
45
|
+
role = "button",
|
|
46
|
+
} = props;
|
|
21
47
|
|
|
22
48
|
const resolvedIcon =
|
|
23
49
|
typeof icon === "string" ? <Icon size={20} name={icon as IconName} /> : icon;
|
|
@@ -39,18 +65,26 @@ export function MenuListItem(props: MenuListItemProps) {
|
|
|
39
65
|
</>
|
|
40
66
|
);
|
|
41
67
|
|
|
42
|
-
const containerStyle = [
|
|
68
|
+
const containerStyle = [
|
|
69
|
+
styles.container,
|
|
70
|
+
selected && styles.selected,
|
|
71
|
+
focused && !selected && styles.focused,
|
|
72
|
+
style,
|
|
73
|
+
];
|
|
43
74
|
|
|
44
75
|
if (onPress) {
|
|
45
76
|
return (
|
|
46
77
|
<PressableHighlight
|
|
47
78
|
ref={ref}
|
|
48
79
|
testID={testID}
|
|
80
|
+
nativeID={nativeID}
|
|
49
81
|
onPress={onPress}
|
|
82
|
+
onHoverIn={onHoverIn}
|
|
50
83
|
disabled={disabled}
|
|
51
84
|
style={containerStyle}
|
|
52
|
-
|
|
85
|
+
role={role}
|
|
53
86
|
accessibilityLabel={description ? `${title}, ${description}` : title}
|
|
87
|
+
aria-selected={selected || undefined}
|
|
54
88
|
aria-disabled={disabled || undefined}
|
|
55
89
|
>
|
|
56
90
|
{inner}
|
|
@@ -79,4 +113,10 @@ const styles = StyleSheet.create({
|
|
|
79
113
|
gap: 2,
|
|
80
114
|
alignItems: "flex-start",
|
|
81
115
|
},
|
|
116
|
+
selected: {
|
|
117
|
+
backgroundColor: colors.zinc["100"],
|
|
118
|
+
},
|
|
119
|
+
focused: {
|
|
120
|
+
backgroundColor: colors.zinc["50"],
|
|
121
|
+
},
|
|
82
122
|
});
|
package/src/search_select.tsx
CHANGED
|
@@ -9,9 +9,8 @@ import {
|
|
|
9
9
|
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
|
|
10
10
|
import { colors } from "./colors";
|
|
11
11
|
import { Text } from "./text";
|
|
12
|
-
import { Icon } from "./icon";
|
|
13
12
|
import { TextInputField } from "./text_input_field";
|
|
14
|
-
import {
|
|
13
|
+
import { MenuListItem } from "./menu_list_item";
|
|
15
14
|
import { ActivityIndicator } from "./activity_indicator";
|
|
16
15
|
import { Popover, PopoverContent } from "./popover";
|
|
17
16
|
import type { PickerOption } from "./picker";
|
|
@@ -30,13 +29,20 @@ export interface SearchSelectProps<T extends string = string, D = unknown> {
|
|
|
30
29
|
/** Shown — under a heading — when the box is focused but empty (e.g. the
|
|
31
30
|
* output of `useRecents`). Omit for no recents. */
|
|
32
31
|
recentOptions?: PickerOption<T, D>[];
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
/** Row subtitle, read from the option's `data` payload. The row's structure
|
|
33
|
+
* and height are owned by the component (a MenuListItem) so every result
|
|
34
|
+
* reads consistently — consumers supply data, not markup. */
|
|
35
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
36
|
+
/** Leading accessory (e.g. a status dot / avatar). */
|
|
37
|
+
renderOptionIcon?: (option: PickerOption<T, D>) => ReactNode;
|
|
38
|
+
/** Trailing accessory (e.g. a `Badge`). */
|
|
39
|
+
renderOptionRight?: (option: PickerOption<T, D>) => ReactNode;
|
|
40
|
+
/** Marks the matching row as the current selection (a highlight). The box
|
|
41
|
+
* itself stays a search affordance — the selection is shown by the host. */
|
|
38
42
|
selectedValue?: T | null;
|
|
39
|
-
/**
|
|
43
|
+
/** True only on the initial load (no results yet) — show a spinner. During
|
|
44
|
+
* revalidation / typing, pass the query's (SWR-style) `loading`, which stays
|
|
45
|
+
* false while previous rows are kept, so the list never blanks. */
|
|
40
46
|
loading?: boolean;
|
|
41
47
|
/** Debounce for `onSearchChange`, in ms. Default 200. */
|
|
42
48
|
searchDebounceMs?: number;
|
|
@@ -53,14 +59,15 @@ export interface SearchSelectProps<T extends string = string, D = unknown> {
|
|
|
53
59
|
style?: StyleProp<ViewStyle>;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
// Approximate row height (MenuListItem: title + description) for scroll-into-view.
|
|
63
|
+
const OPTION_HEIGHT = 52;
|
|
57
64
|
|
|
58
65
|
/**
|
|
59
|
-
* Search-first combobox: an always-visible search
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* fires `onValueChange` and clears the box for the next search —
|
|
63
|
-
* itself is rendered by the host (a summary above, details below).
|
|
66
|
+
* Search-first combobox: an always-visible white search panel whose typing
|
|
67
|
+
* drives a debounced, server-side search; results drop into a Popover listbox
|
|
68
|
+
* of MenuListItem rows below. Focused while empty, it offers `recentOptions`.
|
|
69
|
+
* Picking a row fires `onValueChange` and clears the box for the next search —
|
|
70
|
+
* the selection itself is rendered by the host (a summary above, details below).
|
|
64
71
|
*
|
|
65
72
|
* Focus stays on the input the whole time (Popover `manageFocus={false}`); the
|
|
66
73
|
* input owns the keyboard (↑/↓ move the active row via `aria-activedescendant`,
|
|
@@ -74,7 +81,9 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
74
81
|
onSearchChange,
|
|
75
82
|
onValueChange,
|
|
76
83
|
recentOptions,
|
|
77
|
-
|
|
84
|
+
getOptionDescription,
|
|
85
|
+
renderOptionIcon,
|
|
86
|
+
renderOptionRight,
|
|
78
87
|
selectedValue = null,
|
|
79
88
|
loading = false,
|
|
80
89
|
searchDebounceMs = 200,
|
|
@@ -185,6 +194,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
185
194
|
autoFocus={autoFocus}
|
|
186
195
|
autoCapitalize="none"
|
|
187
196
|
autoCorrect={false}
|
|
197
|
+
style={styles.input}
|
|
188
198
|
role="combobox"
|
|
189
199
|
aria-expanded={showList}
|
|
190
200
|
aria-controls={listboxId}
|
|
@@ -224,7 +234,7 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
224
234
|
// react-native-web forwards it verbatim for assistive tech.
|
|
225
235
|
role={"listbox" as "list"}
|
|
226
236
|
>
|
|
227
|
-
{loading
|
|
237
|
+
{loading ? (
|
|
228
238
|
<View style={styles.statusRow}>
|
|
229
239
|
<ActivityIndicator />
|
|
230
240
|
</View>
|
|
@@ -235,37 +245,23 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
235
245
|
</Text>
|
|
236
246
|
</View>
|
|
237
247
|
) : (
|
|
238
|
-
list.map((opt, i) =>
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
accessibilityLabel={opt.label ?? opt.value}
|
|
256
|
-
focused={i === activeIndex}
|
|
257
|
-
selected={isSelected}
|
|
258
|
-
right={
|
|
259
|
-
isSelected ? (
|
|
260
|
-
<Icon name="check" size={18} color={colors.zinc["950"]} />
|
|
261
|
-
) : undefined
|
|
262
|
-
}
|
|
263
|
-
disabled={opt.disabled}
|
|
264
|
-
onPress={() => handleSelect(i)}
|
|
265
|
-
onHoverIn={() => setActiveIndex(i)}
|
|
266
|
-
/>
|
|
267
|
-
);
|
|
268
|
-
})
|
|
248
|
+
list.map((opt, i) => (
|
|
249
|
+
<MenuListItem
|
|
250
|
+
key={opt.value}
|
|
251
|
+
nativeID={optionId(i)}
|
|
252
|
+
testID={`search-option-${opt.value}`}
|
|
253
|
+
role="option"
|
|
254
|
+
icon={renderOptionIcon?.(opt)}
|
|
255
|
+
title={opt.label ?? opt.value}
|
|
256
|
+
description={getOptionDescription?.(opt)}
|
|
257
|
+
right={renderOptionRight?.(opt)}
|
|
258
|
+
focused={i === activeIndex}
|
|
259
|
+
selected={opt.value === selectedValue}
|
|
260
|
+
disabled={opt.disabled}
|
|
261
|
+
onPress={() => handleSelect(i)}
|
|
262
|
+
onHoverIn={() => setActiveIndex(i)}
|
|
263
|
+
/>
|
|
264
|
+
))
|
|
269
265
|
)}
|
|
270
266
|
</ScrollView>
|
|
271
267
|
</View>
|
|
@@ -276,6 +272,10 @@ export function SearchSelect<T extends string = string, D = unknown>(
|
|
|
276
272
|
}
|
|
277
273
|
|
|
278
274
|
const styles = StyleSheet.create({
|
|
275
|
+
input: {
|
|
276
|
+
backgroundColor: colors.background,
|
|
277
|
+
boxShadow: colors.border_shadow,
|
|
278
|
+
},
|
|
279
279
|
menu: {
|
|
280
280
|
gap: 2,
|
|
281
281
|
},
|