@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.
Files changed (104) hide show
  1. package/AGENTS.md +184 -56
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_convert.tsx +11 -23
  4. package/examples/tpl_dossier.tsx +5 -4
  5. package/examples/tpl_order.tsx +1 -1
  6. package/examples/tpl_pipeline.tsx +400 -0
  7. package/examples/tpl_ratedesk.tsx +1 -1
  8. package/examples/tpl_record.tsx +6 -5
  9. package/examples/tpl_shifts.tsx +7 -5
  10. package/examples/tpl_task_board.tsx +473 -0
  11. package/examples/tpl_tasks.tsx +440 -0
  12. package/package.json +19 -6
  13. package/src/accordion.tsx +1 -1
  14. package/src/agent_progress.tsx +1 -1
  15. package/src/agent_run.tsx +1 -1
  16. package/src/breakdown.tsx +2 -2
  17. package/src/button.tsx +12 -3
  18. package/src/calendar/calendar_view.tsx +10 -10
  19. package/src/calendar/month_view.tsx +30 -11
  20. package/src/calendar/time_grid_view.tsx +25 -7
  21. package/src/card_select_item.tsx +11 -13
  22. package/src/check_circle.tsx +88 -0
  23. package/src/checkbox_input.tsx +5 -1
  24. package/src/chip.tsx +1 -1
  25. package/src/chip_group.tsx +1 -1
  26. package/src/column_filter.tsx +3 -2
  27. package/src/combobox.tsx +84 -219
  28. package/src/confidence.tsx +14 -3
  29. package/src/control_surface.ts +22 -7
  30. package/src/data_grid.tsx +159 -0
  31. package/src/date_calendar.tsx +46 -23
  32. package/src/date_field.tsx +10 -2
  33. package/src/date_filter.tsx +5 -9
  34. package/src/date_picker.tsx +78 -26
  35. package/src/date_picker_value.ts +11 -0
  36. package/src/date_range_filter_field.tsx +6 -4
  37. package/src/drawer.tsx +1 -1
  38. package/src/file_dropzone.tsx +5 -0
  39. package/src/file_gallery_modal.tsx +227 -139
  40. package/src/file_grid.tsx +184 -0
  41. package/src/file_preview.web.tsx +349 -12
  42. package/src/file_preview_types.ts +49 -0
  43. package/src/file_row.tsx +118 -0
  44. package/src/file_rows.tsx +109 -0
  45. package/src/file_thumbnail.tsx +132 -81
  46. package/src/file_thumbnail_grid.tsx +135 -55
  47. package/src/files_editor.tsx +232 -0
  48. package/src/filter_chip.tsx +25 -12
  49. package/src/focus_ring_pressable.tsx +34 -0
  50. package/src/form_picker.tsx +3 -3
  51. package/src/gantt/gantt_view.tsx +25 -7
  52. package/src/heatmap.tsx +5 -1
  53. package/src/icon_button.tsx +14 -3
  54. package/src/image_gallery.tsx +8 -14
  55. package/src/index.css +8 -12
  56. package/src/inline_date_picker.tsx +11 -11
  57. package/src/inline_edit.tsx +22 -6
  58. package/src/inline_member_select.tsx +1 -1
  59. package/src/inline_select.tsx +9 -8
  60. package/src/inline_text_input.tsx +4 -1
  61. package/src/link.tsx +11 -1
  62. package/src/link_button.tsx +1 -1
  63. package/src/list_item.tsx +2 -2
  64. package/src/member_select.tsx +9 -4
  65. package/src/menu_button.tsx +2 -2
  66. package/src/menu_list_item.tsx +2 -2
  67. package/src/number_input.tsx +11 -4
  68. package/src/option_list.tsx +211 -0
  69. package/src/pdfjs_worker.d.ts +5 -0
  70. package/src/peek.tsx +1 -1
  71. package/src/picker.tsx +24 -213
  72. package/src/pressable_highlight.tsx +42 -23
  73. package/src/radio_picker.tsx +2 -2
  74. package/src/range_slider.tsx +35 -7
  75. package/src/react_native.d.ts +3 -0
  76. package/src/scroll_to_bottom.tsx +1 -1
  77. package/src/section.tsx +7 -2
  78. package/src/segmented_control.tsx +2 -2
  79. package/src/select.tsx +299 -0
  80. package/src/sort_header.tsx +1 -1
  81. package/src/sources.tsx +1 -1
  82. package/src/spreadsheet_view.tsx +43 -17
  83. package/src/status_grid.tsx +4 -3
  84. package/src/stepper.tsx +1 -1
  85. package/src/switch.tsx +5 -1
  86. package/src/switch_button.tsx +1 -1
  87. package/src/switcher.tsx +1 -1
  88. package/src/table.tsx +5 -1
  89. package/src/tabs.tsx +2 -2
  90. package/src/text_input_field.tsx +15 -4
  91. package/src/time_picker.tsx +11 -4
  92. package/src/timeline.tsx +1 -1
  93. package/src/uploading_thumbnail.tsx +212 -0
  94. package/src/use_focus_ring.ts +80 -0
  95. package/src/use_hover.ts +26 -0
  96. package/src/use_list_keyboard_nav.test.ts +71 -0
  97. package/src/use_list_keyboard_nav.ts +52 -5
  98. package/src/use_option_list.test.ts +193 -0
  99. package/src/use_option_list.ts +354 -0
  100. package/src/command_menu.tsx +0 -205
  101. package/src/hover_action.tsx +0 -54
  102. package/src/picker_menu.tsx +0 -355
  103. package/src/tag_input.tsx +0 -203
  104. package/src/time_field.tsx +0 -297
