@lotics/ui 6.4.0 → 7.1.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 +60 -28
- 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/date_range_filter_field.tsx +3 -4
- package/src/focus_ring_pressable.tsx +25 -7
- package/src/inline_edit.tsx +15 -7
- package/src/select.tsx +10 -10
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`
|
|
@@ -170,7 +171,7 @@ This is the most common thing to get right. Match the JOB to the pattern:
|
|
|
170
171
|
### Inline edit — the preferred way to edit an existing record
|
|
171
172
|
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|
|
172
173
|
form mode or a preview↔edit card — make each VALUE inline-editable: it reads as plain text,
|
|
173
|
-
hover
|
|
174
|
+
hover reveals its border (it's a field — no grey tint, no pencil that shifts), click swaps the input in **at the same height** (zero
|
|
174
175
|
reflow, the whole point), and it commits on blur (Enter saves, Escape reverts) or via
|
|
175
176
|
`controls="buttons"` (✓ primary / ✕). One per type — `InlineTextInput` · `InlineNumberInput`
|
|
176
177
|
(`format` for currency/units) · `InlineSelect` (plain options OR `renderOptionContent`; floats an
|
|
@@ -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
|
|
@@ -528,9 +547,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
528
547
|
resets the native outline. Every interactive control paints its OWN ring; nothing rings unless it
|
|
529
548
|
opts in. Three ways, pick by base:
|
|
530
549
|
- **`FocusRingPressable`** (`@lotics/ui/focus_ring_pressable`) — a `Pressable` that rings on keyboard
|
|
531
|
-
focus; the
|
|
532
|
-
|
|
533
|
-
|
|
550
|
+
focus, WITHOUT the hover wash; the base for any control whose hover affordance is its BORDER not a wash
|
|
551
|
+
(inputs/selects, the inline editors, cells, tiles, nav buttons, menu options). Forwards all
|
|
552
|
+
`PressableProps`; its state-fn `style` exposes `hovered`/`focusVisible` so the control paints its own
|
|
553
|
+
border-hover. Reach for this before hand-rolling a Pressable + the hook.
|
|
534
554
|
- **`PressableHighlight`** — set the opt-in **`focusRing`** prop (or read `state.focusVisible` from
|
|
535
555
|
its style-fn / children for a bespoke treatment, e.g. `CardSelectItem` which rings on hover/press/focus).
|
|
536
556
|
- **`useFocusRing`** (`@lotics/ui/use_focus_ring`) — the underlying hook for inputs / custom surfaces.
|
|
@@ -543,14 +563,26 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
|
|
|
543
563
|
its open state so it reads identically to a keyboard-focused control. **Coverage is a contract:** EVERY
|
|
544
564
|
interactive control must ring (a focusable control with no focus treatment is a bug — the per-Pressable
|
|
545
565
|
audit is the gate); a surface that shouldn't ring is a non-control → `tabIndex={-1}`, not a missing ring.
|
|
546
|
-
- **
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
566
|
+
- **Hover intensifies a control's OWN resting signature — never a foreign affordance. Pick the base by WHAT THE SURFACE IS; never a raw `Pressable`.** The one rule behind every interactive control:
|
|
567
|
+
- **Fields** (type into / pick from) — `TextInputField`, `Picker`, `Select`, `Combobox`'s input,
|
|
568
|
+
`NumberInput`, `DateField`/`TimePicker`, `DateRangeFilterField`, the inline editors
|
|
569
|
+
(`InlineSelect`/`InlineDatePicker` via `InlineEditView`) — signature is a BORDER → hover DARKENS it to
|
|
570
|
+
`HOVER_BORDER` (a borderless in-cell field REVEALS one). Build on **`FocusRingPressable`** (ring + a11y
|
|
571
|
+
+ a `state.hovered`/`focusVisible` style-fn, NO wash); layer the hover-border AFTER `style` so it wins
|
|
572
|
+
over the caller's resting edge; put the open ring (`FOCUS_RING`) on the open state. **A field NEVER
|
|
573
|
+
greys its content.**
|
|
574
|
+
- **Actions** — `Button`, `IconButton` — signature is a FILL → hover DARKENS the fill (own colour logic).
|
|
575
|
+
Never a border, never a wash.
|
|
576
|
+
- **Pills / toggles** — `Chip`/`ChipGroup`/`FilterChip` (via `chipSurfaceStyle`), `SegmentedControl`,
|
|
577
|
+
`Tabs`, `Switcher`, `RadioPicker` — signature is a pill SURFACE → hover WASHES it (white→zinc-100);
|
|
578
|
+
selected adds a ring/fill.
|
|
579
|
+
- **Surfaces** — `PressableRow`, `MenuButton`, list/menu items, `CardSelectItem`, `Card`, `Accordion` —
|
|
580
|
+
signature is a row/card SURFACE → hover WASHES it. Build on **`PressableHighlight`** — the grey wash IS
|
|
581
|
+
the affordance (its whole job; it's in the name).
|
|
582
|
+
- The bug this prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border →
|
|
583
|
+
it greys *and* animates its edge while its siblings only border-hover (`Select`/`DateRangeFilterField`/
|
|
584
|
+
the inline editors did exactly this pre-7.x). Reach for `PressableHighlight` on a bordered/input control
|
|
585
|
+
and you've made it.
|
|
554
586
|
- **Cards are banded — and composable** (all from `@lotics/ui/card`):
|
|
555
587
|
```tsx
|
|
556
588
|
<Card style={{ padding: 0 }}>
|
|
@@ -751,7 +783,7 @@ option_badge (OptionBadge — a select value as its configured colored badge) ·
|
|
|
751
783
|
member_chip (MemberChip — avatar + name; the universal person render) ·
|
|
752
784
|
member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
|
|
753
785
|
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
|
|
786
|
+
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
787
|
text_input_field · number_input · search_input · form_field · checkbox · checkbox_input · switch ·
|
|
756
788
|
radio_picker · counter · range_slider · date_picker · date_range_filter_field · time_picker ·
|
|
757
789
|
inline_text_input · inline_number_input · inline_select · inline_member_select · inline_date_picker ·
|
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,
|
|
@@ -5,7 +5,7 @@ import { colors } from "./colors";
|
|
|
5
5
|
import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { Button } from "./button";
|
|
8
|
-
import {
|
|
8
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
9
9
|
import { Popover, PopoverTrigger, PopoverContent, PopoverFooter } from "./popover";
|
|
10
10
|
import { DateFilter, DateFilterValue, DateFilterLabels } from "./date_filter";
|
|
11
11
|
import { formatDate } from "./format_date";
|
|
@@ -120,8 +120,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
120
120
|
return (
|
|
121
121
|
<Popover open={open} onOpenChange={setOpen} side="bottom" align="start">
|
|
122
122
|
<PopoverTrigger>
|
|
123
|
-
<
|
|
124
|
-
focusRing
|
|
123
|
+
<FocusRingPressable
|
|
125
124
|
testID={testID}
|
|
126
125
|
accessibilityRole="button"
|
|
127
126
|
accessibilityLabel={labels.selectDateRange}
|
|
@@ -143,7 +142,7 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
143
142
|
{display}
|
|
144
143
|
</Text>
|
|
145
144
|
<Icon name="chevron-down" size={14} color={colors.zinc[400]} />
|
|
146
|
-
</
|
|
145
|
+
</FocusRingPressable>
|
|
147
146
|
</PopoverTrigger>
|
|
148
147
|
<PopoverContent disableBodyScroll>
|
|
149
148
|
<DateFilter
|
|
@@ -2,20 +2,37 @@ import { Pressable, type PressableProps, type StyleProp, type View, type ViewSty
|
|
|
2
2
|
import { type Ref } from "react";
|
|
3
3
|
import { FOCUS_RING } from "./control_surface";
|
|
4
4
|
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
5
|
+
import { type PressableHighlightState } from "./pressable_highlight";
|
|
5
6
|
|
|
6
|
-
export interface FocusRingPressableProps extends PressableProps {
|
|
7
|
+
export interface FocusRingPressableProps extends Omit<PressableProps, "style"> {
|
|
7
8
|
ref?: Ref<View>;
|
|
9
|
+
/**
|
|
10
|
+
* Narrows the state-function `style` to `PressableHighlightState`, so a control can
|
|
11
|
+
* read the web-only `hovered` (to paint its OWN border-hover) and `focusVisible`,
|
|
12
|
+
* exactly like `PressableHighlight` — the wash-free base still hands controls the
|
|
13
|
+
* flags they need to drive their edge.
|
|
14
|
+
*/
|
|
15
|
+
style?: StyleProp<ViewStyle> | ((state: PressableHighlightState) => StyleProp<ViewStyle>);
|
|
16
|
+
/**
|
|
17
|
+
* `"none"` on a value/text surface (an inline-edit box) — a pressable is a button,
|
|
18
|
+
* not a selection surface, so a drag-click edits instead of starting a text
|
|
19
|
+
* selection. Exposed because RN types only carry `userSelect` on `TextStyle` while
|
|
20
|
+
* react-native-web applies it to any element.
|
|
21
|
+
*/
|
|
22
|
+
userSelect?: "auto" | "none";
|
|
8
23
|
}
|
|
9
24
|
|
|
10
25
|
/**
|
|
11
26
|
* A `Pressable` that paints the kit `FOCUS_RING` on keyboard focus — the raw-control
|
|
12
27
|
* counterpart to `PressableHighlight`'s `focusRing` prop, for interactive elements
|
|
13
|
-
* that DON'T want the hover wash (cells, tiles, nav buttons, menu
|
|
14
|
-
* every `PressableProps` (onPress, accessibilityRole/Label/State,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
28
|
+
* that DON'T want the hover wash (the inputs/selects, cells, tiles, nav buttons, menu
|
|
29
|
+
* options). Forwards every `PressableProps` (onPress, accessibilityRole/Label/State,
|
|
30
|
+
* ref, …); its state-function `style` exposes `hovered`/`focusVisible` so a control
|
|
31
|
+
* paints its own border-hover, and it merges the focus ring + onFocus/onBlur. Reach
|
|
32
|
+
* for this for ANY interactive control whose hover affordance is its border (never a
|
|
33
|
+
* wash) — `PressableHighlight` is only for rows/menus/cards.
|
|
17
34
|
*/
|
|
18
|
-
export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: FocusRingPressableProps) {
|
|
35
|
+
export function FocusRingPressable({ ref, style, onFocus, onBlur, userSelect, ...rest }: FocusRingPressableProps) {
|
|
19
36
|
const { focusVisible, focusProps } = useFocusRing();
|
|
20
37
|
return (
|
|
21
38
|
<Pressable
|
|
@@ -25,7 +42,8 @@ export function FocusRingPressable({ ref, style, onFocus, onBlur, ...rest }: Foc
|
|
|
25
42
|
onBlur={composeHandler(onBlur ?? undefined, focusProps.onBlur)}
|
|
26
43
|
style={(state) =>
|
|
27
44
|
[
|
|
28
|
-
|
|
45
|
+
userSelect ? ({ userSelect } as ViewStyle) : null,
|
|
46
|
+
typeof style === "function" ? style({ ...state, focusVisible }) : style,
|
|
29
47
|
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
30
48
|
] as StyleProp<ViewStyle>
|
|
31
49
|
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -3,9 +3,9 @@ import { View, StyleSheet, type GestureResponderEvent } from "react-native";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { IconButton } from "./icon_button";
|
|
5
5
|
import { ActivityIndicator } from "./activity_indicator";
|
|
6
|
-
import {
|
|
6
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
7
7
|
import { colors } from "./colors";
|
|
8
|
-
import { FOCUS_RING, CONTROL_RADIUS } from "./control_surface";
|
|
8
|
+
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
9
9
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
10
10
|
|
|
11
11
|
/** The kit's standard control height (TextInputField, NumberInput, Picker, …).
|
|
@@ -124,7 +124,8 @@ interface InlineEditViewProps {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* The view-mode box of an inline-editable field: the value as plain text in a
|
|
127
|
-
* `
|
|
127
|
+
* `FocusRingPressable` (an input, so it hovers via its BORDER — the transparent
|
|
128
|
+
* edge reveals on hover, never a grey wash; the pointer cursor signals it's
|
|
128
129
|
* editable), at the kit's control height with a transparent border — so
|
|
129
130
|
* swapping to an input, or floating a dropdown above it, never shifts the
|
|
130
131
|
* layout. Used by `InlineEditFrame`, and as the overlay trigger for the
|
|
@@ -141,15 +142,17 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
141
142
|
}
|
|
142
143
|
: undefined;
|
|
143
144
|
return (
|
|
144
|
-
<
|
|
145
|
-
focusRing
|
|
145
|
+
<FocusRingPressable
|
|
146
146
|
ref={ref}
|
|
147
147
|
disabled={disabled}
|
|
148
148
|
onPress={handlePress}
|
|
149
149
|
accessibilityRole="button"
|
|
150
150
|
accessibilityLabel={accessibilityLabel}
|
|
151
151
|
userSelect="none"
|
|
152
|
-
|
|
152
|
+
// An inline-edit field is an input — it hovers via its BORDER, not a grey
|
|
153
|
+
// wash: the transparent edge reveals on hover (the kit's hover-border),
|
|
154
|
+
// then fills + rings when open. Suppressed while open (the ring leads).
|
|
155
|
+
style={(state) => [styles.view, CONTROL_TRANSITION, active && styles.viewActive, !active && state.hovered && !disabled && styles.viewHovered]}
|
|
153
156
|
>
|
|
154
157
|
{typeof display === "string" ? (
|
|
155
158
|
<Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder, struck ? styles.struck : null]}>
|
|
@@ -159,7 +162,7 @@ export function InlineEditView(props: InlineEditViewProps) {
|
|
|
159
162
|
<View style={styles.viewNode}>{display}</View>
|
|
160
163
|
)}
|
|
161
164
|
{trailing != null ? trailing : null}
|
|
162
|
-
</
|
|
165
|
+
</FocusRingPressable>
|
|
163
166
|
);
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -250,6 +253,11 @@ const styles = StyleSheet.create({
|
|
|
250
253
|
borderColor: colors.border,
|
|
251
254
|
boxShadow: FOCUS_RING,
|
|
252
255
|
},
|
|
256
|
+
// Hover (not open): reveal the kit hover-border on the transparent edge — the
|
|
257
|
+
// input-family affordance, no grey wash.
|
|
258
|
+
viewHovered: {
|
|
259
|
+
borderColor: HOVER_BORDER,
|
|
260
|
+
},
|
|
253
261
|
placeholder: { color: colors.zinc[400] },
|
|
254
262
|
struck: { textDecorationLine: "line-through", color: colors.zinc[500] },
|
|
255
263
|
viewNode: { flex: 1, minWidth: 0 },
|
package/src/select.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { StyleSheet, View, type GestureResponderEvent, type StyleProp, type View
|
|
|
2
2
|
import { useState, useCallback, useMemo, type ReactNode, type Ref } from "react";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { FOCUS_RING, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
-
import {
|
|
5
|
+
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
6
|
import { Text } from "./text";
|
|
7
7
|
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
8
8
|
import { OptionList } from "./option_list";
|
|
@@ -217,13 +217,13 @@ function SelectTrigger<T extends string>({
|
|
|
217
217
|
const hasSelection = selectedItems.length > 0;
|
|
218
218
|
|
|
219
219
|
return (
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
// (
|
|
225
|
-
|
|
226
|
-
|
|
220
|
+
// The wash-free control base (FOCUS_RING + a11y, NO grey wash) — a Select is an
|
|
221
|
+
// input, so its hover affordance is its BORDER, never a content wash. The
|
|
222
|
+
// hover-border sits AFTER `style` so it wins over the caller's resting border:
|
|
223
|
+
// a bordered Select darkens its edge, and a borderless in-cell Select
|
|
224
|
+
// (`borderColor:"transparent"`) REVEALS one — both reading as "interactive",
|
|
225
|
+
// neither greying its content.
|
|
226
|
+
<FocusRingPressable
|
|
227
227
|
ref={ref}
|
|
228
228
|
testID={testID}
|
|
229
229
|
// Without a role this Pressable renders as an unfocusable <div> on web — the
|
|
@@ -233,7 +233,7 @@ function SelectTrigger<T extends string>({
|
|
|
233
233
|
accessibilityRole="button"
|
|
234
234
|
accessibilityLabel={accessibilityLabel}
|
|
235
235
|
accessibilityState={{ expanded: open, disabled }}
|
|
236
|
-
style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, state.hovered && !disabled && { borderColor: HOVER_BORDER }
|
|
236
|
+
style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, style, state.hovered && !disabled && { borderColor: HOVER_BORDER }]}
|
|
237
237
|
onPress={!disabled ? onPress : undefined}
|
|
238
238
|
disabled={disabled}
|
|
239
239
|
>
|
|
@@ -267,7 +267,7 @@ function SelectTrigger<T extends string>({
|
|
|
267
267
|
{placeholder}
|
|
268
268
|
</Text>
|
|
269
269
|
)}
|
|
270
|
-
</
|
|
270
|
+
</FocusRingPressable>
|
|
271
271
|
);
|
|
272
272
|
}
|
|
273
273
|
|