@kahitsan/ksui 0.10.1 → 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.1",
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",
@@ -360,11 +360,13 @@ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
360
360
 
361
361
  const addToPool = (item: T) => {
362
362
  if (props.value.some((x) => idOf(x) === idOf(item))) return;
363
- props.onChange([...props.value, item]);
364
- resetInput();
365
- // resetInput re-focuses the input; closing after that keeps focus so the
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
366
  // next keystroke reopens the popup (the input's onInput re-opens it).
367
367
  if (props.closeOnSelect) eng.setOpen(false);
368
+ props.onChange([...props.value, item]);
369
+ resetInput();
368
370
  };
369
371
 
370
372
  const removeFromPool = (item: T) => {
@@ -563,53 +565,64 @@ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
563
565
  {eng.trimmedQuery() ? `No matching ${props.noun}s.` : `Start typing to find a ${props.noun}…`}
564
566
  </div>
565
567
  </Show>
566
- <For each={displayOptions()}>
567
- {(opt, i) => {
568
- const isFocused = () => focusedIdx() === i();
569
- 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;
570
603
  return (
571
604
  <button
572
605
  type="button"
573
606
  role="option"
574
607
  aria-selected={isFocused()}
575
- data-testid={opt.create ? tid("create") : `${tid("result")}-${idOf(opt.item)}`}
576
- onMouseEnter={() => setFocusedIdx(i())}
577
- onClick={() => selectOption(opt)}
578
- disabled={opt.create && eng.creating()}
608
+ data-testid={`${tid("result")}-${idOf(item)}`}
609
+ onMouseEnter={() => setFocusedIdx(displayIdx())}
610
+ onClick={() => addToPool(item)}
579
611
  class={`w-full flex items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
580
612
  isFocused() ? "bg-amber-500/15 text-amber-200" : "text-zinc-100 hover:bg-zinc-800"
581
- } ${opt.create ? "border-t border-zinc-800" : ""}`}
613
+ }`}
582
614
  >
583
- <Show
584
- when={opt.create}
585
- fallback={<Icon size={14} class="text-zinc-500 shrink-0 mt-0.5" />}
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
- </Show>
594
- <Show
595
- when={opt.create}
596
- fallback={
597
- <span class="flex-1 min-w-0">
598
- <span class="block truncate font-medium">
599
- {highlightMatch(props.labelOf((opt as { create: false; item: T }).item), eng.debouncedQuery().trim())}
600
- </span>
601
- <Show when={secondary()}>
602
- <span class="block truncate text-[11px] text-zinc-500">
603
- {highlightMatch(secondary()!, eng.debouncedQuery().trim())}
604
- </span>
605
- </Show>
606
- </span>
607
- }
608
- >
609
- <span class="flex-1 text-emerald-300">
610
- 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())}
611
619
  </span>
612
- </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>
613
626
  </button>
614
627
  );
615
628
  }}