@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/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,
|
|
9
|
+
import { useCallback, useRef, useState, type ReactNode } from "react";
|
|
11
10
|
import { colors } from "./colors";
|
|
12
|
-
import {
|
|
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 {
|
|
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
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
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
|
|
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).
|
|
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
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
140
|
-
const
|
|
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
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
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,
|
|
167
|
+
[onValueChange, isServer, debouncedSearch, onSearchChange, reflectSelection],
|
|
222
168
|
);
|
|
223
169
|
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
[
|
|
198
|
+
[open, list],
|
|
281
199
|
);
|
|
282
200
|
|
|
283
|
-
const showList = open && !disabled && (searching || list.length > 0);
|
|
284
|
-
const
|
|
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 = !
|
|
206
|
+
const showChevron = !icon && !clearable;
|
|
290
207
|
|
|
291
208
|
return (
|
|
292
209
|
<View style={style}>
|
|
293
|
-
<View ref={triggerRef}
|
|
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={
|
|
214
|
+
icon={icon}
|
|
315
215
|
value={query}
|
|
316
|
-
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
|
|
322
|
-
//
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
showList ? styles.openRing : undefined,
|
|
350
248
|
]}
|
|
351
249
|
role="combobox"
|
|
352
250
|
aria-expanded={showList}
|
|
353
|
-
aria-controls={listboxId}
|
|
354
|
-
aria-activedescendant={
|
|
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
|
-
//
|
|
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
|
-
{
|
|
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((
|
|
410
|
-
const isCustom =
|
|
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={
|
|
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={
|
|
440
|
-
selected={
|
|
338
|
+
focused={row.index === list.activeIndex}
|
|
339
|
+
selected={row.selected}
|
|
441
340
|
disabled={opt.disabled}
|
|
442
|
-
onPress={() =>
|
|
443
|
-
onHoverIn={() => setActiveIndex(
|
|
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:
|
|
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
|
},
|
package/src/confidence.tsx
CHANGED
|
@@ -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
|
|
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={
|
|
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
|
-
{
|
|
54
|
+
{l[level]}
|
|
44
55
|
</Text>
|
|
45
56
|
</View>
|
|
46
57
|
);
|
package/src/control_surface.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
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
|