@lotics/ui 6.3.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 +36 -17
- package/examples/tpl_dossier.tsx +5 -5
- package/examples/tpl_order.tsx +5 -6
- package/examples/tpl_quick.tsx +5 -4
- package/examples/tpl_ratedesk.tsx +9 -3
- package/examples/tpl_report.tsx +5 -8
- package/package.json +1 -1
- package/src/combobox.tsx +379 -201
- package/src/stepper.tsx +26 -4
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
|
|
49
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
`
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 children — never 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),
|
|
210
|
-
row below the listbox, OUTSIDE keyboard option-nav; call `close()` from a
|
|
211
|
-
off elsewhere (so the popover dismisses cleanly instead of floating over
|
|
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
|
|
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 ·
|
|
@@ -761,7 +780,7 @@ list · list_item · menu_button · menu_list_item · detail_row · pressable_ro
|
|
|
761
780
|
check_circle (CheckCircle — the completion ring: an empty ring that springs to a filled check when done, distinct from the square checkbox; the task/to-do/checklist toggle. Compose task rows directly, no Task component) · action_menu ·
|
|
762
781
|
floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
763
782
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
764
|
-
sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
783
|
+
sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
765
784
|
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
|
|
766
785
|
alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
|
|
767
786
|
CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
|
package/examples/tpl_dossier.tsx
CHANGED
|
@@ -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
|
-
|
|
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" />
|
package/examples/tpl_order.tsx
CHANGED
|
@@ -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.
|
package/examples/tpl_quick.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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" />
|
package/examples/tpl_report.tsx
CHANGED
|
@@ -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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
94
|
-
* with results in a
|
|
95
|
-
* the active row, Enter selects, Esc closes)
|
|
96
|
-
* field it browses the full set (or `recentOptions`).
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
181
|
-
// own self-close (so the input's keyboard focus ring doesn't linger
|
|
182
|
-
//
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
) :
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
)
|
|
324
|
-
) :
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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,
|
package/src/stepper.tsx
CHANGED
|
@@ -56,7 +56,8 @@ interface StepPositional {
|
|
|
56
56
|
export interface StepProps extends StepPositional {
|
|
57
57
|
status: StepStatus;
|
|
58
58
|
children: ReactNode;
|
|
59
|
-
/** Make the step navigable
|
|
59
|
+
/** Make the step navigable (both orientations) — the whole step is the tap target.
|
|
60
|
+
* `active` washes the selected one (a panel can also sit beside it in vertical). */
|
|
60
61
|
onPress?: () => void;
|
|
61
62
|
active?: boolean;
|
|
62
63
|
accessibilityLabel?: string;
|
|
@@ -116,16 +117,34 @@ export function Step(props: StepProps) {
|
|
|
116
117
|
const { orientation, color, live } = useContext(StepperContext);
|
|
117
118
|
|
|
118
119
|
if (orientation === "horizontal") {
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
const inner = (
|
|
121
|
+
<>
|
|
121
122
|
<View style={styles.hTrackRow}>
|
|
122
123
|
<View style={[styles.hTrack, { backgroundColor: _leftFilled ? color : colors.zinc[200] }]} />
|
|
123
124
|
<Marker status={status} color={color} live={live} />
|
|
124
125
|
<View style={[styles.hTrack, { backgroundColor: _rightFilled ? color : colors.zinc[200] }]} />
|
|
125
126
|
</View>
|
|
126
127
|
<View style={styles.hLabel}>{children}</View>
|
|
127
|
-
|
|
128
|
+
</>
|
|
128
129
|
);
|
|
130
|
+
// Navigable: the whole step (marker + label + its track halves) is the tap
|
|
131
|
+
// target — generous, no dead zones; `active` washes the selected one.
|
|
132
|
+
if (onPress) {
|
|
133
|
+
return (
|
|
134
|
+
<View style={styles.hStepSlot}>
|
|
135
|
+
<PressableHighlight
|
|
136
|
+
focusRing
|
|
137
|
+
onPress={onPress}
|
|
138
|
+
accessibilityRole="button"
|
|
139
|
+
accessibilityLabel={accessibilityLabel}
|
|
140
|
+
style={[styles.hPress, active ? styles.hActive : null]}
|
|
141
|
+
>
|
|
142
|
+
{inner}
|
|
143
|
+
</PressableHighlight>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return <View style={styles.hStep}>{inner}</View>;
|
|
129
148
|
}
|
|
130
149
|
|
|
131
150
|
const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
|
|
@@ -210,6 +229,9 @@ const NODE = 18;
|
|
|
210
229
|
const styles = StyleSheet.create({
|
|
211
230
|
hRow: { flexDirection: "row" },
|
|
212
231
|
hStep: { flex: 1, alignItems: "center", gap: 8 },
|
|
232
|
+
hStepSlot: { flex: 1 },
|
|
233
|
+
hPress: { width: "100%", alignItems: "center", gap: 8, borderRadius: 8, paddingVertical: 4 },
|
|
234
|
+
hActive: { backgroundColor: colors.zinc[100] },
|
|
213
235
|
hTrackRow: { flexDirection: "row", alignItems: "center", width: "100%" },
|
|
214
236
|
hTrack: { flex: 1, height: 2 },
|
|
215
237
|
hLabel: { alignItems: "center" },
|