@joewinke/jatui 0.1.26 → 0.1.27

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.
@@ -26,6 +26,13 @@
26
26
  displayValue,
27
27
  colorFn,
28
28
  footer,
29
+ variant = 'default',
30
+ size = 'md',
31
+ dropup = false,
32
+ appendToBody = false,
33
+ clearable = false,
34
+ triggerAttrs,
35
+ embedded = false,
29
36
  onChange,
30
37
  }: {
31
38
  value: string;
@@ -35,13 +42,31 @@
35
42
  displayValue?: string;
36
43
  colorFn?: (value: string) => string | undefined;
37
44
  footer?: Snippet;
45
+ /** 'chip' renders a compact TopBar-style pill instead of the full-width trigger */
46
+ variant?: 'default' | 'chip';
47
+ /** 'sm' renders a smaller trigger matching text-[11px] compact UIs */
48
+ size?: 'sm' | 'md';
49
+ /** Open the panel above the trigger instead of below */
50
+ dropup?: boolean;
51
+ /** Render panel with position:fixed using viewport coordinates — escapes table/overflow stacking contexts */
52
+ appendToBody?: boolean;
53
+ /** Show an × button on the trigger when a value is selected */
54
+ clearable?: boolean;
55
+ /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
56
+ triggerAttrs?: Record<string, string>;
57
+ /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
58
+ embedded?: boolean;
38
59
  onChange: (value: string) => void;
39
60
  } = $props();
40
61
 
41
62
  let open = $state(false);
42
63
  let searchQuery = $state('');
43
- let searchInput = $state<HTMLInputElement | undefined>(undefined);
64
+ let highlightedIndex = $state(-1);
65
+ let panelStyle = $state('');
66
+ let searchInput: HTMLInputElement | undefined;
67
+ let optionListRef: HTMLUListElement | undefined;
44
68
  let containerRef: HTMLDivElement | undefined;
69
+ let triggerRef: HTMLButtonElement | undefined;
45
70
 
46
71
  // Find the currently selected option across all groups
