@kahitsan/ksui 0.10.0 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -87,6 +87,11 @@ export interface ComboBoxMultiProps<T> extends ComboBoxCommonProps<T> {
87
87
  /** Focus the inline input on mount (marks the wrapper with `data-autofocus`
88
88
  * for a host modal's focus helper). */
89
89
  autoFocusOnMount?: boolean;
90
+ /** Close the results popup after each add/create instead of keeping it open
91
+ * for rapid multi-add. The input keeps focus and the popup reopens on the
92
+ * next keystroke. Use when the popup overlays a click target below it (e.g.
93
+ * the POS package grid) so a lingering popup would eat the next click. */
94
+ closeOnSelect?: boolean;
90
95
  }
91
96
 
92
97
  export type ComboBoxProps<T> = ComboBoxSingleProps<T> | ComboBoxMultiProps<T>;
@@ -355,6 +360,11 @@ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
355
360
 
356
361
  const addToPool = (item: T) => {
357
362
  if (props.value.some((x) => idOf(x) === idOf(item))) return;
363
+ // Close the popup BEFORE mutating the value when closeOnSelect: otherwise the
364
+ // about-to-be-hidden results list re-renders against the new value first,
365
+ // which the user sees as a flicker. resetInput re-focuses the input, so the
366
+ // next keystroke reopens the popup (the input's onInput re-opens it).
367
+ if (props.closeOnSelect) eng.setOpen(false);
358
368
  props.onChange([...props.value, item]);
359
369
  resetInput();
360
370
  };
@@ -555,53 +565,64 @@ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
555
565
  {eng.trimmedQuery() ? `No matching ${props.noun}s.` : `Start typing to find a ${props.noun}…`}
556
566
  </div>
557
567
  </Show>
558
- <For each={displayOptions()}>
559
- {(opt, i) => {
560
- const isFocused = () => focusedIdx() === i();
561
- const secondary = () => (opt.create ? null : props.secondaryOf?.(opt.item) ?? null);
568
+ {/* The create row (index 0 when shown) is rendered separately from
569
+ the results so the results <For> iterates the STABLE search-result
570
+ objects and reconciles by reference selecting one removes just
571
+ that node instead of tearing the whole list down (which flickered).
572
+ Keyboard focus still indexes the combined list: 0 = create row,
573
+ results start after it. */}
574
+ <Show when={showCreateOption()}>
575
+ <button
576
+ type="button"
577
+ role="option"
578
+ aria-selected={focusedIdx() === 0}
579
+ data-testid={tid("create")}
580
+ onMouseEnter={() => setFocusedIdx(0)}
581
+ onClick={() => void createAndAdd()}
582
+ disabled={eng.creating()}
583
+ class={`w-full flex items-start gap-2 px-3 py-2 text-left text-sm transition-colors border-b border-zinc-800 ${
584
+ focusedIdx() === 0 ? "bg-amber-500/15 text-amber-200" : "text-zinc-100 hover:bg-zinc-800"
585
+ }`}
586
+ >
587
+ <Show
588
+ when={!eng.creating()}
589
+ fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0 mt-0.5" />}
590
+ >
591
+ <UserPlus size={14} class="text-emerald-400 shrink-0 mt-0.5" />
592
+ </Show>
593
+ <span class="flex-1 text-emerald-300">
594
+ New {props.noun} "<span class="font-medium">{eng.trimmedQuery()}</span>"
595
+ </span>
596
+ </button>
597
+ </Show>
598
+ <For each={filteredResults()}>
599
+ {(item, i) => {
600
+ const displayIdx = () => i() + (showCreateOption() ? 1 : 0);
601
+ const isFocused = () => focusedIdx() === displayIdx();
602
+ const secondary = () => props.secondaryOf?.(item) ?? null;
562
603
  return (
563
604
  <button
564
605
  type="button"
565
606
  role="option"
566
607
  aria-selected={isFocused()}
567
- data-testid={opt.create ? tid("create") : `${tid("result")}-${idOf(opt.item)}`}
568
- onMouseEnter={() => setFocusedIdx(i())}
569
- onClick={() => selectOption(opt)}
570
- disabled={opt.create && eng.creating()}
608
+ data-testid={`${tid("result")}-${idOf(item)}`}
609
+ onMouseEnter={() => setFocusedIdx(displayIdx())}
610
+ onClick={() => addToPool(item)}
571
611
  class={`w-full flex items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
572
612
  isFocused() ? "bg-amber-500/15 text-amber-200" : "text-zinc-100 hover:bg-zinc-800"
573
- } ${opt.create ? "border-t border-zinc-800" : ""}`}
613
+ }`}
574
614
  >
575
- <Show
576
- when={opt.create}
577
- fallback={<Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />}
578
- >
579
- <Show
580
- when={!eng.creating()}
581
- fallback={<Loader2 size={14} class="animate-spin text-emerald-400 shrink-0 mt-0.5" />}
582
- >
583
- <UserPlus size={14} class="text-emerald-400 shrink-0 mt-0.5" />
584
- </Show>
585
- </Show>
586
- <Show
587
- when={opt.create}
588
- fallback={
589
- <span class="flex-1 min-w-0">
590
- <span class="block truncate font-medium">
591
- {highlightMatch(props.labelOf((opt as { create: false; item: T }).item), eng.debouncedQuery().trim())}
592
- </span>
593
- <Show when={secondary()}>
594
- <span class="block truncate text-[11px] text-zinc-500">
595
- {highlightMatch(secondary()!, eng.debouncedQuery().trim())}
596
- </span>
597
- </Show>
598
- </span>
599
- }
600
- >
601
- <span class="flex-1 text-emerald-300">
602
- New {props.noun} "<span class="font-medium">{(opt as { create: true; name: string }).name}</span>"
615
+ <Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />
616
+ <span class="flex-1 min-w-0">
617
+ <span class="block truncate font-medium">
618
+ {highlightMatch(props.labelOf(item), eng.debouncedQuery().trim())}
603
619
  </span>
604
- </Show>
620
+ <Show when={secondary()}>
621
+ <span class="block truncate text-[11px] text-zinc-500">
622
+ {highlightMatch(secondary()!, eng.debouncedQuery().trim())}
623
+ </span>
624
+ </Show>
625
+ </span>
605
626
  </button>
606
627
  );
607
628
  }}