package/src/combobox.tsx CHANGED
@@ -2,14 +2,13 @@ import {
2
2
  View,
3
3
  ScrollView,
4
4
  StyleSheet,
5
- Pressable,
6
5
  type StyleProp,
7
6
  type ViewStyle,
8
7
  type TextInput as RNTextInput,
9
8
  } from "react-native";
10
- import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
9
+ import { useCallback, useRef, useState, type ReactNode } from "react";
11
10
  import { colors } from "./colors";
12
- import { ACTIVE_RING, CONTROL_RADIUS } from "./control_surface";
11
+ import { FOCUS_RING } from "./control_surface";
13
12
  import { Text } from "./text";
14
13
  import { Icon, type IconName } from "./icon";
15
14
  import { TextInputField } from "./text_input_field";
@@ -18,7 +17,7 @@ import { ActivityIndicator } from "./activity_indicator";
18
17
  import { Popover, PopoverContent } from "./popover";
19
18
  import type { PickerOption } from "./picker";
20
19
  import { useDebouncedCallback } from "./use_debounced_callback";
21
- import { useListKeyboardNav } from "./use_list_keyboard_nav";
20
+ import { useOptionList } from "./use_option_list";
22
21
 
23
22
  export interface ComboboxProps<T extends string = string, D = unknown> {
24
23
  /** Result options for the current query. With `onSearchChange` the consumer
@@ -29,23 +28,17 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
29
28
  /** Server-side search: debounced, fires as the user types. Omit for local
30
29
  * label filtering of `options`. */
31
30
  onSearchChange?: (query: string) => void;
32
- /** Multi-select: selected options render as dismissible chips inside the input;
33
- * the input stays a search field. Pair with `value` (the selected array) and
34
- * `onRemove`. */
35
- multi?: boolean;
36
- /** Selected option(s). Single the option to reflect in the input (or null).
37
- * Multi the array of selected options rendered as chips. */
38
- value?: PickerOption<T, D> | PickerOption<T, D>[] | null;
39
- /** Multi: remove a selected chip. */
40
- onRemove?: (option: PickerOption<T, D>) => void;
41
- /** Single only: reflect the picked label in the input (classic autocomplete).
42
- * Default true. Set false to keep the input a pure search and render the
43
- * selection yourself (e.g. in a list below). */
31
+ /** The selected option to reflect in the input (or null). */
32
+ value?: PickerOption<T, D> | null;
33
+ /** Reflect the picked label in the input (classic autocomplete). Default true.
34
+ * Set false to keep the input a pure search and render the selection yourself
35
+ * (e.g. in a list below). For a multi-value CHIP field use `Select multi`
36
+ * (its `renderSelected` returns a removable `Chip`). */
44
37
  reflectSelection?: boolean;
45
38
  /** Custom rendering of an option row in the dropdown. The input itself is a
46
39
  * plain text field (it can only show the label string) — to display a
47
40
  * selection with custom rendering, use `reflectSelection={false}` and render
48
- * the picked option(s) yourself below. */
41
+ * the picked option yourself below. */
49
42
  renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
50
43
  /** Row subtitle pulled from the option (single-line, under the title). */
51
44
  getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
@@ -68,16 +61,16 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
68
61
  /** Leading icon inside the input. OMIT (the default) for a SELECT — no leading
69
62
  * glyph, a trailing chevron — so a "pick a code/record" combobox reads as a
70
63
  * picker, not a free-text search box. Opt IN to the search-box look explicitly
71
- * with `icon="search"` (leading glyph, no chevron). Multi-select shows neither. */
64
+ * with `icon="search"` (leading glyph, no chevron). */
72
65
  icon?: IconName;
73
66
  placeholder?: string;
74
67
  recentsLabel?: string;
75
68
  emptyText?: string;
76
69
  accessibilityLabel?: string;
77
70
  disabled?: boolean;
78
- /** Single-select only: show an in-input clear ✕ whenever the input has text
79
- * (a reflected selection or a typed query). Pressing it empties the input,
80
- * resets the search, and fires `onClear` so the consumer can deselect. */
71
+ /** Show an in-input clear ✕ whenever the input has text (a reflected selection
72
+ * or a typed query). Pressing it empties the input, resets the search, and
73
+ * fires `onClear` so the consumer can deselect. */
81
74
  clearable?: boolean;
82
75
  /** Fired when the clear ✕ is pressed (the input + search are reset first). */
83
76
  onClear?: () => void;
@@ -88,31 +81,24 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
88
81
  style?: StyleProp<ViewStyle>;
89
82
  }
