@joewinke/jatui 0.6.2 → 0.6.3

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.
@@ -35,6 +35,8 @@
35
35
  triggerAttrs,
36
36
  embedded = false,
37
37
  onChange,
38
+ onCreate,
39
+ createLabel = (q: string) => `Create "${q}"`,
38
40
  }: {
39
41
  value: string;
40
42
  groups: SearchDropdownGroup[];
@@ -60,6 +62,16 @@
60
62
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
61
63
  embedded?: boolean;
62
64
  onChange: (value: string) => void;
65
+ /**
66
+ * When set, an always-present "create" row is rendered as the last option
67
+ * whenever the search box is non-empty — it survives a zero-match filter (so
68
+ * a brand-new value the list doesn't contain is still actionable) and is
69
+ * called with the trimmed query. Enables the "add something not in the list"
70
+ * pattern (e.g. create a reviewer by typed name).
71
+ */
72
+ onCreate?: (query: string) => void;
73
+ /** Label for the create row; receives the trimmed query. */
74
+ createLabel?: (query: string) => string;
63
75
  } = $props();
64
76
 
65
77
  let open = $state(false);
@@ -114,6 +126,14 @@
114
126
  return opts;
115
127
  });
116
128
 
129
+ // The always-present "create" row shows whenever onCreate is wired and the
130
+ // search box has content — independent of whether anything matched, so a
131
+ // value the list doesn't contain is still actionable. It sits at keyboard
132
+ // index === flatOptions.length (one past the last real option).
133
+ const trimmedQuery = $derived(searchQuery.trim());
134
+ const canCreate = $derived(!!onCreate && trimmedQuery.length > 0);
135
+ const maxIndex = $derived(flatOptions.length - 1 + (canCreate ? 1 : 0));
136
+
117
137
  // When search changes, reset highlight to first option (or to current value's position)
118
138
  $effect(() => {
119
139
  void filteredGroups;
@@ -128,6 +148,14 @@
128
148
  onChange(optionValue);
129
149
  }
130
150
 
151
+ function doCreate() {
152
+ const q = trimmedQuery;
153
+ if (!onCreate || !q) return;
154
+ open = false;
155
+ searchQuery = '';
156
+ onCreate(q);
157
+ }
158
+
131
159
  function scrollHighlightedIntoView() {
132
160
  tick().then(() => {
133
161
  const el = optionListRef?.querySelector<HTMLElement>('[data-highlighted="true"]');
@@ -142,7 +170,7 @@
142
170
  searchQuery = '';
143
171
  } else if (e.key === 'ArrowDown') {
144
172
  e.preventDefault();
145
- highlightedIndex = Math.min(highlightedIndex + 1, flatOptions.length - 1);
173
+ highlightedIndex = Math.min(highlightedIndex + 1, maxIndex);
146
174
  scrollHighlightedIntoView();
147
175
  } else if (e.key === 'ArrowUp') {
148
176
  e.preventDefault();
@@ -153,6 +181,8 @@
153
181
  e.stopPropagation();
154
182
  if (highlightedIndex >= 0 && highlightedIndex < flatOptions.length) {
155
183
  select(flatOptions[highlightedIndex].value);
184
+ } else if (canCreate && highlightedIndex === flatOptions.length) {
185
+ doCreate();
156
186
  }
157
187
  }
158
188
  }
@@ -281,9 +311,24 @@
281
311
  </li>
282
312
  {/each}
283
313
  {/each}
284
- {:else}
314
+ {:else if !canCreate}
285
315
  <li class="sd-empty">No matches for "{searchQuery}"</li>
286
316
  {/if}
317
+ {#if canCreate}
318
+ {@const createIdx = flatOptions.length}
319
+ <li>
320
+ <button
321
+ type="button"
322
+ onclick={doCreate}
323
+ class="sd-option sd-option-create"
324
+ class:sd-option-highlighted={highlightedIndex === createIdx}
325
+ data-highlighted={highlightedIndex === createIdx ? 'true' : undefined}
326
+ >
327
+ <span class="sd-option-icon">+</span>
328
+ <span class="truncate">{createLabel(trimmedQuery)}</span>
329
+ </button>
330
+ </li>
331
+ {/if}
287
332
  </ul>
288
333
  {#if footer}
289
334
  <div class="sd-footer">
@@ -691,6 +736,16 @@
691
736
  color: var(--color-success, oklch(0.70 0.15 145));
692
737
  }
693
738
 
739
+ /* Create row — the always-present "add something not in the list" action */
740
+ .sd-option-create {
741
+ border-top: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.22 0.02 250)) 20%, transparent);
742
+ color: color-mix(in oklch, var(--color-primary, oklch(0.65 0.15 250)) 90%, var(--color-base-content, oklch(0.80 0 0)));
743
+ font-weight: 600;
744
+ }
745
+ .sd-option-create .sd-option-icon {
746
+ color: var(--color-primary, oklch(0.65 0.15 250));
747
+ }
748
+
694
749
  .sd-footer {
695
750
  border-top: 1px solid color-mix(in oklch, var(--color-base-content, oklch(0.22 0.02 250)) 25%, transparent);
696
751
  }
@@ -34,6 +34,16 @@ type $$ComponentProps = {
34
34
  /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
35
35
  embedded?: boolean;
36
36
  onChange: (value: string) => void;
37
+ /**
38
+ * When set, an always-present "create" row is rendered as the last option
39
+ * whenever the search box is non-empty — it survives a zero-match filter (so
40
+ * a brand-new value the list doesn't contain is still actionable) and is
41
+ * called with the trimmed query. Enables the "add something not in the list"
42
+ * pattern (e.g. create a reviewer by typed name).
43
+ */
44
+ onCreate?: (query: string) => void;
45
+ /** Label for the create row; receives the trimmed query. */
46
+ createLabel?: (query: string) => string;
37
47
  };
38
48
  declare const SearchDropdown: import("svelte").Component<$$ComponentProps, {
39
49
  openDropdown: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",