@lotics/ui 6.4.0 → 7.0.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 CHANGED
@@ -7,7 +7,7 @@ open the file.** If the closest component lacks a capability, **extend it** (a p
7
7
  every app inherits), never inline a one-off `View`/`Text` rebuild — that forfeits the
8
8
  typeahead, async search, virtualization, and a11y the primitive already ships.
9
9
 
10
- - Import per module: `import { Combobox } from "@lotics/ui/combobox"`.
10
+ - Import per module: `import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox"`.
11
11
  - Floating content (`Dialog` · `Popover` · `Tooltip` · `Alert` · `OptionList`) needs a
12
12
  `PortalHost` at the app root.
13
13
  - RN-Web only: `View`/`ScrollView` from `react-native`, the `Text` primitive (no raw
@@ -45,8 +45,9 @@ Pick by capability, not by name. (→ the source file for the API.)
45
45
  or `Select` (the rich one — custom-rendered options, single/multi, select-all). Search-as-you-type
46
46
  / async / create-new → `Combobox`. A selectable card row → `CardSelectItem`. For a SELECT-FIELD
47
47
  picker, render each option as its colored chip on `Select`
48
- (`renderOptionContent={(o) => <OptionBadge value={o} />}`). `Select`/`Combobox` all open
49
- the one shared `OptionList` body; `Picker` is the only one that's a native dropdown.
48
+ (`renderOptionContent={(o) => <OptionBadge value={o} />}`). `Select` opens the shared
49
+ `OptionList` body; `Combobox` is COMPOUND (`ComboboxInput` + `ComboboxContent`) over the same
50
+ `useOptionList` engine; `Picker` is the only one that's a native dropdown.
50
51
  - **Pick member(s)** — `MemberSelect` (a `Select` that renders each option as a `MemberChip`,
51
52
  single or multi) — the ready member picker; pass it the roster (`members={useMembers().members}`).
52
53
  Don't re-wire `Select` + `renderOptionContent` + a directory by hand. To edit a `select_member`
@@ -195,20 +196,38 @@ Past two columns the label→field link breaks — MANY inputs means GROUPING in
195
196
  wrap their OWN `FormField` (and omit `style`) — for a grid cell use a bare `FormField style={half}`
196
197
  wrapping `DatePicker`/`Picker`.
197
198
 
198
- ### Find-or-create — `Combobox` IS the control
199
- `Combobox` is a SELECT by default (no leading icon, a trailing chevron); opt INTO the search-box
200
- look with `icon="search"` only when typing-to-search is primary (a large/remote set).
201
- `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query matches no option,
202
- emitting the typed text as the value so a value not in the known set means CREATE. Wire that
203
- branch to a create overlay (a modal `Dialog` for 4+ fields, an anchored popover for 1–3) that
204
- builds the new record and attaches it; existing matches attach directly. The create row sits
205
- BELOW matches by default (the keyboard highlight lands on the first MATCH, so Enter on a partial
206
- picks it, never a duplicate); pass `customOptionPlacement="top"` to pin it above. Use
199
+ ### Find-or-create — the `Combobox` family IS the control
200
+ `Combobox` is COMPOUND: a root holds the DATA + behaviour (the shared `useOptionList` engine
201
+ `options`, `value`, `onValueChange`, `onSearchChange`, `allowCustom`, the per-row
202
+ `getOptionDescription`/`renderOptionContent` so they're typed against the option `data`), and the
203
+ parts render the CHROME, composed as childrennever a render-prop pile:
204
+
205
+ ```tsx
206
+ <Combobox options={hits} value={sel} onSearchChange={setQ} onValueChange={pick} allowCustom
207
+ customOptionLabel={(q) => `Create "${q}"`} getOptionDescription={(o) => o.data?.code}>
208
+ <ComboboxInput icon="search" clearable onClear={deselect} placeholder="Find or create…" />
209
+ <ComboboxContent recentsLabel="Recent">
210
+ <ComboboxEmpty>No match — type a name to create one</ComboboxEmpty>
211
+ <ComboboxFooter>{validity}</ComboboxFooter>
212
+ </ComboboxContent>
213
+ </Combobox>
214
+ ```
215
+
216
+ It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
217
+ search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
218
+ large/remote set). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
219
+ matches no option, emitting the typed text as the value — so a value not in the known set means
220
+ CREATE. Wire that branch to a create overlay (a modal `Dialog` for 4+ fields, an anchored popover
221
+ for 1–3) that builds the new record and attaches it; existing matches attach directly. The create
222
+ row sits BELOW matches by default (the keyboard highlight lands on the first MATCH, so Enter on a
223
+ partial picks it, never a duplicate); pass `customOptionPlacement="top"` to pin it above. Use
207
224
  `reflectSelection={false}` and render the attached record below as a card with a Change action.
208
225
  For input-level status that must stay visible while results scroll (validity feedback on the typed
209
- value, a result count, a secondary "create" affordance), pass `footer={({ close }) => …}` a pinned
210
- row below the listbox, OUTSIDE keyboard option-nav; call `close()` from a footer action that hands
211
- off elsewhere (so the popover dismisses cleanly instead of floating over what you navigated to).
226
+ value, a result count, a secondary "create" affordance), drop a `ComboboxFooter` into the content
227
+ a pinned row below the listbox, OUTSIDE keyboard option-nav; call `useCombobox().close()` from a
228
+ footer action that hands off elsewhere (so the popover dismisses cleanly instead of floating over
229
+ what you navigated to). A `ComboboxEmpty` child gives the no-match state richer content than the
230
+ `ComboboxContent emptyText` string.
212
231
 
213
232
  ### Line items — create → preview → edit (a composition, not a primitive)
214
233
  For a list of records you build then revise (invoice rows, repair lines, config entries), each
@@ -751,7 +770,7 @@ option_badge (OptionBadge — a select value as its configured colored badge) ·
751
770
  member_chip (MemberChip — avatar + name; the universal person render) ·
752
771
  member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
753
772
  status_badge · button · icon_button · link · text_link (TextLink — underlined text that's optionally an `onPress` action or an `href` link, or plain underlined text to wrap in your own pressable; the neutral counterpart to the fixed-blue `Link`) · chip · tabs · segmented_control ·
754
- picker (native `<select>`, plain label-only single) · select (Select — rich/custom-rendered, single/multi, select-all, chips via `renderSelected` + `searchable` + `allowCustom` — the tag field is just a multi Select; opens `OptionList`) · option_list (OptionList — the ONE shared searchable listbox body every selector opens: single/multi, optional internal search, create row, keyboard + native-`<select>` typeahead; host it directly in a `Popover`/`Dialog` for a command palette) · combobox (single-select editable search input; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
773
+ picker (native `<select>`, plain label-only single) · select (Select — rich/custom-rendered, single/multi, select-all, chips via `renderSelected` + `searchable` + `allowCustom` — the tag field is just a multi Select; opens `OptionList`) · option_list (OptionList — the ONE shared searchable listbox body every selector opens: single/multi, optional internal search, create row, keyboard + native-`<select>` typeahead; host it directly in a `Popover`/`Dialog` for a command palette) · combobox (COMPOUND single-select editable search: `Combobox` root + `ComboboxInput` + `ComboboxContent`, optional `ComboboxEmpty`/`ComboboxFooter`, `useCombobox()`; over the shared `useOptionList` engine; browses on focus; no `multi` — multi-value chips → `Select multi`) ·
755
774
  text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
756
775
  radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
757
776
  inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
@@ -23,7 +23,7 @@ import { ChipGroup } from "@lotics/ui/chip_group";
23
23
  import { FilterChip, selectSummary } from "@lotics/ui/filter_chip";
24
24
  import { OptionList } from "@lotics/ui/option_list";
25
25
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
26
- import { Combobox } from "@lotics/ui/combobox";
26
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
27
27
  import type { PickerOption } from "@lotics/ui/picker";
28
28
  import { formatMoney } from "@lotics/ui/format_money";
29
29
 
@@ -222,15 +222,15 @@ function TaoHoSoForm() {
222
222
  </View>
223
223
  <FormField label="Customer">
224
224
  <Combobox
225
- icon="search"
226
225
  options={CUSTOMER_OPTIONS}
227
226
  onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
228
227
  allowCustom
229
228
  customOptionPlacement="top"
230
229
  customOptionLabel={(q) => `Create new customer “${q}”`}
231
- placeholder="Search customers, or add a new one…"
232
- accessibilityLabel="Customer"
233
- />
230
+ >
231
+ <ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
232
+ <ComboboxContent />
233
+ </Combobox>
234
234
  </FormField>
235
235
  <FormField label="Case fee">
236
236
  <NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Case fee" />
@@ -10,7 +10,7 @@ import { Icon } from "@lotics/ui/icon";
10
10
  import { FormField } from "@lotics/ui/form_field";
11
11
  import { TextInputField } from "@lotics/ui/text_input_field";
12
12
  import { Picker } from "@lotics/ui/picker";
13
- import { Combobox } from "@lotics/ui/combobox";
13
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
14
14
  import type { PickerOption } from "@lotics/ui/picker";
15
15
  import { DatePicker } from "@lotics/ui/date_picker";
16
16
  import { NumberInput } from "@lotics/ui/number_input";
@@ -288,7 +288,6 @@ export function TplOrder() {
288
288
  </View>
289
289
  ) : null}
290
290
  <Combobox
291
- icon="search"
292
291
  options={customerOptions}
293
292
  onValueChange={onPickCustomer}
294
293
  getOptionDescription={(o) => (o.data ? `${o.data.code} · ${o.data.city}` : undefined)}
@@ -296,11 +295,11 @@ export function TplOrder() {
296
295
  allowCustom
297
296
  customOptionPlacement="top"
298
297
  customOptionLabel={(q) => `Create new customer “${q}”`}
299
- placeholder="Search customers by name…"
300
- emptyText="No customer matches — type a name to create one"
301
- accessibilityLabel="Attach customer"
302
298
  autoFocus={changing}
303
- />
299
+ >
300
+ <ComboboxInput icon="search" placeholder="Search customers by name…" accessibilityLabel="Attach customer" />
301
+ <ComboboxContent emptyText="No customer matches — type a name to create one" />
302
+ </Combobox>
304
303
  {!customer ? (
305
304
  <Text size="xs" color="muted">
306
305
  No match? Pick “Create new customer …” to add it without leaving this order.
@@ -9,7 +9,7 @@ import { Divider } from "@lotics/ui/divider";
9
9
  import { Icon, type IconName } from "@lotics/ui/icon";
10
10
  import { FormField } from "@lotics/ui/form_field";
11
11
  import { TextInputField } from "@lotics/ui/text_input_field";
12
- import { Combobox } from "@lotics/ui/combobox";
12
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
13
  import type { PickerOption } from "@lotics/ui/picker";
14
14
  import { SegmentedControl } from "@lotics/ui/segmented_control";
15
15
  import { Counter } from "@lotics/ui/counter";
@@ -122,9 +122,10 @@ export function TplQuick() {
122
122
  value={contact}
123
123
  onValueChange={setContact}
124
124
  getOptionDescription={(o) => o.data?.company}
125
- placeholder="Select a contact"
126
- accessibilityLabel="Contact"
127
- />
125
+ >
126
+ <ComboboxInput placeholder="Select a contact" accessibilityLabel="Contact" />
127
+ <ComboboxContent />
128
+ </Combobox>
128
129
  </FormField>
129
130
  <FormField label="Duration" style={{ flexGrow: 0, flexBasis: 180 }}>
130
131
  <View style={{ minHeight: 40, justifyContent: "center" }}>
@@ -6,7 +6,7 @@ import { Button } from "@lotics/ui/button";
6
6
  import { Badge } from "@lotics/ui/badge";
7
7
  import { Icon } from "@lotics/ui/icon";
8
8
  import { EmptyState } from "@lotics/ui/empty_state";
9
- import { Combobox } from "@lotics/ui/combobox";
9
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
10
10
  import { type PickerOption } from "@lotics/ui/picker";
11
11
  import { ColumnFilter, type FilterableColumn, type ColumnFilterValue } from "@lotics/ui/column_filter";
12
12
  import { FileDropzone } from "@lotics/ui/file_dropzone";
@@ -193,11 +193,17 @@ export function TplRatedesk() {
193
193
  <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
194
194
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6, flexGrow: 1, flexBasis: 360, minWidth: 280 }}>
195
195
  <View style={{ flex: 1, minWidth: 120 }}>
196
- <Combobox icon="map-pin" options={originOpts} recentOptions={originOpts} recentsLabel="Origin" value={fromV} onValueChange={setFromV} clearable onClear={() => setFromV(null)} placeholder="From" accessibilityLabel="Origin port" />
196
+ <Combobox options={originOpts} recentOptions={originOpts} value={fromV} onValueChange={setFromV}>
197
+ <ComboboxInput icon="map-pin" clearable onClear={() => setFromV(null)} placeholder="From" accessibilityLabel="Origin port" />
198
+ <ComboboxContent recentsLabel="Origin" />
199
+ </Combobox>
197
200
  </View>
198
201
  <Icon name="arrow-right" size={14} color={colors.zinc[400]} />
199
202
  <View style={{ flex: 1, minWidth: 120 }}>
200
- <Combobox icon="map-pin" options={destOpts} recentOptions={destOpts} recentsLabel="Destination" value={toV} onValueChange={setToV} clearable onClear={() => setToV(null)} placeholder="To" accessibilityLabel="Destination port" />
203
+ <Combobox options={destOpts} recentOptions={destOpts} value={toV} onValueChange={setToV}>
204
+ <ComboboxInput icon="map-pin" clearable onClear={() => setToV(null)} placeholder="To" accessibilityLabel="Destination port" />
205
+ <ComboboxContent recentsLabel="Destination" />
206
+ </Combobox>
201
207
  </View>
202
208
  </View>
203
209
  <ColumnFilter column={carrierCol} value={carrierF} onChange={setCarrierF} clearLabel="Clear carrier" />
@@ -6,7 +6,7 @@ import { Badge } from "@lotics/ui/badge";
6
6
  import { Breakdown } from "@lotics/ui/breakdown";
7
7
  import { Button } from "@lotics/ui/button";
8
8
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
9
- import { Combobox } from "@lotics/ui/combobox";
9
+ import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
10
10
  import { type PickerOption } from "@lotics/ui/picker";
11
11
  import { DateRangeFilterField } from "@lotics/ui/date_range_filter_field";
12
12
  import { type DateFilterValue } from "@lotics/ui/date_filter";
@@ -400,14 +400,11 @@ function NamePicker(props: {
400
400
  <Combobox<string>
401
401
  options={options}
402
402
  value={selected ? { value: selected, label: selected } : null}
403
- clearable
404
- clearLabel={`Clear ${label}`}
405
- onClear={() => onSelect(null)}
406
403
  onSearchChange={setQ}
407
404
  onValueChange={(opt) => onSelect(opt.value)}
408
- placeholder={placeholder}
409
- emptyText={`No ${label} matches`}
410
- accessibilityLabel={`Search ${label}`}
411
- />
405
+ >
406
+ <ComboboxInput clearable clearLabel={`Clear ${label}`} onClear={() => onSelect(null)} placeholder={placeholder} accessibilityLabel={`Search ${label}`} />
407
+ <ComboboxContent emptyText={`No ${label} matches`} />
408
+ </Combobox>
412
409
  );
413
410
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "6.4.0",
3
+ "version": "7.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/combobox.tsx CHANGED
@@ -6,7 +6,17 @@ import {
6
6
  type ViewStyle,
7
7
  type TextInput as RNTextInput,
8
8
  } from "react-native";
9
- import { useCallback, useRef, useState, type ReactNode } from "react";
9
+ import {
10
+ Children,
11
+ createContext,
12
+ isValidElement,
13
+ useCallback,
14
+ useContext,
15
+ useRef,
16
+ useState,
17
+ type ReactNode,
18
+ type RefObject,
19
+ } from "react";
10
20
  import { colors } from "./colors";
11
21
  import { FOCUS_RING } from "./control_surface";
12
22
  import { useLoticsLocale } from "./locale";
@@ -18,7 +28,65 @@ import { ActivityIndicator } from "./activity_indicator";
18
28
  import { Popover, PopoverContent } from "./popover";
19
29
  import type { PickerOption } from "./picker";
20
30
  import { useDebouncedCallback } from "./use_debounced_callback";
21
- import { useOptionList } from "./use_option_list";
31
+ import { useOptionList, type UseOptionList } from "./use_option_list";
32
+
33
+ type KeyEvent = { nativeEvent: { key: string }; preventDefault: () => void };
34
+
35
+ interface ComboboxContextValue {
36
+ /** The shared engine instance — the input drives its keyboard, the content
37
+ * renders its rows, so both halves stay one source of truth. Typed loosely
38
+ * here (React context can't be per-instance generic); `ComboboxContent` casts
39
+ * it back to its own `<T, D>` at the rendering boundary. */
40
+ list: UseOptionList<string, unknown>;
41
+ /** The text shown in the input (a reflected selection or the live query). */
42
+ query: string;
43
+ open: boolean;
44
+ /** A dropdown is actually up (open AND it has something to show). The input's
45
+ * focus ring and the popover both gate on this, never on `open` alone. */
46
+ showList: boolean;
47
+ loading: boolean;
48
+ disabled: boolean;
49
+ autoFocus: boolean;
50
+ /** `recentOptions` were supplied — so the idle list is recents (gets a heading),
51
+ * not a plain browse of `options`. */
52
+ hasRecents: boolean;
53
+ /** Per-row renderers — typed against the root's `<T, D>`, widened to the loose
54
+ * context shape; `ComboboxContent` invokes them on its (equally loose) rows. */
55
+ renderOptionContent?: (option: PickerOption<string, unknown>) => ReactNode;
56
+ getOptionDescription?: (option: PickerOption<string, unknown>) => string | undefined;
57
+ triggerRef: RefObject<View | null>;
58
+ inputRef: RefObject<RNTextInput | null>;
59
+ handleChangeText: (text: string) => void;
60
+ handleKeyPress: (e: KeyEvent) => void;
61
+ onInputFocus: () => void;
62
+ onInputBlur: () => void;
63
+ setOpen: (open: boolean) => void;
64
+ close: () => void;
65
+ }
66
+
67
+ const ComboboxContext = createContext<ComboboxContextValue | null>(null);
68
+
69
+ /** Read the surrounding `Combobox`'s state from a part or its children — `close()`
70
+ * to dismiss the popover (e.g. from a `ComboboxFooter` action that navigates
71
+ * elsewhere), plus the live `query` / `open` / `searching` flags. Throws outside a
72
+ * `Combobox`. */
73
+ export function useCombobox(): {
74
+ close: () => void;
75
+ setOpen: (open: boolean) => void;
76
+ query: string;
77
+ open: boolean;
78
+ searching: boolean;
79
+ } {
80
+ const ctx = useContext(ComboboxContext);
81
+ if (!ctx) throw new Error("useCombobox must be used inside a <Combobox>");
82
+ return {
83
+ close: ctx.close,
84
+ setOpen: ctx.setOpen,
85
+ query: ctx.query,
86
+ open: ctx.open,
87
+ searching: ctx.list.searching,
88
+ };
89
+ }
22
90
 
23
91
  export interface ComboboxProps<T extends string = string, D = unknown> {
24
92
  /** Result options for the current query. With `onSearchChange` the consumer
@@ -33,16 +101,8 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
33
101
  value?: PickerOption<T, D> | null;
34
102
  /** Reflect the picked label in the input (classic autocomplete). Default true.
35
103
  * Set false to keep the input a pure search and render the selection yourself
36
- * (e.g. in a list below). For a multi-value CHIP field use `Select multi`
37
- * (its `renderSelected` returns a removable `Chip`). */
104
+ * (e.g. in a list below). For a multi-value CHIP field use `Select multi`. */
38
105
  reflectSelection?: boolean;
39
- /** Custom rendering of an option row in the dropdown. The input itself is a
40
- * plain text field (it can only show the label string) — to display a
41
- * selection with custom rendering, use `reflectSelection={false}` and render
42
- * the picked option yourself below. */
43
- renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
44
- /** Row subtitle pulled from the option (single-line, under the title). */
45
- getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
46
106
  /** Accept free text: when the query matches no option, offer it as a custom
47
107
  * value — `onValueChange` receives `{ value: query, label: query }`. */
48
108
  allowCustom?: boolean;
@@ -57,49 +117,46 @@ export interface ComboboxProps<T extends string = string, D = unknown> {
57
117
  customOptionPlacement?: "top" | "bottom";
58
118
  /** Shown — under a heading — when the input is focused but empty. */
59
119
  recentOptions?: PickerOption<T, D>[];
120
+ /** Rich row content (a colour-dot badge, a member chip). Falls back to the label.
121
+ * Lives on the root so its `option` is typed against the inferred `<T, D>`. */
122
+ renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
123
+ /** A single-line subtitle under the label (e.g. a code or company). */
124
+ getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
60
125
  loading?: boolean;
61
126
  searchDebounceMs?: number;
62
- /** Leading icon inside the input. OMIT (the default) for a SELECT — no leading
63
- * glyph, a trailing chevron — so a "pick a code/record" combobox reads as a
64
- * picker, not a free-text search box. Opt IN to the search-box look explicitly
65
- * with `icon="search"` (leading glyph, no chevron). */
66
- icon?: IconName;
67
- placeholder?: string;
68
- recentsLabel?: string;
69
- emptyText?: string;
70
- /** A pinned row rendered at the bottom of the popover (below the results, outside
71
- * the scroll area) whenever the popover is open. For input-level status that must
72
- * stay visible while results scroll — validity feedback, a result count, a "create"
73
- * affordance. It sits OUTSIDE the listbox, so its content is auxiliary (not an
74
- * option) and never enters keyboard option-nav. Receives `close` to dismiss the
75
- * popover — call it from an action that navigates elsewhere. */
76
- footer?: (api: { close: () => void }) => ReactNode;
77
- accessibilityLabel?: string;
78
127
  disabled?: boolean;
79
- /** Show an in-input clear ✕ whenever the input has text (a reflected selection
80
- * or a typed query). Pressing it empties the input, resets the search, and
81
- * fires `onClear` so the consumer can deselect. */
82
- clearable?: boolean;
83
- /** Fired when the clear ✕ is pressed (the input + search are reset first). */
84
- onClear?: () => void;
85
- /** Accessible label for the clear ✕ (default "Clear"). */
86
- clearLabel?: string;
128
+ /** Focus the input (and open) on mount. */
87
129
  autoFocus?: boolean;
88
- testID?: string;
130
+ /** The container View around the input + popover. */
89
131
  style?: StyleProp<ViewStyle>;
132
+ /** `ComboboxInput` then `ComboboxContent` (with optional `ComboboxEmpty` /
133
+ * `ComboboxFooter` inside it). */
134
+ children: ReactNode;
90
135
  }
91
136
 
92
137
  /**
93
- * The ARIA combobox: an editable input whose typing drives a (debounced) search,
94
- * with results in a Popover listbox below. The input owns the keyboard (↑/↓ move
95
- * the active row, Enter selects, Esc closes) — not a button trigger; on an empty
96
- * field it browses the full set (or `recentOptions`). Shares the `useOptionList`
97
- * row model + nav with the rest of the kit's selectors.
138
+ * The ARIA combobox, composed: an editable `ComboboxInput` whose typing drives a
139
+ * (debounced) search, with results in a `ComboboxContent` popover listbox below.
140
+ * The input owns the keyboard (↑/↓ move the active row, Enter selects, Esc closes);
141
+ * on an empty field it browses the full set (or `recentOptions`).
98
142
  *
99
- * Selection display: the picked label reflects into the input (`reflectSelection`),
100
- * or set `reflectSelection={false}` to keep the input a pure search and render the
101
- * selection below yourself. For a "pick from a known list" control use `Picker`
102
- * (native, plain) or `Select` (rich/multi, or a chip box via `renderSelected`).
143
+ * The root holds the data + behaviour (the shared `useOptionList` engine, the
144
+ * selection, the open/query state) and provides it via context; the parts render
145
+ * the chrome so per-row content, the empty state, recents and a footer are
146
+ * COMPOSED as children, not passed as a pile of render-props:
147
+ *
148
+ * ```tsx
149
+ * <Combobox options={hits} value={sel} onSearchChange={setQ} onValueChange={pick} allowCustom>
150
+ * <ComboboxInput icon="search" clearable placeholder="Search…" />
151
+ * <ComboboxContent recentsLabel="Recent" getOptionDescription={(o) => o.data?.code}>
152
+ * <ComboboxEmpty>No matches — type a name to create one</ComboboxEmpty>
153
+ * <ComboboxFooter>{validity}</ComboboxFooter>
154
+ * </ComboboxContent>
155
+ * </Combobox>
156
+ * ```
157
+ *
158
+ * For a "pick from a known list" control use `Picker` (native, plain) or `Select`
159
+ * (rich/multi, or a chip box via `renderSelected`).
103
160
  */
104
161
  export function Combobox<T extends string = string, D = unknown>(props: ComboboxProps<T, D>) {
105
162
  const {
@@ -108,29 +165,19 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
108
165
  onSearchChange,
109
166
  value,
110
167
  reflectSelection = true,
111
- renderOptionContent,
112
- getOptionDescription,
113
168
  allowCustom = false,
114
169
  customOptionLabel,
115
170
  customOptionPlacement = "bottom",
116
171
  recentOptions,
172
+ renderOptionContent,
173
+ getOptionDescription,
117
174
  loading = false,
118
175
  searchDebounceMs = 200,
119
- icon,
120
- placeholder,
121
- footer,
122
- accessibilityLabel = "Results",
123
176
  disabled = false,
124
- clearable = false,
125
- onClear,
126
- clearLabel,
127
177
  autoFocus = false,
128
- testID,
129
178
  style,
179
+ children,
130
180
  } = props;
131
- const loc = useLoticsLocale().optionList;
132
- const emptyText = props.emptyText ?? loc.noResults;
133
- const recentsLabel = props.recentsLabel ?? loc.recent;
134
181
 
135
182
  const single = value ?? null;
136
183
  const reflectedText = single ? (single.label ?? single.value) : "";
@@ -177,9 +224,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
177
224
  [onValueChange, isServer, debouncedSearch, onSearchChange, reflectSelection],
178
225
  );
179
226
 
180
- // Dismiss the popover from the footer — drop focus too, mirroring the popover's
181
- // own self-close (so the input's keyboard focus ring doesn't linger after a
182
- // footer action hands off elsewhere).
227
+ // Dismiss the popover from a footer action — drop focus too, mirroring the
228
+ // popover's own self-close (so the input's keyboard focus ring doesn't linger
229
+ // after the action hands off elsewhere).
183
230
  const close = useCallback(() => {
184
231
  setOpen(false);
185
232
  inputRef.current?.blur();
@@ -208,7 +255,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
208
255
  );
209
256
 
210
257
  const handleKeyPress = useCallback(
211
- (e: { nativeEvent: { key: string }; preventDefault: () => void }) => {
258
+ (e: KeyEvent) => {
212
259
  const key = e.nativeEvent.key;
213
260
  if (!open && (key === "ArrowDown" || key === "ArrowUp")) setOpen(true);
214
261
  if (list.handleKey(key)) e.preventDefault();
@@ -216,161 +263,292 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
216
263
  [open, list],
217
264
  );
218
265
 
266
+ const onInputFocus = useCallback(() => {
267
+ // A programmatic refocus right after a commit must not reopen the menu;
268
+ // consume the one-shot flag instead of opening.
269
+ if (suppressFocusOpenRef.current) {
270
+ suppressFocusOpenRef.current = false;
271
+ return;
272
+ }
273
+ if (!disabled) setOpen(true);
274
+ }, [disabled]);
275
+
276
+ const onInputBlur = useCallback(() => {
277
+ // Clear a flag the keyboard path left set (its refocus was a no-op, so
278
+ // onFocus never consumed it) — the next real focus opens normally.
279
+ suppressFocusOpenRef.current = false;
280
+ }, []);
281
+
219
282
  const showList = open && !disabled && (list.searching || list.rows.length > 0);
220
- const showRecentsHeader = !list.searching && (recentOptions?.length ?? 0) > 0;
221
- // Select mode (no leading icon): show a trailing chevron so the field reads as
222
- // a picker, not a free-text search. The clear owns the right slot when
223
- // present, so suppress the chevron then.
283
+
284
+ const ctx: ComboboxContextValue = {
285
+ // Boundary cast: the engine is generic per-instance, the context type is not.
286
+ // `ComboboxContent` casts back to its own `<T, D>` before touching rows.
287
+ list: list as unknown as UseOptionList<string, unknown>,
288
+ query,
289
+ open,
290
+ showList,
291
+ loading,
292
+ disabled,
293
+ autoFocus,
294
+ hasRecents: (recentOptions?.length ?? 0) > 0,
295
+ // Boundary casts: the renderers were type-checked against `<T, D>` on the root;
296
+ // the loose context invokes them on its loose rows.
297
+ renderOptionContent: renderOptionContent as
298
+ | ((o: PickerOption<string, unknown>) => ReactNode)
299
+ | undefined,
300
+ getOptionDescription: getOptionDescription as
301
+ | ((o: PickerOption<string, unknown>) => string | undefined)
302
+ | undefined,
303
+ triggerRef,
304
+ inputRef,
305
+ handleChangeText,
306
+ handleKeyPress,
307
+ onInputFocus,
308
+ onInputBlur,
309
+ setOpen,
310
+ close,
311
+ };
312
+
313
+ return (
314
+ <ComboboxContext.Provider value={ctx}>
315
+ <View style={style}>{children}</View>
316
+ </ComboboxContext.Provider>
317
+ );
318
+ }
319
+
320
+ function useComboboxContext(part: string): ComboboxContextValue {
321
+ const ctx = useContext(ComboboxContext);
322
+ if (!ctx) throw new Error(`<${part}> must be used inside a <Combobox>`);
323
+ return ctx;
324
+ }
325
+
326
+ export interface ComboboxInputProps {
327
+ /** Leading icon inside the input. OMIT (the default) for a SELECT — no leading
328
+ * glyph, a trailing chevron — so a "pick a code/record" combobox reads as a
329
+ * picker, not a free-text search box. Opt IN to the search-box look explicitly
330
+ * with `icon="search"` (leading glyph, no chevron). */
331
+ icon?: IconName;
332
+ placeholder?: string;
333
+ /** Show an in-input clear ✕ whenever the input has text (a reflected selection
334
+ * or a typed query). Pressing it empties the input, resets the search, and
335
+ * fires `onClear` so the consumer can deselect. */
336
+ clearable?: boolean;
337
+ /** Fired when the clear ✕ is pressed (the input + search are reset first). */
338
+ onClear?: () => void;
339
+ /** Accessible label for the clear ✕ (default "Clear"). */
340
+ clearLabel?: string;
341
+ /** Accessible name for the combobox input (the `role="combobox"` control). */
342
+ accessibilityLabel?: string;
343
+ testID?: string;
344
+ }
345
+
346
+ /** The editable text field that drives the search and owns the keyboard. Keeps DOM
347
+ * focus while the listbox is open (the ARIA combobox pattern — the active row is
348
+ * wired via `aria-activedescendant`, never real focus). */
349
+ export function ComboboxInput(props: ComboboxInputProps) {
350
+ const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
351
+ const ctx = useComboboxContext("ComboboxInput");
352
+ // Select mode (no leading icon): a trailing chevron so the field reads as a
353
+ // picker, not a free-text search. The clear ✕ owns the right slot when present.
224
354
  const showChevron = !icon && !clearable;
225
355
 
226
356
  return (
227
- <View style={style}>
228
- <View ref={triggerRef}>
229
- <TextInputField
230
- ref={inputRef}
231
- testID={testID}
232
- icon={icon}
233
- value={query}
234
- clearable={clearable}
235
- clearLabel={clearLabel}
236
- onClear={onClear}
237
- onChangeText={handleChangeText}
238
- onFocus={() => {
239
- // A programmatic refocus right after a commit must not reopen the
240
- // menu; consume the one-shot flag instead of opening.
241
- if (suppressFocusOpenRef.current) {
242
- suppressFocusOpenRef.current = false;
243
- return;
244
- }
245
- if (!disabled) setOpen(true);
246
- }}
247
- onBlur={() => {
248
- // Clear a flag the keyboard path left set (its refocus was a no-op,
249
- // so onFocus never consumed it) the next real focus opens normally.
250
- suppressFocusOpenRef.current = false;
251
- }}
252
- onKeyPress={handleKeyPress}
253
- placeholder={placeholder}
254
- placeholderTextColor={colors.zinc["400"]}
255
- editable={!disabled}
256
- autoFocus={autoFocus}
257
- autoCapitalize="none"
258
- autoCorrect={false}
259
- style={[
260
- showChevron ? styles.selectInput : undefined,
261
- // Gate on `showList`, not `open`: an empty query with no options has
262
- // `open=true` but renders NO dropdown, so a ring tied to `open` would
263
- // show with nothing below it AND linger after blur (no popover fires a
264
- // close). The ring must mean "a dropdown is up".
265
- showList ? styles.openRing : undefined,
266
- ]}
267
- role="combobox"
268
- aria-expanded={showList}
269
- aria-controls={list.listboxId}
270
- aria-activedescendant={showList ? list.activeId : undefined}
271
- aria-autocomplete="list"
272
- />
273
- {showChevron ? (
274
- <View style={styles.chevron}>
275
- <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
276
- </View>
277
- ) : null}
278
- </View>
279
- <Popover
280
- open={showList}
281
- onOpenChange={(next) => {
282
- setOpen(next);
283
- // An outside dismiss closes the menu but leaves the textbox focused, so
284
- // the input's keyboard focus ring would linger. Drop focus when the
285
- // popover closes ITSELF a commit refocuses through its own path
286
- // (`commit` sets `open` directly), never through `onOpenChange`.
287
- if (!next) inputRef.current?.blur();
288
- }}
289
- triggerRef={triggerRef}
290
- side="bottom"
291
- align="start"
292
- offset={4}
293
- inheritTriggerWidth
294
- >
295
- <PopoverContent manageFocus={false} disableBodyScroll testID={testID ? `${testID}-popover` : undefined}>
296
- <View style={styles.menu}>
297
- {showRecentsHeader ? (
298
- <View style={styles.sectionHeader}>
299
- <Text size="xs" weight="medium" color="zinc-500">
300
- {recentsLabel}
301
- </Text>
357
+ <View ref={ctx.triggerRef}>
358
+ <TextInputField
359
+ ref={ctx.inputRef}
360
+ testID={testID}
361
+ icon={icon}
362
+ value={ctx.query}
363
+ clearable={clearable}
364
+ clearLabel={clearLabel}
365
+ onClear={onClear}
366
+ onChangeText={ctx.handleChangeText}
367
+ onFocus={ctx.onInputFocus}
368
+ onBlur={ctx.onInputBlur}
369
+ onKeyPress={ctx.handleKeyPress}
370
+ placeholder={placeholder}
371
+ placeholderTextColor={colors.zinc["400"]}
372
+ editable={!ctx.disabled}
373
+ autoFocus={ctx.autoFocus}
374
+ autoCapitalize="none"
375
+ autoCorrect={false}
376
+ accessibilityLabel={accessibilityLabel}
377
+ style={[
378
+ showChevron ? styles.selectInput : undefined,
379
+ // Gate on `showList`, not `open`: an empty query with no options has
380
+ // `open=true` but renders NO dropdown, so a ring tied to `open` would
381
+ // show with nothing below it AND linger after blur (no popover fires a
382
+ // close). The ring must mean "a dropdown is up".
383
+ ctx.showList ? styles.openRing : undefined,
384
+ ]}
385
+ role="combobox"
386
+ aria-expanded={ctx.showList}
387
+ aria-controls={ctx.list.listboxId}
388
+ aria-activedescendant={ctx.showList ? ctx.list.activeId : undefined}
389
+ aria-autocomplete="list"
390
+ />
391
+ {showChevron ? (
392
+ <View style={styles.chevron}>
393
+ <Icon name="chevrons-up-down" size={16} color={colors.zinc["400"]} />
394
+ </View>
395
+ ) : null}
396
+ </View>
397
+ );
398
+ }
399
+
400
+ export interface ComboboxContentProps {
401
+ /** Heading over the idle list when `recentOptions` were supplied. */
402
+ recentsLabel?: string;
403
+ /** Shown when a non-empty query matches nothing. A `<ComboboxEmpty>` child wins
404
+ * over this string. */
405
+ emptyText?: string;
406
+ /** Accessible name for the listbox. */
407
+ accessibilityLabel?: string;
408
+ testID?: string;
409
+ /** Optional `ComboboxEmpty` / `ComboboxFooter`. */
410
+ children?: ReactNode;
411
+ }
412
+
413
+ /** The popover body: an optional recents heading, the keyboard-navigable row list
414
+ * (from the root's `options`, rendered via the root's `renderOptionContent` /
415
+ * `getOptionDescription`), then any `ComboboxFooter`. A `ComboboxEmpty` child
416
+ * replaces the default empty text. */
417
+ export function ComboboxContent(props: ComboboxContentProps) {
418
+ const { accessibilityLabel, testID, children } = props;
419
+ const ctx = useComboboxContext("ComboboxContent");
420
+ const loc = useLoticsLocale().optionList;
421
+ const emptyText = props.emptyText ?? loc.noResults;
422
+ const recentsLabel = props.recentsLabel ?? loc.recent;
423
+ const { list, renderOptionContent, getOptionDescription } = ctx;
424
+
425
+ // Pull the optional slots out of children — `ComboboxEmpty` replaces the default
426
+ // empty state, `ComboboxFooter` pins below the scroll.
427
+ let emptyEl: ReactNode = null;
428
+ let footerEl: ReactNode = null;
429
+ Children.forEach(children, (child) => {
430
+ if (!isValidElement(child)) return;
431
+ if (child.type === ComboboxEmpty) emptyEl = child;
432
+ else if (child.type === ComboboxFooter) footerEl = child;
433
+ });
434
+
435
+ const showRecentsHeader = !list.searching && ctx.hasRecents;
436
+ const isEmpty = !ctx.loading && list.rows.length === 0;
437
+
438
+ return (
439
+ <Popover
440
+ open={ctx.showList}
441
+ onOpenChange={(next) => {
442
+ ctx.setOpen(next);
443
+ // An outside dismiss closes the menu but leaves the textbox focused, so
444
+ // the input's keyboard focus ring would linger. Drop focus when the
445
+ // popover closes ITSELF — a commit refocuses through its own path (the
446
+ // root sets `open` directly), never through `onOpenChange`.
447
+ if (!next) ctx.inputRef.current?.blur();
448
+ }}
449
+ triggerRef={ctx.triggerRef}
450
+ side="bottom"
451
+ align="start"
452
+ offset={4}
453
+ inheritTriggerWidth
454
+ >
455
+ <PopoverContent manageFocus={false} disableBodyScroll testID={testID}>
456
+ <View style={styles.menu}>
457
+ {showRecentsHeader ? (
458
+ <View style={styles.sectionHeader}>
459
+ <Text size="xs" weight="medium" color="zinc-500">
460
+ {recentsLabel}
461
+ </Text>
462
+ </View>
463
+ ) : null}
464
+ <ScrollView
465
+ ref={list.scrollRef}
466
+ style={styles.scroll}
467
+ nativeID={list.listboxId}
468
+ accessibilityLabel={accessibilityLabel ?? (list.searching ? undefined : recentsLabel)}
469
+ keyboardShouldPersistTaps="handled"
470
+ role={"listbox" as "list"}
471
+ >
472
+ {ctx.loading ? (
473
+ <View style={styles.statusRow}>
474
+ <ActivityIndicator />
302
475
  </View>
303
- ) : null}
304
- <ScrollView
305
- ref={list.scrollRef}
306
- style={styles.scroll}
307
- nativeID={list.listboxId}
308
- accessibilityLabel={list.searching ? accessibilityLabel : recentsLabel}
309
- keyboardShouldPersistTaps="handled"
310
- role={"listbox" as "list"}
311
- >
312
- {loading ? (
313
- <View style={styles.statusRow}>
314
- <ActivityIndicator />
315
- </View>
316
- ) : list.rows.length === 0 ? (
317
- list.searching ? (
476
+ ) : isEmpty ? (
477
+ // Idle with nothing to show renders no empty row (an empty SELECT
478
+ // browse); only a non-matching SEARCH gets the empty state.
479
+ list.searching ? (
480
+ emptyEl ?? (
318
481
  <View style={styles.statusRow}>
319
482
  <Text size="sm" color="zinc-500">
320
483
  {emptyText}
321
484
  </Text>
322
485
  </View>
323
- ) : null
324
- ) : (
325
- list.rows.map((row) => {
326
- const isCustom = row.kind === "custom";
327
- const opt = row.option;
328
- const desc = !isCustom ? getOptionDescription?.(opt) : undefined;
329
- const label = opt.label ?? opt.value;
330
- const title =
331
- !isCustom && renderOptionContent ? (
332
- renderOptionContent(opt)
333
- ) : desc ? (
334
- <View>
335
- <Text userSelect="none" numberOfLines={1}>
336
- {label}
337
- </Text>
338
- <Text size="xs" color="zinc-500" numberOfLines={1}>
339
- {desc}
340
- </Text>
341
- </View>
342
- ) : (
343
- <Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
486
+ )
487
+ ) : null
488
+ ) : (
489
+ list.rows.map((row) => {
490
+ const isCustom = row.kind === "custom";
491
+ const opt = row.option;
492
+ const desc = !isCustom ? getOptionDescription?.(opt) : undefined;
493
+ const label = opt.label ?? opt.value;
494
+ const title =
495
+ !isCustom && renderOptionContent ? (
496
+ renderOptionContent(opt)
497
+ ) : desc ? (
498
+ <View>
499
+ <Text userSelect="none" numberOfLines={1}>
344
500
  {label}
345
501
  </Text>
346
- );
347
- return (
348
- <MenuButton
349
- key={`${row.kind}-${opt.value}`}
350
- nativeID={row.nativeID}
351
- testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
352
- role="option"
353
- accessibilityLabel={label}
354
- icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
355
- title={title}
356
- focused={row.index === list.activeIndex}
357
- selected={row.selected}
358
- disabled={opt.disabled}
359
- onPress={() => list.pickRow(row.index)}
360
- onHoverIn={() => list.setActiveIndex(row.index, false)}
361
- />
502
+ <Text size="xs" color="zinc-500" numberOfLines={1}>
503
+ {desc}
504
+ </Text>
505
+ </View>
506
+ ) : (
507
+ <Text userSelect="none" numberOfLines={1} weight={isCustom ? "medium" : undefined}>
508
+ {label}
509
+ </Text>
362
510
  );
363
- })
364
- )}
365
- </ScrollView>
366
- {footer ? <View style={styles.footer}>{footer({ close })}</View> : null}
367
- </View>
368
- </PopoverContent>
369
- </Popover>
370
- </View>
511
+ return (
512
+ <MenuButton
513
+ key={`${row.kind}-${opt.value}`}
514
+ nativeID={row.nativeID}
515
+ testID={isCustom ? "combobox-custom-option" : `combobox-option-${opt.value}`}
516
+ role="option"
517
+ accessibilityLabel={label}
518
+ icon={isCustom ? <Icon name="plus" size={16} color={colors.zinc["600"]} /> : undefined}
519
+ title={title}
520
+ focused={row.index === list.activeIndex}
521
+ selected={row.selected}
522
+ disabled={opt.disabled}
523
+ onPress={() => list.pickRow(row.index)}
524
+ onHoverIn={() => list.setActiveIndex(row.index, false)}
525
+ />
526
+ );
527
+ })
528
+ )}
529
+ </ScrollView>
530
+ {footerEl}
531
+ </View>
532
+ </PopoverContent>
533
+ </Popover>
371
534
  );
372
535
  }
373
536
 
537
+ /** A richer empty state for `ComboboxContent` — used INSTEAD of its `emptyText`
538
+ * string. Renders only when a non-empty query matches nothing. */
539
+ export function ComboboxEmpty(props: { children: ReactNode }) {
540
+ return <View style={styles.statusRow}>{props.children}</View>;
541
+ }
542
+
543
+ /** A pinned row at the bottom of the popover (below the results, outside the
544
+ * scroll) — input-level status that stays visible while results scroll: validity
545
+ * feedback, a result count, a create affordance. It sits OUTSIDE the listbox, so
546
+ * its content is auxiliary (never an option, never in keyboard option-nav). Call
547
+ * `useCombobox().close()` from an action that hands off elsewhere. */
548
+ export function ComboboxFooter(props: { children: ReactNode }) {
549
+ return <View style={styles.footer}>{props.children}</View>;
550
+ }
551
+
374
552
  const styles = StyleSheet.create({
375
553
  openRing: {
376
554
  boxShadow: FOCUS_RING,