@lotics/ui 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -227,7 +227,10 @@ parts render the CHROME, composed as children — never a render-prop pile:
227
227
 
228
228
  It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
229
229
  search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
230
- large/remote set). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
230
+ large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
231
+ the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
232
+ pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
233
+ set (on focus an empty field already browses everything — `recentOptions ?? all`). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
231
234
  matches no option, emitting the typed text as the value — so a value not in the known set means
232
235
  CREATE. Wire that branch to a create overlay (a modal `Dialog` for 4+ fields, an anchored popover
233
236
  for 1–3) that builds the new record and attaches it; existing matches attach directly. The create
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.7.0",
3
+ "version": "7.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/combobox.tsx CHANGED
@@ -182,7 +182,13 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
182
182
  const single = value ?? null;
183
183
  const reflectedText = single ? (single.label ?? single.value) : "";
184
184
 
185
- const [query, setQuery] = useState(reflectSelection && single ? reflectedText : "");
185
+ // The live edit buffer. `null` = the user isn't editing: the input reflects the
186
+ // committed selection's label and the list browses the FULL set. A string (incl.
187
+ // "") = the user is searching: the input shows it and the list filters by it.
188
+ // This one nullable carries the bit that (value, inputText) alone can't — after
189
+ // selecting FPP, the input reads "FPP" whether the user just reopened (browse)
190
+ // or retyped it (filter); `draft` is null in the first case, "FPP" in the second.
191
+ const [draft, setDraft] = useState<string | null>(null);
186
192
  const [open, setOpen] = useState(autoFocus);
187
193
  const triggerRef = useRef<View>(null);
188
194
  const inputRef = useRef<RNTextInput>(null);
@@ -197,9 +203,11 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
197
203
  const isServer = onSearchChange !== undefined;
198
204
  const debouncedSearch = useDebouncedCallback(onSearchChange ?? (() => {}), searchDebounceMs);
199
205
 
200
- // The displayed text equals the reflected selection until the user types show
201
- // ALL options then (browse), not the list filtered to the selected label.
202
- const filterQuery = reflectSelection && single && query === reflectedText ? "" : query;
206
+ // What the input shows: the live draft while editing, else the reflected label.
207
+ const inputText = draft ?? (reflectSelection && single ? reflectedText : "");
208
+ // What the list filters by: nothing while reflecting a selection (browse), the
209
+ // draft while editing — so retyping the selected label still filters to it.
210
+ const filterQuery = draft ?? "";
203
211
 
204
212
  const findOption = useCallback(
205
213
  (val: T): PickerOption<T, D> =>
@@ -215,7 +223,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
215
223
  debouncedSearch.cancel();
216
224
  onSearchChange?.("");
217
225
  }
218
- setQuery(reflectSelection ? (opt.label ?? opt.value) : "");
226
+ // Stop editing — the input reflects the new label (or clears, when
227
+ // reflectSelection is off) and the next open browses the full set.
228
+ setDraft(null);
219
229
  setOpen(false);
220
230
  // The refocus below must not reopen the just-closed menu.
221
231
  suppressFocusOpenRef.current = true;
@@ -247,7 +257,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
247
257
 
248
258
  const handleChangeText = useCallback(
249
259
  (text: string) => {
250
- setQuery(text);
260
+ setDraft(text);
251
261
  if (!disabled) setOpen(true);
252
262
  if (isServer) debouncedSearch(text);
253
263
  },
@@ -285,7 +295,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
285
295
  // Boundary cast: the engine is generic per-instance, the context type is not.
286
296
  // `ComboboxContent` casts back to its own `<T, D>` before touching rows.
287
297
  list: list as unknown as UseOptionList<string, unknown>,
288
- query,
298
+ query: inputText,
289
299
  open,
290
300
  showList,
291
301
  loading,
@@ -349,9 +359,14 @@ export interface ComboboxInputProps {
349
359
  export function ComboboxInput(props: ComboboxInputProps) {
350
360
  const { icon, placeholder, clearable = false, onClear, clearLabel, accessibilityLabel, testID } = props;
351
361
  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.
354
- const showChevron = !icon && !clearable;
362
+ // Select mode (no leading icon): the right slot holds the clear when the field
363
+ // carries text (a reflected selection or a live query) AND it's clearable;
364
+ // otherwise the trailing chevron, so an EMPTY picker still reads as a picker.
365
+ // The two never show together — ✕ replaces the chevron once there's something to
366
+ // clear, and pressing it resets the query + reopens the browse list (via
367
+ // `handleChangeText("")`) so the consumer's `onClear` lands back on the full set.
368
+ const showClear = clearable && ctx.query.length > 0;
369
+ const showChevron = !icon && !showClear;
355
370
 
356
371
  return (
357
372
  <View ref={ctx.triggerRef}>
@@ -375,7 +390,9 @@ export function ComboboxInput(props: ComboboxInputProps) {
375
390
  autoCorrect={false}
376
391
  accessibilityLabel={accessibilityLabel}
377
392
  style={[
378
- showChevron ? styles.selectInput : undefined,
393
+ // Select mode reserves the right gutter for whichever trailing affordance
394
+ // is up (chevron when empty, ✕ when there's text) so neither overlaps it.
395
+ !icon ? styles.selectInput : undefined,
379
396
  // Gate on `showList`, not `open`: an empty query with no options has
380
397
  // `open=true` but renders NO dropdown, so a ring tied to `open` would
381
398
  // show with nothing below it AND linger after blur (no popover fires a