90
83
 
91
- const OPTION_HEIGHT = 52;
92
- const CUSTOM_VALUE = "__combobox_custom__";
93
-
94
84
  /**
95
85
  * The ARIA combobox: an editable input whose typing drives a (debounced) search,
96
86
  * with results in a Popover listbox below. The input owns the keyboard (↑/↓ move
97
- * the active row, Enter selects, Esc closes) — not a button trigger.
98
- *
99
- * Selection display is flexible:
100
- * - single (default): the picked label reflects into the input (`reflectSelection`),
101
- * or set `reflectSelection={false}` to keep the input a pure search and render
102
- * the selection below yourself;
103
- * - `multi`: selected options render as dismissible chips inside the input and
104
- * the input stays a search field.
87
+ * the active row, Enter selects, Esc closes) — not a button trigger; on an empty
88
+ * field it browses the full set (or `recentOptions`). Shares the `useOptionList`
89
+ * row model + nav with the rest of the kit's selectors.
105
90
  *
106
- * For a "select from a known list" control (no search box, typeahead), use `Picker`.
91
+ * Selection display: the picked label reflects into the input (`reflectSelection`),
92
+ * or set `reflectSelection={false}` to keep the input a pure search and render the
93
+ * selection below yourself. For a "pick from a known list" control use `Picker`
94
+ * (native, plain) or `Select` (rich/multi, or a chip box via `renderSelected`).
107
95
  */