47
72
  const selectedOption = $derived.by(() => {
@@ -55,6 +80,13 @@
55
80
  const triggerLabel = $derived(displayValue || selectedOption?.label || value || placeholder);
56
81
  const triggerIcon = $derived(selectedOption?.icon || '');
57
82
  const triggerImage = $derived(selectedOption?.image || '');
83
+
84
+ // Generate a deterministic hue from a string for initials avatars
85
+ function nameHue(s: string): number {
86
+ let h = 0;
87
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
88
+ return h % 360;
89
+ }
58
90
  const activeColor = $derived(colorFn ? colorFn(value) : undefined);
59
91
 
60
92
  const filteredGroups = $derived.by(() => {
@@ -70,12 +102,22 @@
70
102
  return result;
71
103
  });
72
104
 
73
- // Generate a deterministic hue from a string for initials avatars
74
- function nameHue(s: string): number {
75
- let h = 0;
76
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
77
- return h % 360;
78
- }
105
+ // Flat ordered list of visible options for keyboard navigation
106
+ const flatOptions = $derived.by(() => {
107
+ const opts: SearchDropdownOption[] = [];
108
+ for (const group of filteredGroups) {
109
+ for (const opt of group.options) opts.push(opt);
110
+ }
111
+ return opts;
112
+ });
113
+
114
+ // When search changes, reset highlight to first option (or to current value's position)
115
+ $effect(() => {
116
+ void filteredGroups;
117
+ if (!open && !embedded) return;
118
+ const idx = flatOptions.findIndex(o => o.value === value);
119
+ highlightedIndex = idx >= 0 ? idx : 0;
120
+ });
79
121
 
80
122
  function select(optionValue: string) {
81
123
  open = false;
@@ -83,6 +125,35 @@
83
125
  onChange(optionValue);
84
126
  }
85
127
 
128
+ function scrollHighlightedIntoView() {
129
+ tick().then(() => {
130
+ const el = optionListRef?.querySelector<HTMLElement>('[data-highlighted="true"]');
131
+ el?.scrollIntoView({ block: 'center' });
132
+ });
133
+ }
134
+
135
+ function handleSearchKeydown(e: KeyboardEvent) {
136
+ if (e.key === 'Escape') {
137
+ e.stopPropagation();
138
+ open = false;
139
+ searchQuery = '';
140
+ } else if (e.key === 'ArrowDown') {
141
+ e.preventDefault();
142
+ highlightedIndex = Math.min(highlightedIndex + 1, flatOptions.length - 1);
143
+ scrollHighlightedIntoView();
144
+ } else if (e.key === 'ArrowUp') {
145
+ e.preventDefault();
146
+ highlightedIndex = Math.max(highlightedIndex - 1, 0);
147
+ scrollHighlightedIntoView();
148
+ } else if (e.key === 'Enter') {
149
+ e.preventDefault();
150
+ e.stopPropagation();
151
+ if (highlightedIndex >= 0 && highlightedIndex < flatOptions.length) {
152
+ select(flatOptions[highlightedIndex].value);
153
+ }
154
+ }
155
+ }
156
+
86
157
  function handleClickOutside(e: MouseEvent) {
87
158
  if (containerRef && !containerRef.contains(e.target as Node)) {
88
159
  open = false;
@@ -91,142 +162,194 @@
91
162
  }
92
163
 
93
164
  $effect(() => {
165
+ if (embedded) {
166
+ tick().then(() => { searchInput?.focus(); scrollHighlightedIntoView(); });
167
+ return;
168
+ }
94
169
  if (open) {
170
+ if (appendToBody && triggerRef) {
171
+ const rect = triggerRef.getBoundingClientRect();
172
+ const width = Math.max(rect.width, 192);
173
+ // Viewport-aware flip: if there isn't room below the trigger but there
174
+ // is above, anchor the panel to the trigger's top and grow upward. This
175
+ // keeps the panel on-screen for rows near the bottom of a scroll area
176
+ // (e.g. suggested-task rows in the completed-session overlay).
177
+ const estPanelHeight = 300;
178
+ const spaceBelow = window.innerHeight - rect.bottom;
179
+ const spaceAbove = rect.top;
180
+ const flipUp = spaceBelow < estPanelHeight && spaceAbove > spaceBelow;
181
+ // Clamp left so a right-edge trigger doesn't push the panel off-screen.
182
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
183
+ const vertical = flipUp
184
+ ? `bottom: ${window.innerHeight - rect.top + 4}px;`
185
+ : `top: ${rect.bottom + 4}px;`;
186
+ panelStyle = `position: fixed; z-index: 9999; ${vertical} left: ${left}px; width: ${width}px; margin-top: 0;`;
187
+ }
95
188
  document.addEventListener('mousedown', handleClickOutside);
96
- tick().then(() => searchInput?.focus());
189
+ tick().then(() => { searchInput?.focus(); scrollHighlightedIntoView(); });
97
190
  return () => document.removeEventListener('mousedown', handleClickOutside);
98
191
  }
99
192
  });
193
+
194
+ export function openDropdown() {
195
+ open = true;
196
+ }
100
197
  </script>
101
198
 
102
- <div class="search-dropdown" bind:this={containerRef}>
103
- <button
104
- type="button"
105
- class="sd-trigger"
106
- class:sd-disabled={disabled}
107
- style={activeColor ? `color: ${activeColor};` : ''}
108
- onclick={() => { if (!disabled) open = !open; }}
109
- {disabled}
110
- >
111
- <span class="sd-trigger-label">
112
- {#if triggerImage}
113
- <img src={triggerImage} alt="" class="sd-avatar" />
114
- {:else if triggerIcon}
115
- <span class="sd-trigger-icon">{triggerIcon}</span>
116
- {:else if selectedOption && selectedOption.value}
117
- <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(selectedOption.label)})">
118
- {selectedOption.label.charAt(0).toUpperCase()}
119
- </span>
199
+ {#snippet panelBody()}
200
+ <!-- Search input -->
201
+ <div class="sd-search">
202
+ <div class="sd-search-inner">
203
+ <svg class="sd-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
204
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
205
+ </svg>
206
+ <input
207
+ bind:this={searchInput}
208
+ bind:value={searchQuery}
209
+ onkeydown={handleSearchKeydown}
210
+ type="text"
211
+ {placeholder}
212
+ class="sd-search-input"
213
+ autocomplete="off"
214
+ />
215
+ {#if searchQuery}
216
+ <button type="button" aria-label="Clear search" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
217
+ <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
218
+ </button>
120
219
  {/if}
121
- <span class="truncate">{triggerLabel}</span>
122
- </span>
123
- <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
124
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
125
- </svg>
126
- </button>
127
-
128
- {#if open}
129
- <div
130
- class="sd-panel"
131
- transition:slide={{ duration: 120 }}
132
- >
133
- <!-- Search input -->
134
- <div class="sd-search">
135
- <div class="sd-search-inner">
136
- <svg class="sd-search-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
137
- <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
138
- </svg>
139
- <input
140
- bind:this={searchInput}
141
- bind:value={searchQuery}
142
- onkeydown={(e) => {
143
- if (e.key === 'Escape') { e.stopPropagation(); open = false; searchQuery = ''; }
144
- }}
145
- type="text"
146
- {placeholder}
147
- class="sd-search-input"
148
- autocomplete="off"
149
- />
150
- {#if searchQuery}
151
- <button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear" aria-label="Clear search">
152
- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Options list -->
224
+ <ul class="sd-list" bind:this={optionListRef}>
225
+ {#if filteredGroups.length > 0}
226
+ {#each filteredGroups as group, _gi}
227
+ <li class="sd-group-label">
228
+ <span>{group.label}</span>
229
+ </li>
230
+ {#each group.options as option}
231
+ {@const flatIdx = flatOptions.indexOf(option)}
232
+ <li>
233
+ <button
234
+ type="button"
235
+ onclick={() => select(option.value)}
236
+ class="sd-option"
237
+ class:sd-option-selected={value === option.value}
238
+ class:sd-option-highlighted={flatIdx === highlightedIndex}
239
+ data-highlighted={flatIdx === highlightedIndex ? 'true' : undefined}
240
+ style={colorFn && colorFn(option.value)
241
+ ? variant === 'chip'
242
+ ? `--sd-color: ${colorFn(option.value)};`
243
+ : `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};`
244
+ : ''}
245
+ >
246
+ {#if option.image}
247
+ <img src={option.image} alt="" class="sd-avatar" />
248
+ {:else if option.icon}
249
+ <span class="sd-option-icon">{option.icon}</span>
250
+ {:else if variant !== 'chip' && option.value}
251
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(option.label)})">
252
+ {option.label.charAt(0).toUpperCase()}
253
+ </span>
254
+ {/if}
255
+ {#if variant === 'chip' && colorFn && colorFn(option.value)}
256
+ <span class="sd-option-chip">{option.label}</span>
257
+ {:else}
258
+ <span class="truncate">{option.label}</span>
259
+ {/if}
260
+ {#if value === option.value}
261
+ <svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
262
+ {/if}
153
263
  </button>
154
- {/if}
155
- </div>
156
- </div>
264
+ </li>
265
+ {/each}
266
+ {/each}
267
+ {:else}
268
+ <li class="sd-empty">No matches for "{searchQuery}"</li>
269
+ {/if}
270
+ </ul>
271
+ {#if footer}
272
+ <div class="sd-footer">
273
+ {@render footer()}
274
+ </div>
275
+ {/if}
276
+ {/snippet}
157
277
 
158
- <!-- Options list -->
159
- <ul class="sd-list">
160
- {#if filteredGroups.length > 0}
161
- {#each filteredGroups as group}
162
- <li class="sd-group-label">
163
- <span>{group.label}</span>
164
- </li>
165
- {#each group.options as option}
166
- <li>
167
- <button
168
- type="button"
169
- onclick={() => select(option.value)}
170
- class="sd-option"
171
- class:sd-option-selected={value === option.value}
172
- style={colorFn && colorFn(option.value) ? `color: ${colorFn(option.value)};` : ''}
173
- >
174
- {#if option.image}
175
- <img src={option.image} alt="" class="sd-avatar" />
176
- {:else if option.icon}
177
- <span class="sd-option-icon">{option.icon}</span>
178
- {:else if option.value}
179
- <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(option.label)})">
180
- {option.label.charAt(0).toUpperCase()}
181
- </span>
182
- {/if}
183
- <span class="truncate">{option.label}</span>
184
- {#if value === option.value}
185
- <svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
186
- {/if}
187
- </button>
188
- </li>
189
- {/each}
190
- {/each}
191
- {:else}
192
- <li class="sd-empty">
193
- <svg class="sd-empty-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
194
- <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 15.803 7.5 7.5 0 0015.803 15.803z" />
195
- </svg>
196
- No matches for "{searchQuery}"
197
- </li>
278
+ {#if embedded}
279
+ <div class="sd-embedded" class:sd-panel-sm={size === 'sm'}>
280
+ {@render panelBody()}
281
+ </div>
282
+ {:else}
283
+ <div class="search-dropdown" class:sd-chip-wrap={variant === 'chip'} bind:this={containerRef}>
284
+ <button
285
+ type="button"
286
+ class="sd-trigger"
287
+ class:sd-disabled={disabled}
288
+ class:sd-chip={variant === 'chip'}
289
+ class:sd-sm={size === 'sm'}
290
+ style={activeColor
291
+ ? variant === 'chip'
292
+ ? `--sd-color: ${activeColor};`
293
+ : `border-left-color: ${activeColor}; border-left-width: 3px; color: ${activeColor};`
294
+ : ''}
295
+ bind:this={triggerRef}
296
+ onclick={() => { if (!disabled) open = !open; }}
297
+ {disabled}
298
+ {...triggerAttrs}
299
+ >
300
+ <span class="sd-trigger-label">
301
+ {#if variant !== 'chip' && triggerImage}
302
+ <img src={triggerImage} alt="" class="sd-avatar" />
303
+ {:else if triggerIcon}
304
+ <span class="sd-trigger-icon">{triggerIcon}</span>
305
+ {:else if variant !== 'chip' && selectedOption && selectedOption.value}
306
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(selectedOption.label)})">
307
+ {selectedOption.label.charAt(0).toUpperCase()}
308
+ </span>
198
309
  {/if}
199
- </ul>
200
- {#if footer}
201
- <div class="sd-footer">
202
- {@render footer()}
203
- </div>
310
+ <span class="truncate">{triggerLabel}</span>
311
+ </span>
312
+ {#if clearable && value !== ''}
313
+ <button
314
+ type="button"
315
+ class="sd-clear"
316
+ aria-label="Clear filter"
317
+ onclick={(e) => { e.stopPropagation(); onChange(''); }}
318
+ >
319
+ <svg class="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
320
+ </button>
204
321
  {/if}
205
- </div>
206
- {/if}
207
- </div>
322
+ <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
323
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
324
+ </svg>
325
+ </button>
326
+
327
+ {#if open}
328
+ <div
329
+ class="sd-panel"
330
+ class:sd-panel-sm={size === 'sm'}
331
+ class:sd-panel-dropup={dropup && !appendToBody}
332
+ style={appendToBody ? panelStyle : undefined}
333
+ transition:slide={{ duration: 120 }}
334
+ >
335
+ {@render panelBody()}
336
+ </div>
337
+ {/if}
338
+ </div>
339
+ {/if}
208
340
 
209
341
  <style>
210
342
  .search-dropdown {
211
343
  position: relative;
212
- --sd-bg: var(--sd-color-bg, var(--color-base-100, Canvas));
213
- --sd-bg-hover: var(--sd-color-bg-hover, var(--color-base-200, color-mix(in oklch, Canvas 90%, CanvasText)));
214
- --sd-bg-selected: var(--sd-color-bg-selected, var(--color-base-300, color-mix(in oklch, Canvas 80%, CanvasText)));
215
- --sd-border: var(--sd-color-border, color-mix(in oklch, var(--color-base-content, CanvasText) 20%, transparent));
216
- --sd-text: var(--sd-color-text, var(--color-base-content, CanvasText));
217
- --sd-text-muted: var(--sd-color-text-muted, color-mix(in oklch, var(--color-base-content, CanvasText) 50%, transparent));
218
- --sd-text-label: var(--sd-color-text-label, color-mix(in oklch, var(--color-base-content, CanvasText) 45%, transparent));
219
- --sd-accent: var(--sd-color-accent, var(--color-primary, LinkText));
220
- --sd-success: var(--sd-color-success, var(--color-success, green));
221
344
  }
222
345
 
223
346
  /* Trigger button */
224
347
  .sd-trigger {
225
348
  width: 100%;
226
349
  padding: 0.25rem 0.5rem;
227
- border-radius: var(--rounded-btn, 0.5rem);
228
- font-family: inherit;
229
- font-size: inherit;
350
+ border-radius: 0.5rem;
351
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
352
+ font-size: 0.8125rem;
230
353
  text-align: left;
231
354
  display: flex;
232
355
  align-items: center;
@@ -235,28 +358,88 @@
235
358
  transition: background 0.15s, border-color 0.15s;
236
359
  min-height: 2rem;
237
360
  cursor: pointer;
238
- background: var(--sd-bg);
239
- border: 1px solid var(--sd-border);
240
- color: var(--sd-text);
361
+ background: oklch(0.16 0.01 250);
362
+ border: 1px solid oklch(0.25 0.02 250);
363
+ color: oklch(0.85 0.02 250);
241
364
  }
242
365
  .sd-trigger:hover:not(:disabled) {
243
- background: var(--sd-bg-hover);
366
+ background: oklch(0.18 0.01 250);
367
+ border-color: oklch(0.30 0.02 250);
244
368
  }
245
369
  .sd-trigger:disabled, .sd-disabled {
246
370
  opacity: 0.5;
247
371
  cursor: not-allowed;
248
372
  }
249
373
 
374
+ /* Small size variant — matches DaisyUI select-sm / input-sm */
375
+ .sd-sm {
376
+ font-size: 0.75rem;
377
+ min-height: 2rem;
378
+ padding: 0 0.75rem;
379
+ border-radius: var(--rounded-btn, 0.5rem);
380
+ background: var(--b2, oklch(0.16 0.01 250));
381
+ border: 1px solid oklch(from var(--bc, oklch(0.85 0.02 250)) l c h / 0.3);
382
+ color: var(--bc, oklch(0.85 0.02 250));
383
+ }
384
+ .sd-sm .sd-chevron {
385
+ width: 0.625rem;
386
+ height: 0.625rem;
387
+ }
388
+ .sd-panel-sm {
389
+ font-size: 0.75rem;
390
+ }
391
+ .sd-panel-sm .sd-search {
392
+ padding: 0.25rem 0.375rem;
393
+ }
394
+ .sd-panel-sm .sd-search-input {
395
+ font-size: 0.6875rem;
396
+ }
397
+ .sd-panel-sm .sd-option {
398
+ padding: 0.375rem 0.625rem;
399
+ font-size: 0.75rem;
400
+ }
401
+ .sd-panel-sm .sd-group-label {
402
+ font-size: 0.625rem;
403
+ padding: 0.25rem 0.625rem 0.125rem;
404
+ }
405
+
406
+ /* Chip variant — TopBar fav-chip style: full border, color-tinted background */
407
+ .sd-chip-wrap {
408
+ display: inline-flex;
409
+ }
410
+ /* Right-align the panel for chip variant so it doesn't spill past the container edge */
411
+ .sd-chip-wrap .sd-panel {
412
+ right: 0;
413
+ left: auto;
414
+ }
415
+ .sd-chip {
416
+ width: auto;
417
+ max-width: 10rem;
418
+ min-height: 1.5rem;
419
+ padding: 0.15rem 0.4rem;
420
+ border-radius: 0.375rem;
421
+ font-size: 0.6875rem;
422
+ font-weight: 600;
423
+ text-transform: uppercase;
424
+ letter-spacing: 0.025em;
425
+ background: transparent;
426
+ border: 1px solid color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 35%, transparent);
427
+ color: color-mix(in oklch, var(--sd-color, oklch(0.7 0 0)) 85%, oklch(0.80 0 0));
428
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, color 0.15s;
429
+ }
430
+ .sd-chip:hover:not(:disabled) {
431
+ background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
432
+ border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
433
+ color: var(--sd-color, oklch(0.80 0 0));
434
+ box-shadow: 0 0 6px color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
435
+ }
436
+
250
437
  .sd-trigger-label {
251
438
  display: flex;
252
439
  align-items: center;
253
440
  gap: 0.375rem;
254
441
  min-width: 0;
255
442
  }
256
- .sd-trigger-icon {
257
- flex-shrink: 0;
258
- font-size: 0.75rem;
259
- }
260
443
  .sd-avatar {
261
444
  width: 1.25rem;
262
445
  height: 1.25rem;
@@ -273,62 +456,110 @@
273
456
  color: oklch(1 0 0);
274
457
  line-height: 1;
275
458
  }
459
+ .sd-trigger-icon {
460
+ flex-shrink: 0;
461
+ font-size: 0.75rem;
462
+ }
463
+
464
+ .sd-clear {
465
+ display: flex;
466
+ align-items: center;
467
+ justify-content: center;
468
+ flex-shrink: 0;
469
+ padding: 0.1rem;
470
+ border-radius: 0.25rem;
471
+ background: none;
472
+ border: none;
473
+ cursor: pointer;
474
+ color: oklch(0.50 0.02 250);
475
+ transition: color 0.1s, background 0.1s;
476
+ }
477
+ .sd-clear:hover {
478
+ color: oklch(0.75 0.15 30);
479
+ background: oklch(0.75 0.15 30 / 0.12);
480
+ }
276
481
 
277
482
  .sd-chevron {
278
483
  width: 0.75rem;
279
484
  height: 0.75rem;
280
485
  flex-shrink: 0;
281
486
  transition: transform 0.15s;
282
- color: var(--sd-text-muted);
487
+ color: oklch(0.50 0.02 250);
283
488
  }
284
489
  .sd-chevron-open {
285
490
  transform: rotate(180deg);
286
491
  }
287
492
 
493
+ /* Embedded (inline) mode — panel content with no trigger or positioning */
494
+ .sd-embedded {
495
+ width: 100%;
496
+ }
497
+ .sd-embedded .sd-search {
498
+ background: transparent;
499
+ border-bottom: 1px solid oklch(0.22 0.02 250);
500
+ }
501
+ .sd-embedded .sd-list {
502
+ max-height: none;
503
+ }
504
+
288
505
  /* Dropdown panel */
289
506
  .sd-panel {
290
507
  position: absolute;
291
- z-index: 50;
508
+ z-index: 9999;
509
+ top: 100%;
292
510
  margin-top: 0.25rem;
293
- width: 100%;
294
- min-width: 12rem;
295
- border-radius: var(--rounded-box, 0.5rem);
511
+ width: max-content;
512
+ min-width: max(100%, 14rem);
513
+ border-radius: 0.5rem;
296
514
  overflow: hidden;
297
- box-shadow: 0 4px 24px oklch(0 0 0 / 0.15);
298
- background: var(--sd-bg);
299
- border: 1px solid var(--sd-border);
515
+ box-shadow: 0 4px 24px oklch(0 0 0 / 0.4);
516
+ background: oklch(0.16 0.01 250);
517
+ border: 1px solid oklch(0.25 0.02 250);
518
+ }
519
+
520
+ .sd-panel-dropup {
521
+ top: auto;
522
+ bottom: 100%;
523
+ margin-top: 0;
524
+ margin-bottom: 0.25rem;
525
+ box-shadow: 0 -4px 24px oklch(0 0 0 / 0.4);
300
526
  }
301
527
 
302
528
  /* Search section */
303
529
  .sd-search {
304
- padding: 0.375rem 0.625rem;
305
- border-bottom: 1px solid var(--sd-border);
530
+ padding: 0.375rem 0.5rem;
531
+ border-bottom: 1px solid oklch(0.25 0.02 250);
532
+ background: oklch(0.13 0.01 250);
306
533
  }
307
534
  .sd-search-inner {
308
535
  display: flex;
309
536
  align-items: center;
310
537
  gap: 0.375rem;
538
+ background: oklch(0.19 0.01 250);
539
+ border: 1px solid oklch(0.28 0.02 250);
540
+ border-radius: 0.25rem;
541
+ padding: 0.2rem 0.4rem;
311
542
  }
312
543
  .sd-search-icon {
313
544
  width: 0.75rem;
314
545
  height: 0.75rem;
315
546
  flex-shrink: 0;
316
- color: var(--sd-text-muted);
547
+ color: oklch(0.60 0.04 250);
317
548
  }
318
549
  .sd-search-input {
319
550
  width: 100%;
320
551
  background: transparent;
321
- font-size: inherit;
322
- font-family: inherit;
323
- color: var(--sd-text);
552
+ font-size: 0.6875rem;
553
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
554
+ color: oklch(0.85 0.02 250);
324
555
  border: none;
325
556
  outline: none;
326
557
  }
327
558
  .sd-search-input::placeholder {
328
- color: var(--sd-text-muted);
559
+ color: oklch(0.52 0.03 250);
329
560
  }
330
561
  .sd-search-clear {
331
- color: var(--sd-text-muted);
562
+ color: oklch(0.50 0.02 250);
332
563
  background: none;
333
564
  border: none;
334
565
  cursor: pointer;
@@ -336,7 +567,7 @@
336
567
  display: flex;
337
568
  }
338
569
  .sd-search-clear:hover {
339
- opacity: 0.8;
570
+ color: oklch(0.75 0.15 30);
340
571
  }
341
572
 
342
573
  /* Options list */
@@ -352,11 +583,12 @@
352
583
  padding: 0.375rem 0.75rem 0.125rem;
353
584
  }
354
585
  .sd-group-label span {
355
- font-size: 0.8125rem;
356
- font-family: inherit;
357
- font-weight: 400;
358
- letter-spacing: 0.005em;
359
- color: var(--sd-text-label);
586
+ font-size: 0.5625rem;
587
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
588
+ font-weight: 600;
589
+ text-transform: uppercase;
590
+ letter-spacing: 0.05em;
591
+ color: oklch(0.50 0.10 250);
360
592
  }
361
593
 
362
594
  .sd-option {
@@ -366,20 +598,63 @@
366
598
  align-items: center;
367
599
  gap: 0.5rem;
368
600
  text-align: left;
369
- font-size: inherit;
370
- font-family: inherit;
601
+ font-size: 0.6875rem;
602
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
371
603
  transition: background 0.1s;
372
604
  cursor: pointer;
373
605
  background: transparent;
374
606
  border: none;
375
- color: var(--sd-text);
607
+ border-left: 2px solid transparent;
608
+ color: oklch(0.80 0.02 250);
376
609
  }
377
610
  .sd-option:hover {
378
- background: var(--sd-bg-hover);
611
+ background: oklch(0.19 0.01 250);
379
612
  }
380
613
  .sd-option-selected {
381
- background: var(--sd-bg-selected);
614
+ background: oklch(0.20 0.02 250);
615
+ border-left-color: oklch(0.65 0.15 250);
616
+ }
617
+ .sd-option-highlighted {
618
+ background: oklch(0.22 0.02 250);
619
+ outline: 1px solid oklch(0.45 0.10 250 / 0.5);
620
+ outline-offset: -1px;
621
+ }
622
+ .sd-option-highlighted.sd-option-selected {
623
+ background: oklch(0.24 0.03 250);
624
+ }
625
+
626
+ /* Chip variant — options render as inline chip pills */
627
+ .sd-chip-wrap .sd-option {
628
+ border-left: none;
629
+ padding: 0.25rem 0.625rem;
630
+ }
631
+ .sd-chip-wrap .sd-option-selected {
632
+ background: oklch(0.19 0.01 250);
633
+ border-left: none;
634
+ }
635
+ .sd-option-chip {
636
+ display: inline-flex;
637
+ align-items: center;
638
+ padding: 0.1rem 0.45rem;
639
+ border-radius: 0.3rem;
640
+ font-size: 0.6875rem;
382
641
  font-weight: 600;
642
+ text-transform: uppercase;
643
+ letter-spacing: 0.025em;
644
+ border: 1px solid color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 35%, transparent);
645
+ color: color-mix(in oklch, var(--sd-color, oklch(0.7 0 0)) 85%, oklch(0.80 0 0));
646
+ background: transparent;
647
+ transition: background 0.1s, border-color 0.1s, color 0.1s;
648
+ }
649
+ .sd-chip-wrap .sd-option:hover .sd-option-chip {
650
+ background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 12%, transparent);
651
+ border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
652
+ color: var(--sd-color, oklch(0.80 0 0));
653
+ }
654
+ .sd-chip-wrap .sd-option-selected .sd-option-chip {
655
+ background: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 15%, transparent);
656
+ border-color: color-mix(in oklch, var(--sd-color, oklch(0.5 0 0)) 50%, transparent);
657
+ color: var(--sd-color, oklch(0.80 0 0));
383
658
  }
384
659
 
385
660
  .sd-option-icon {
@@ -394,28 +669,18 @@
394
669
  height: 0.75rem;
395
670
  flex-shrink: 0;
396
671
  margin-left: auto;
397
- color: var(--sd-success);
672
+ color: oklch(0.70 0.15 145);
398
673
  }
399
674
 
400
675
  .sd-footer {
401
- border-top: 1px solid var(--sd-border);
676
+ border-top: 1px solid oklch(0.22 0.02 250);
402
677
  }
403
678
 
404
679
  .sd-empty {
405
- padding: 1rem 0.75rem;
680
+ padding: 0.75rem;
406
681
  text-align: center;
407
- font-size: inherit;
408
- font-family: inherit;
409
- color: var(--sd-text-muted);
410
- display: flex;
411
- flex-direction: column;
412
- align-items: center;
413
- gap: 0.375rem;
414
- }
415
-
416
- .sd-empty-icon {
417
- width: 1.25rem;
418
- height: 1.25rem;
419
- opacity: 0.35;
682
+ font-size: 0.625rem;
683
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
684
+ color: oklch(0.45 0.02 250);
420
685
  }
421
686
  </style>
@@ -17,8 +17,24 @@ type $$ComponentProps = {
17
17
  displayValue?: string;
18
18
  colorFn?: (value: string) => string | undefined;
19
19
  footer?: Snippet;
20
+ /** 'chip' renders a compact TopBar-style pill instead of the full-width trigger */
21
+ variant?: 'default' | 'chip';
22
+ /** 'sm' renders a smaller trigger matching text-[11px] compact UIs */
23
+ size?: 'sm' | 'md';
24
+ /** Open the panel above the trigger instead of below */
25
+ dropup?: boolean;
26
+ /** Render panel with position:fixed using viewport coordinates — escapes table/overflow stacking contexts */
27
+ appendToBody?: boolean;
28
+ /** Show an × button on the trigger when a value is selected */
29
+ clearable?: boolean;
30
+ /** Extra attributes forwarded to the trigger button (e.g. data-rail-id) */
31
+ triggerAttrs?: Record<string, string>;
32
+ /** Render inline without a trigger button — panel content is always visible, no dropdown behavior */
33
+ embedded?: boolean;
20
34
  onChange: (value: string) => void;
21
35
  };
22
- declare const SearchDropdown: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ declare const SearchDropdown: import("svelte").Component<$$ComponentProps, {
37
+ openDropdown: () => void;
38
+ }, "">;
23
39
  type SearchDropdown = ReturnType<typeof SearchDropdown>;
24
40
  export default SearchDropdown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",