@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -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 { ref, icon, title, description, right, onPress, disabled, style, testID } = props;
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 = [styles.container, style];
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
- accessibilityRole="button"
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
  });
@@ -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 { MenuButton } from "./menu_button";
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
- /** Render a rich row from the option (use its `data` payload). Falls back to
34
- * `option.label`. */
35
- renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
36
- /** Marks the matching row as the current selection (check + highlight). The
37
- * box itself stays a search affordance the selection is shown by the host. */
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
- /** Show a spinner while results are in flight. */
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
- const OPTION_HEIGHT = 44;
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 input (the "panel") whose
60
- * typing drives a debounced, server-side search; results drop into a Popover
61
- * listbox below. Focused while empty, it offers `recentOptions`. Picking a row
62
- * fires `onValueChange` and clears the box for the next search — the selection
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
- renderOptionContent,
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 && searching ? (
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
- const isSelected = opt.value === selectedValue;
240
- return (
241
- <MenuButton
242
- key={opt.value}
243
- nativeID={optionId(i)}
244
- testID={`search-option-${opt.value}`}
245
- role="option"
246
- title={
247
- renderOptionContent ? (
248
- renderOptionContent(opt)
249
- ) : (
250
- <Text userSelect="none" numberOfLines={1}>
251
- {opt.label ?? opt.value}
252
- </Text>
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
  },