108
96
  export function Combobox<T extends string = string, D = unknown>(props: ComboboxProps<T, D>) {
109
97
  const {
110
98
  options,
111
99
  onValueChange,
112
100
  onSearchChange,
113
- multi = false,
114
101
  value,
115
- onRemove,
116
102
  reflectSelection = true,
117
103
  renderOptionContent,
118
104
  getOptionDescription,
@@ -136,66 +122,34 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
136
122
  style,
137
123
  } = props;
138
124
 
139
- const chips = multi && Array.isArray(value) ? value : [];
140
- const single = !multi && value && !Array.isArray(value) ? value : null;
125
+ const single = value ?? null;
126
+ const reflectedText = single ? (single.label ?? single.value) : "";
141
127
 
142
- const [query, setQuery] = useState(
143
- !multi && reflectSelection && single ? (single.label ?? single.value) : "",
144
- );
128
+ const [query, setQuery] = useState(reflectSelection && single ? reflectedText : "");
145
129
  const [open, setOpen] = useState(autoFocus);
146
130
  const triggerRef = useRef<View>(null);
147
131
  const inputRef = useRef<RNTextInput>(null);
148
- const scrollRef = useRef<ScrollView>(null);
149
132
  // Options are focusable (RN-Web Pressable), so a mouse-press on one blurs the
150
133
  // input; commit then refocuses it to keep the textbox focus (aria pattern),
151
134
  // which would re-fire onFocus and reopen the popover. This one-shot flag lets
152
- // that programmatic refocus pass without reopening — set on a single-select
153
- // commit, consumed by the next onFocus, and cleared on blur (the keyboard
154
- // path never blurs, so the refocus is a no-op and onFocus never fires).
135
+ // that programmatic refocus pass without reopening — set on commit, consumed by
136
+ // the next onFocus, and cleared on blur (the keyboard path never blurs, so the
137
+ // refocus is a no-op and onFocus never fires).
155
138
  const suppressFocusOpenRef = useRef(false);
156
- const baseId = useId();
157
- const listboxId = `${baseId}-listbox`;
158
- const optionId = (i: number) => `${baseId}-option-${i}`;
159
139
 
160
- const searching = query.trim().length > 0;
161
140
  const isServer = onSearchChange !== undefined;
162
-
163
- // Local filtering when not server-driven. Reflected single value shouldn't
164
- // filter the list to itself, so only filter once the query diverges.
165
- const filtered = useMemo(() => {
166
- if (isServer || !searching) return options;
167
- if (!multi && reflectSelection && single && query === (single.label ?? single.value)) {
168
- return options;
169
- }
170
- const q = query.toLowerCase();
171
- return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q));
172
- }, [isServer, searching, options, multi, reflectSelection, single, query]);
173
-
174
- // Free-entry row, appended when the query matches no option exactly. Match
175
- // both label AND value — a query equal to an option's value (e.g. a code typed
176
- // in full) must NOT also offer "Add <code>", or the two share a key.
177
- const customRow = useMemo((): PickerOption<T, D> | null => {
178
- if (!allowCustom || !searching) return null;
179
- const q = query.trim();
180
- const ql = q.toLowerCase();
181
- const exact = options.some((o) => (o.label ?? o.value).toLowerCase() === ql || String(o.value).toLowerCase() === ql);
182
- if (exact) return null;
183
- const label = customOptionLabel ? customOptionLabel(q) : `Add "${q}"`;
184
- if (label === null) return null;
185
- return { value: q as T, label, testID: CUSTOM_VALUE };
186
- }, [allowCustom, searching, query, options, customOptionLabel]);
187
-
188
- const list = useMemo(() => {
189
- const base = searching ? filtered : (recentOptions ?? []);
190
- if (!customRow) return base;
191
- return customOptionPlacement === "top" ? [customRow, ...base] : [...base, customRow];
192
- }, [searching, filtered, recentOptions, customRow, customOptionPlacement]);
193
-
194
141
  const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
195
142
 
196
- const scrollToIndex = useCallback((index: number) => {
197
- scrollRef.current?.scrollTo({ y: Math.max(0, index * OPTION_HEIGHT - 80), animated: false });
198
- }, []);
143
+ // The displayed text equals the reflected selection until the user types — show
144
+ // ALL options then (browse), not the list filtered to the selected label.
145
+ const filterQuery = reflectSelection && single && query === reflectedText ? "" : query;
146
+
147
+ const findOption = useCallback(
148
+ (val: T): PickerOption<T, D> =>
149
+ options.find((o) => o.value === val) ??
150
+ recentOptions?.find((o) => o.value === val) ?? { value: val, label: val },
151
+ [options, recentOptions],
152
+ );
199
153
 
200
154
  const commit = useCallback(
201
155
  (opt: PickerOption<T, D>) => {
@@ -204,59 +158,28 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
204
158
  debouncedSearch.cancel();
205
159
  onSearchChange?.("");
206
160
  }
207
- if (multi) {
208
- // Input stays a search field; the chip is rendered from `value`.
209
- setQuery("");
210
- } else if (reflectSelection) {
211
- setQuery(opt.label ?? opt.value);
212
- } else {
213
- setQuery("");
214
- }
161
+ setQuery(reflectSelection ? (opt.label ?? opt.value) : "");
215
162
  setOpen(false);
216
- // Single-select: the refocus below must not reopen the just-closed menu.
217
- // Multi-select keeps the menu open to add more, so it isn't suppressed.
218
- if (!multi) suppressFocusOpenRef.current = true;
163
+ // The refocus below must not reopen the just-closed menu.
164
+ suppressFocusOpenRef.current = true;
219
165
  inputRef.current?.focus();
220
166
  },
221
- [onValueChange, isServer, debouncedSearch, onSearchChange, multi, reflectSelection],
167
+ [onValueChange, isServer, debouncedSearch, onSearchChange, reflectSelection],
222
168
  );
223
169
 
224
- const handleSelect = useCallback(
225
- (index: number) => {
226
- const opt = list[index];
227
- if (!opt || opt.disabled) return;
228
- // The custom row displays `Add "x"` but emits the raw query as both value
229
- // and label (so it reflects/round-trips as the typed text, not "Add …").
230
- const emitted =
231
- opt.testID === CUSTOM_VALUE
232
- ? ({ value: opt.value, label: opt.value } as PickerOption<T, D>)
233
- : opt;
234
- commit(emitted);
235
- },
236
- [list, commit],
237
- );
238
-
239
- const { activeIndex, setActiveIndex, handleKey } = useListKeyboardNav({
240
- count: list.length,
241
- isDisabled: (i) => list[i]?.disabled ?? false,
242
- onSelect: handleSelect,
243
- onClose: () => setOpen(false),
244
- onActiveChange: scrollToIndex,
170
+ const list = useOptionList<T, false, D>({
171
+ options,
172
+ value: single?.value ?? null,
173
+ onValueChange: (val) => commit(findOption(val)),
174
+ onCustomCommit: (q) => commit({ value: q as T, label: q }),
175
+ onRequestClose: () => setOpen(false),
176
+ search: { mode: "controlled", query: filterQuery, serverFiltered: isServer },
177
+ recentOptions,
178
+ allowCustom,
179
+ customOptionLabel,
180
+ customOptionPlacement,
245
181
  });
246
182
 
247
- // Default the keyboard highlight to the first real (non-custom, enabled)
248
- // option, so a free-entry "Create…" row — wherever it sits — is never the
249
- // Enter target while a match exists (Enter on a partial picks the match, not
250
- // a duplicate). Only when the custom row is the sole option does it lead.
251
- const firstValue = list[0]?.value;
252
- const defaultIndex = useMemo(() => {
253
- const i = list.findIndex((o) => o.testID !== CUSTOM_VALUE && !o.disabled);
254
- return i >= 0 ? i : 0;
255
- }, [list]);
256
- useEffect(() => {
257
- setActiveIndex(defaultIndex);
258
- }, [searching, list.length, firstValue, defaultIndex, setActiveIndex]);
259
-
260
183
  const handleChangeText = useCallback(
261
184
  (text: string) => {
262
185
  setQuery(text);
@@ -269,57 +192,34 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
269
192
  const handleKeyPress = useCallback(
270
193
  (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
271
194
  const key = e.nativeEvent.key;
272
- // Backspace on an empty query removes the last chip (token-input idiom).
273
- if (multi && key === "Backspace" && query.length === 0 && chips.length > 0) {
274
- onRemove?.(chips[chips.length - 1]);
275
- return;
276
- }
277
195
  if (!open && (key === "ArrowDown" || key === "ArrowUp")) setOpen(true);
278
- if (handleKey(key)) e.preventDefault();
196
+ if (list.handleKey(key)) e.preventDefault();
279
197
  },
280
- [multi, query, chips, open, handleKey, onRemove],
198
+ [open, list],
281
199
  );
282
200
 
283
- const showList = open && !disabled && (searching || list.length > 0);
284
- const activeOptionId =
285
- activeIndex >= 0 && activeIndex < list.length ? optionId(activeIndex) : undefined;
201
+ const showList = open && !disabled && (list.searching || list.rows.length > 0);
202
+ const showRecentsHeader = !list.searching && (recentOptions?.length ?? 0) > 0;
286
203
  // Select mode (no leading icon): show a trailing chevron so the field reads as
287
204
  // a picker, not a free-text search. The clear ✕ owns the right slot when
288
205
  // present, so suppress the chevron then.
289
- const showChevron = !multi && !icon && !clearable;
206
+ const showChevron = !icon && !clearable;
290
207
 
291
208
  return (
292
209
  <View style={style}>
293
- <View ref={triggerRef} style={multi ? [styles.multiBox, showList && styles.openRing] : undefined}>
294
- {multi
295
- ? chips.map((chip) => (
296
- <View key={chip.value} style={styles.chip}>
297
- <Text size="sm" numberOfLines={1}>
298
- {chip.label ?? chip.value}
299
- </Text>
300
- <Pressable
301
- accessibilityRole="button"
302
- accessibilityLabel={`Remove ${chip.label ?? chip.value}`}
303
- onPress={() => onRemove?.(chip)}
304
- style={styles.chipRemove}
305
- >
306
- <Icon name="x" size={12} color={colors.zinc["500"]} />
307
- </Pressable>
308
- </View>
309
- ))
310
- : null}
210
+ <View ref={triggerRef}>
311
211
  <TextInputField
312
212
  ref={inputRef}
313
213
  testID={testID}
314
- icon={multi ? undefined : icon}
214
+ icon={icon}
315
215
  value={query}
316
- clearable={!multi && clearable}
216
+ clearable={clearable}
317
217
  clearLabel={clearLabel}
318
218
  onClear={onClear}
319
219
  onChangeText={handleChangeText}
320
220
  onFocus={() => {
321
- // A programmatic refocus right after a single-select commit must not
322
- // reopen the menu; consume the one-shot flag instead of opening.
221
+ // A programmatic refocus right after a commit must not reopen the
222
+ // menu; consume the one-shot flag instead of opening.
323
223
  if (suppressFocusOpenRef.current) {
324
224
  suppressFocusOpenRef.current = false;
325
225
  return;
@@ -332,26 +232,24 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
332
232
  suppressFocusOpenRef.current = false;
333
233
  }}
334
234
  onKeyPress={handleKeyPress}
335
- placeholder={chips.length > 0 ? undefined : placeholder}
235
+ placeholder={placeholder}
336
236
  placeholderTextColor={colors.zinc["400"]}
337
237
  editable={!disabled}
338
238
  autoFocus={autoFocus}
339
239
  autoCapitalize="none"
340
240
  autoCorrect={false}
341
241
  style={[
342
- multi ? styles.multiInput : showChevron ? styles.selectInput : undefined,
343
- // Single-select wears the open ring on the input (which holds the
344
- // border); multi wears it on the wrapper above (the input is borderless).
242
+ showChevron ? styles.selectInput : undefined,
345
243
  // Gate on `showList`, not `open`: an empty query with no options has
346
244
  // `open=true` but renders NO dropdown, so a ring tied to `open` would
347
245
  // show with nothing below it AND linger after blur (no popover fires a
348
246
  // close). The ring must mean "a dropdown is up".
349
- !multi && showList ? styles.openRing : undefined,
247
+ showList ? styles.openRing : undefined,
350
248
  ]}
351
249
  role="combobox"
352
250
  aria-expanded={showList}
353
- aria-controls={listboxId}
354
- aria-activedescendant={activeOptionId}
251
+ aria-controls={list.listboxId}
252
+ aria-activedescendant={showList ? list.activeId : undefined}
355
253
  aria-autocomplete="list"
356
254
  />
357
255
  {showChevron ? (
@@ -365,7 +263,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
365
263
  onOpenChange={(next) => {
366
264
  setOpen(next);
367
265
  // An outside dismiss closes the menu but leaves the textbox focused, so
368
- // a keyboard `:focus-visible` ring would linger. Drop focus when the
266
+ // the input's keyboard focus ring would linger. Drop focus when the
369
267
  // popover closes ITSELF — a commit refocuses through its own path
370
268
  // (`commit` sets `open` directly), never through `onOpenChange`.
371
269
  if (!next) inputRef.current?.blur();
@@ -378,7 +276,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
378
276
  >
379
277
  <PopoverContent manageFocus={false} disableBodyScroll testID={testID ? `${testID}-popover` : undefined}>
380
278
  <View style={styles.menu}>
381
- {!searching && (recentOptions?.length ?? 0) > 0 ? (
279
+ {showRecentsHeader ? (
382
280
  <View style={styles.sectionHeader}>
383
281
  <Text size="xs" weight="medium" color="zinc-500">
384
282
  {recentsLabel}
@@ -386,10 +284,10 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
386
284
  </View>
387
285
  ) : null}
388
286
  <ScrollView
389
- ref={scrollRef}
287
+ ref={list.scrollRef}
390
288
  style={styles.scroll}
391
- nativeID={listboxId}
392
- accessibilityLabel={searching ? accessibilityLabel : recentsLabel}
289
+ nativeID={list.listboxId}
290
+ accessibilityLabel={list.searching ? accessibilityLabel : recentsLabel}
393
291
  keyboardShouldPersistTaps="handled"
394
292
  role={"listbox" as "list"}
395
293
  >
@@ -397,8 +295,8 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
397
295
  <View style={styles.statusRow}>
398
296
  <ActivityIndicator />
399
297
  </View>
400
- ) : list.length === 0 ? (
401
- searching ? (
298
+ ) : list.rows.length === 0 ? (
299
+ list.searching ? (
402
300
  <View style={styles.statusRow}>
403
301
  <Text size="sm" color="zinc-500">
404
302
  {emptyText}
@@ -406,8 +304,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
406
304
  </View>
407
305
  ) : null
408
306
  ) : (
409
- list.map((opt, i) => {
410
- const isCustom = opt.testID === CUSTOM_VALUE;
307
+ list.rows.map((row) => {
308
+ const isCustom = row.kind === "custom";
309
+ const opt = row.option;
411
310
  const desc = !isCustom ? getOptionDescription?.(opt) : undefined;
412
311
  const label = opt.label ?? opt.value;
413
312
  const title =
@@ -429,18 +328,18 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
429
328
  );
430
329
  return (
431
330
  <MenuButton
432
- key={opt.value}
433
- nativeID={optionId(i)}
331
+ key={`${row.kind}-${opt.value}`}
332
+ nativeID={row.nativeID}
434
333
  testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
435
334
  role="option"
436
335
  accessibilityLabel={label}
437
336
  icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
438
337
  title={title}
439
- focused={i === activeIndex}
440
- selected={!multi && !isCustom && single?.value === opt.value}
338
+ focused={row.index === list.activeIndex}
339
+ selected={row.selected}
441
340
  disabled={opt.disabled}
442
- onPress={() => handleSelect(i)}
443
- onHoverIn={() => setActiveIndex(i, false)}
341
+ onPress={() => list.pickRow(row.index)}
342
+ onHoverIn={() => list.setActiveIndex(row.index, false)}
444
343
  />
445
344
  );
446
345
  })
@@ -454,26 +353,8 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
454
353
  }
455
354
 
456
355
  const styles = StyleSheet.create({
457
- multiBox: {
458
- flexDirection: "row",
459
- flexWrap: "wrap",
460
- alignItems: "center",
461
- gap: 4,
462
- paddingHorizontal: 6,
463
- paddingVertical: 4,
464
- minHeight: 40,
465
- borderWidth: 1,
466
- borderColor: colors.border,
467
- borderRadius: CONTROL_RADIUS,
468
- },
469
- multiInput: {
470
- flexGrow: 1,
471
- flexBasis: 80,
472
- borderWidth: 0,
473
- height: 28,
474
- },
475
356
  openRing: {
476
- boxShadow: ACTIVE_RING,
357
+ boxShadow: FOCUS_RING,
477
358
  },
478
359
  // Select mode: room on the right for the trailing chevron.
479
360
  selectInput: {
@@ -487,22 +368,6 @@ const styles = StyleSheet.create({
487
368
  justifyContent: "center",
488
369
  pointerEvents: "none",
489
370
  },
490
-
491
- chip: {
492
- flexDirection: "row",
493
- alignItems: "center",
494
- gap: 4,
495
- paddingLeft: 8,
496
- paddingRight: 4,
497
- paddingVertical: 2,
498
- borderRadius: 6,
499
- backgroundColor: colors.zinc["100"],
500
- },
501
- chipRemove: {
502
- padding: 2,
503
- borderRadius: 4,
504
- cursor: "pointer",
505
- },
506
371
  menu: {
507
372
  gap: 2,
508
373
  },
@@ -4,13 +4,23 @@ import { Text } from "./text";
4
4
 
5
5
  export type ConfidenceLevel = "high" | "medium" | "low";
6
6
 
7
+ export interface ConfidenceLabels {
8
+ /** The full level phrase — shown beside the meter and read out. Default "High confidence" etc.,
9
+ * so the meter reads as what it is, not a bare "High". One phrase per level (translates cleanly). */
10
+ high: string;
11
+ medium: string;
12
+ low: string;
13
+ }
14
+
7
15
  export interface ConfidenceProps {
8
16
  /** Pass a level directly, or a 0–1 `score` (≥0.8 high · ≥0.5 medium · else low). */
9
17
  level?: ConfidenceLevel;
10
18
  score?: number;
19
+ /** Translated level phrases. Defaults to English ("High confidence" …). */
20
+ labels?: Partial<ConfidenceLabels>;
11
21
  }
12
22
 
13
- const LABEL: Record<ConfidenceLevel, string> = { high: "High", medium: "Medium", low: "Low" };
23
+ const DEFAULT_LABELS: ConfidenceLabels = { high: "High confidence", medium: "Medium confidence", low: "Low confidence" };
14
24
  const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
15
25
  // high emerald, medium amber, low zinc (unsure — not an error). One family per
16
26
  // level; the fill count AND the colour both carry it.
@@ -30,17 +40,18 @@ export function levelFromScore(score: number): ConfidenceLevel {
30
40
  */
31
41
  export function Confidence(props: ConfidenceProps) {
32
42
  const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
43
+ const l = { ...DEFAULT_LABELS, ...props.labels };
33
44
  const filled = FILLED[level];
34
45
  const fill = solid(COLOR[level]);
35
46
  return (
36
- <View style={styles.row} accessibilityLabel={`${LABEL[level]} confidence`}>
47
+ <View style={styles.row} accessibilityLabel={l[level]}>
37
48
  <View style={styles.meter}>
38
49
  {[0, 1, 2].map((i) => (
39
50
  <View key={i} style={[styles.tick, { backgroundColor: i < filled ? fill : colors.zinc[200] }]} />
40
51
  ))}
41
52
  </View>
42
53
  <Text size="xs" weight="medium" style={{ color: fill }}>
43
- {LABEL[level]}
54
+ {l[level]}
44
55
  </Text>
45
56
  </View>
46
57
  );
@@ -16,13 +16,28 @@ export const CONTROL_HEIGHT = 40;
16
16
  * (avatars, dots, switches, sliders, progress, icon buttons) stay full. */
17
17
  export const CONTROL_RADIUS = 10;
18
18
 
19
- /** The active/open edge for an interactive control — a 2px zinc-900 ring flush
20
- * against the box (offset 0), MIRRORING the keyboard `:focus-visible` outline
21
- * (index.css). A mouse-opened popover trigger (Picker / Combobox / InlineSelect /
22
- * InlineDatePicker) never receives `:focus-visible`, so apply this on its open
23
- * state to read identically to a focused input a lone 1px border looks thin
24
- * beside the kit's focus outline and selected-pill ring. */
25
- export const ACTIVE_RING = `0 0 0 2px ${colors.zinc[900]}`;
19
+ /** THE focus ring — a 2px zinc-900 box-shadow flush against the box (offset 0),
20
+ * layout-neutral (never reflows). The kit no longer ships a global `:focus-visible`
21
+ * CSS rule, so every interactive control paints THIS inline when keyboard-focused
22
+ * (via `useFocusRing`). It doubles as the active/open edge: a mouse-opened popover
23
+ * trigger (Picker / Combobox / InlineSelect / InlineDatePicker) wears it on its open
24
+ * state so an open trigger reads identically to a keyboard-focused one — one token,
25
+ * one look. Apps building their own controls reuse it. */
26
+ export const FOCUS_RING = `0 0 0 2px ${colors.zinc[900]}`;
27
+
28
+ /** A bordered control's pointer-hover edge: the resting `border` darkens to this on
29
+ * hover — the premium "this is interactive" affordance that complements the focus
30
+ * ring (focus = the ring; hover = a darker border). Drive it with `useHover` on a
31
+ * TextInput/select, or a Pressable's built-in `hovered` state. Pair with
32
+ * `CONTROL_TRANSITION` so the change eases in. */
33
+ export const HOVER_BORDER = colors.zinc[400];
34
+
35
+ /** The ease applied to a bordered control's interactive edges (border + ring), so
36
+ * hover/focus transitions read smooth, not snapped. Web-only; ignored on native. */
37
+ export const CONTROL_TRANSITION = {
38
+ transitionProperty: "border-color, box-shadow",
39
+ transitionDuration: "0.12s",
40
+ };
26
41
 
27
42
  /** THE review-card surface — the bordered white card every "AI proposes, you
28
43
  * decide" surface wears (`ReviewCard`, and the whole-set `ChangeReview`), so the