picocrank 1.21.5 → 1.22.1

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.
@@ -1,47 +1,25 @@
1
1
  <template>
2
- <!-- Mobile: collapse to a search button in the header -->
3
- <button
4
- v-if="isMobile"
5
- type="button"
6
- class="quick-search-trigger neutral"
7
- aria-label="Open search"
8
- aria-haspopup="dialog"
9
- :aria-expanded="overlayOpen"
10
- @click="openOverlay"
11
- >
12
- <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
13
- </button>
14
-
15
- <Teleport to="body" :disabled="!isMobile">
16
- <div
17
- v-if="!isMobile || overlayOpen"
18
- :class="{ 'quick-search-overlay-root': isMobile }"
2
+ <div class="quick-search">
3
+ <button
4
+ type="button"
5
+ class="neutral search-trigger"
6
+ :aria-label="buttonLabel"
7
+ :title="buttonTitle"
8
+ aria-haspopup="dialog"
9
+ :aria-expanded="isOpen"
10
+ @click="open"
19
11
  >
20
- <div
21
- v-if="isMobile"
22
- class="quick-search-backdrop"
23
- aria-hidden="true"
24
- @click="closeOverlay"
25
- />
26
-
27
- <div
28
- :class="['quick-search', { 'quick-search--overlay': isMobile }]"
29
- :role="isMobile ? 'dialog' : undefined"
30
- :aria-modal="isMobile ? 'true' : undefined"
31
- :aria-label="isMobile ? 'Search' : undefined"
32
- >
33
- <div v-if="isMobile" class="quick-search-overlay-header">
34
- <span class="quick-search-overlay-title">Search</span>
35
- <button
36
- type="button"
37
- class="quick-search-overlay-close neutral"
38
- aria-label="Close search"
39
- @click="closeOverlay"
40
- >
41
- <HugeiconsIcon :icon="Cancel01Icon" width="1em" height="1em" />
42
- </button>
43
- </div>
44
-
12
+ <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
13
+ </button>
14
+
15
+ <dialog
16
+ ref="dialogRef"
17
+ class="quick-search-dialog"
18
+ :aria-busy="hasAsyncSearch && isLoading ? 'true' : undefined"
19
+ @close="onDialogClose"
20
+ @click="onDialogBackdropClick"
21
+ >
22
+ <div class="dialog-body" @click.stop>
45
23
  <div class="search-container">
46
24
  <input
47
25
  ref="searchInput"
@@ -49,21 +27,41 @@
49
27
  type="text"
50
28
  class="search-input"
51
29
  :placeholder="placeholder"
52
- @input="onSearch"
53
- @focus="onFocus"
54
- @blur="onBlur"
30
+ @input="onSearchInput"
55
31
  @keydown="onKeydown"
56
32
  />
57
- <div class="search-icon">
58
- <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
33
+ <div class="search-icon" aria-hidden="true">
34
+ <HugeiconsIcon
35
+ :icon="hasAsyncSearch && isLoading ? Loading03Icon : SearchIcon"
36
+ width="1em"
37
+ height="1em"
38
+ :class="{ 'is-spinning': hasAsyncSearch && isLoading }"
39
+ />
59
40
  </div>
60
41
  </div>
61
42
 
62
- <div v-if="showResults && filteredItems.length > 0" class="search-results">
43
+ <p
44
+ v-if="hasAsyncSearch && isLoading"
45
+ class="search-status subtle"
46
+ aria-live="polite"
47
+ >
48
+ Searching…
49
+ </p>
50
+ <p
51
+ v-else-if="hasAsyncSearch && searchError"
52
+ class="search-status subtle"
53
+ role="alert"
54
+ >
55
+ {{ searchErrorMessage }}
56
+ </p>
57
+
58
+ <div v-if="displayedItems.length > 0" class="search-results" role="listbox">
63
59
  <div
64
- v-for="(item, index) in filteredItems"
60
+ v-for="(item, index) in displayedItems"
65
61
  :key="item.id"
66
62
  :class="['search-result-item', { active: selectedIndex === index }]"
63
+ role="option"
64
+ :aria-selected="selectedIndex === index"
67
65
  @click="selectItem(item)"
68
66
  @mouseenter="selectedIndex = index"
69
67
  >
@@ -72,36 +70,56 @@
72
70
  </div>
73
71
  <div class="result-content">
74
72
  <div class="result-title" v-html="highlightText(item.title, searchQuery)"></div>
75
- <div v-if="item.description" class="result-description" v-html="highlightText(item.description, searchQuery)"></div>
73
+ <div
74
+ v-if="item.description"
75
+ class="result-description"
76
+ v-html="highlightText(item.description, searchQuery)"
77
+ ></div>
76
78
  <div v-if="item.category" class="result-category">{{ item.category }}</div>
77
79
  </div>
78
80
  </div>
79
81
  </div>
80
82
 
81
- <div v-if="showResults && searchQuery && filteredItems.length === 0" class="no-results">
83
+ <div
84
+ v-else-if="searchQuery && !(hasAsyncSearch && isLoading)"
85
+ class="no-results"
86
+ >
82
87
  <div class="no-results-content">
83
88
  <HugeiconsIcon :icon="SearchRemoveIcon" width="2em" height="2em" />
84
89
  <p>No results found for "{{ searchQuery }}"</p>
85
90
  </div>
86
91
  </div>
92
+
93
+ <p v-else-if="!searchQuery" class="search-hint subtle">
94
+ Type to search. Use ↑ ↓ and Enter to choose.
95
+ </p>
87
96
  </div>
88
- </div>
89
- </Teleport>
97
+ </dialog>
98
+ </div>
90
99
  </template>
91
100
 
92
101
  <script setup>
93
102
  import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
94
103
  import { useRouter } from 'vue-router'
95
104
  import { HugeiconsIcon } from '@hugeicons/vue'
96
- import { SearchIcon, SearchRemoveIcon, ViewIcon, Cancel01Icon } from '@hugeicons/core-free-icons'
97
- import { useMediaQuery } from '../composables/useMediaQuery.js'
98
- import { MOBILE_NAV_QUERY } from '../composables/useResponsiveNav.js'
105
+ import { SearchIcon } from '@hugeicons/core-free-icons'
106
+ import { SearchRemoveIcon } from '@hugeicons/core-free-icons'
107
+ import { Loading03Icon } from '@hugeicons/core-free-icons'
108
+ import { ViewIcon } from '@hugeicons/core-free-icons'
99
109
 
100
110
  const props = defineProps({
101
111
  placeholder: {
102
112
  type: String,
103
113
  default: 'Search...'
104
114
  },
115
+ buttonLabel: {
116
+ type: String,
117
+ default: 'Search',
118
+ },
119
+ buttonTitle: {
120
+ type: String,
121
+ default: 'Search (Ctrl+K)',
122
+ },
105
123
  items: {
106
124
  type: Array,
107
125
  default: () => []
@@ -125,24 +143,56 @@ const props = defineProps({
125
143
  autoImportRoutes: {
126
144
  type: Boolean,
127
145
  default: true
128
- }
146
+ },
147
+ /**
148
+ * Optional async search provider.
149
+ * When set, QuickSearch shows loading feedback and cancels in-flight requests.
150
+ * Signature: async (query, { signal }) => Item[]
151
+ */
152
+ fetchResults: {
153
+ type: Function,
154
+ default: null,
155
+ },
129
156
  })
130
157
 
131
- const emit = defineEmits(['select', 'search', 'focus', 'blur'])
158
+ const emit = defineEmits([
159
+ 'select',
160
+ 'search',
161
+ 'search-error',
162
+ 'focus',
163
+ 'blur',
164
+ 'open',
165
+ 'close',
166
+ ])
132
167
 
133
168
  const router = useRouter()
134
- const isMobile = useMediaQuery(MOBILE_NAV_QUERY)
135
- const overlayOpen = ref(false)
169
+ const dialogRef = ref(null)
136
170
  const searchInput = ref(null)
137
171
  const searchQuery = ref('')
138
- const showResults = ref(false)
172
+ const isOpen = ref(false)
139
173
  const selectedIndex = ref(-1)
140
174
  const items = ref([...props.items])
175
+ const remoteItems = ref([])
176
+ const isLoading = ref(false)
177
+ const searchError = ref(null)
141
178
  const debounceTimer = ref(null)
179
+ let abortController = null
180
+
181
+ const hasAsyncSearch = computed(() => typeof props.fetchResults === 'function')
182
+
183
+ const searchErrorMessage = computed(() => {
184
+ if (!searchError.value) {
185
+ return ''
186
+ }
187
+ if (typeof searchError.value === 'string') {
188
+ return searchError.value
189
+ }
190
+ return searchError.value.message || 'Search failed. Try again.'
191
+ })
142
192
 
143
193
  function importRoutesFromRouter() {
144
194
  const routeItems = router.getRoutes()
145
- .filter(route => route.name) // Exclude unnamed routes
195
+ .filter(route => route.name)
146
196
  .map(route => ({
147
197
  id: `route-${route.name}`,
148
198
  title: route.meta.title || route.name,
@@ -152,106 +202,133 @@ function importRoutesFromRouter() {
152
202
  icon: route.meta.icon || ViewIcon,
153
203
  type: 'route'
154
204
  }))
155
-
156
- // Add the items to the search
205
+
157
206
  routeItems.forEach(item => addItem(item))
158
207
  }
159
208
 
160
- const filteredItems = computed(() => {
209
+ const localFilteredItems = computed(() => {
161
210
  if (!searchQuery.value.trim()) {
162
211
  return []
163
212
  }
164
-
213
+
165
214
  const query = searchQuery.value.toLowerCase()
166
- const filtered = items.value.filter(item => {
215
+ return items.value.filter(item => {
167
216
  return props.searchFields.some(field => {
168
217
  const value = item[field]
169
- return value && value.toLowerCase().includes(query)
218
+ return value && value.toString().toLowerCase().includes(query)
170
219
  })
171
220
  })
172
-
173
- return filtered.slice(0, props.maxResults)
174
221
  })
175
222
 
176
- function openOverlay() {
177
- if (!isMobile.value) {
178
- return
223
+ const displayedItems = computed(() => {
224
+ if (!searchQuery.value.trim()) {
225
+ return []
179
226
  }
180
- overlayOpen.value = true
181
- nextTick(() => {
182
- searchInput.value?.focus()
183
- showResults.value = true
184
- })
185
- }
186
227
 
187
- function closeOverlay() {
188
- if (!overlayOpen.value) {
189
- return
228
+ const seen = new Set()
229
+ const merged = []
230
+
231
+ for (const item of [...localFilteredItems.value, ...remoteItems.value]) {
232
+ if (!item || item.id == null || seen.has(item.id)) {
233
+ continue
234
+ }
235
+ seen.add(item.id)
236
+ merged.push(item)
237
+ if (merged.length >= props.maxResults) {
238
+ break
239
+ }
190
240
  }
191
- overlayOpen.value = false
192
- clear()
193
- }
241
+
242
+ return merged
243
+ })
194
244
 
195
245
  function handleGlobalKeydown(event) {
196
- // Check if Ctrl+K is pressed
197
246
  if (event.ctrlKey && event.key === 'k') {
198
247
  event.preventDefault()
199
248
 
200
- // Don't trigger if user is typing in an input/textarea
201
249
  if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
202
250
  return
203
251
  }
204
252
 
205
- focus()
253
+ open()
206
254
  }
207
255
  }
208
256
 
209
- function onSearch() {
210
- clearTimeout(debounceTimer.value)
211
- debounceTimer.value = setTimeout(() => {
212
- selectedIndex.value = -1
213
- showResults.value = true
214
- emit('search', searchQuery.value)
215
- }, props.debounceMs)
257
+ function cancelSearch() {
258
+ if (abortController) {
259
+ abortController.abort()
260
+ abortController = null
261
+ }
262
+ isLoading.value = false
216
263
  }
217
264
 
218
- function onFocus() {
219
- showResults.value = true
220
- emit('focus')
265
+ function clearRemoteResults() {
266
+ remoteItems.value = []
267
+ searchError.value = null
221
268
  }
222
269
 
223
- function onBlur() {
224
- emit('blur')
225
- // Keep overlay results visible on mobile; only collapse the inline dropdown.
226
- if (isMobile.value) {
270
+ async function runSearch(query) {
271
+ selectedIndex.value = -1
272
+
273
+ if (!hasAsyncSearch.value) {
274
+ emit('search', query)
275
+ return
276
+ }
277
+
278
+ cancelSearch()
279
+
280
+ if (!query.trim()) {
281
+ clearRemoteResults()
282
+ emit('search', query, { signal: null })
227
283
  return
228
284
  }
229
- // Delay hiding results to allow for click events
230
- setTimeout(() => {
231
- showResults.value = false
232
- selectedIndex.value = -1
233
- }, 150)
285
+
286
+ abortController = new AbortController()
287
+ const { signal } = abortController
288
+
289
+ isLoading.value = true
290
+ searchError.value = null
291
+ emit('search', query, { signal })
292
+
293
+ try {
294
+ const results = await props.fetchResults(query, { signal })
295
+ if (signal.aborted) {
296
+ return
297
+ }
298
+ remoteItems.value = Array.isArray(results) ? results : []
299
+ } catch (error) {
300
+ if (error?.name === 'AbortError' || signal.aborted) {
301
+ return
302
+ }
303
+ searchError.value = error
304
+ remoteItems.value = []
305
+ emit('search-error', error)
306
+ } finally {
307
+ if (!signal.aborted) {
308
+ isLoading.value = false
309
+ }
310
+ }
311
+ }
312
+
313
+ function onSearchInput() {
314
+ clearTimeout(debounceTimer.value)
315
+ debounceTimer.value = setTimeout(() => {
316
+ runSearch(searchQuery.value)
317
+ }, props.debounceMs)
234
318
  }
235
319
 
236
320
  function onKeydown(event) {
237
- if (event.key === 'Escape') {
238
- if (isMobile.value && overlayOpen.value) {
239
- event.preventDefault()
240
- closeOverlay()
241
- return
321
+ if (displayedItems.value.length === 0) {
322
+ if (event.key === 'Escape') {
323
+ close()
242
324
  }
243
- showResults.value = false
244
- selectedIndex.value = -1
245
- searchInput.value?.blur()
246
325
  return
247
326
  }
248
327
 
249
- if (!showResults.value || filteredItems.value.length === 0) return
250
-
251
328
  switch (event.key) {
252
329
  case 'ArrowDown':
253
330
  event.preventDefault()
254
- selectedIndex.value = Math.min(selectedIndex.value + 1, filteredItems.value.length - 1)
331
+ selectedIndex.value = Math.min(selectedIndex.value + 1, displayedItems.value.length - 1)
255
332
  break
256
333
  case 'ArrowUp':
257
334
  event.preventDefault()
@@ -260,14 +337,17 @@ function onKeydown(event) {
260
337
  case 'Enter':
261
338
  event.preventDefault()
262
339
  if (selectedIndex.value >= 0) {
263
- selectItem(filteredItems.value[selectedIndex.value])
340
+ selectItem(displayedItems.value[selectedIndex.value])
264
341
  }
265
342
  break
343
+ case 'Escape':
344
+ close()
345
+ break
266
346
  }
267
347
  }
268
348
 
269
349
  function selectItem(item) {
270
- switch(item.type) {
350
+ switch (item.type) {
271
351
  case 'route':
272
352
  router.push({ path: item.path })
273
353
  break
@@ -277,24 +357,42 @@ function selectItem(item) {
277
357
  default:
278
358
  emit('select', item)
279
359
  }
280
-
281
- searchQuery.value = ''
282
- showResults.value = false
283
- selectedIndex.value = -1
284
360
 
285
- if (isMobile.value && overlayOpen.value) {
286
- overlayOpen.value = false
287
- }
361
+ clear()
362
+ close()
363
+ }
364
+
365
+ function escapeHtml(value) {
366
+ return String(value)
367
+ .replace(/&/g, '&amp;')
368
+ .replace(/</g, '&lt;')
369
+ .replace(/>/g, '&gt;')
370
+ .replace(/"/g, '&quot;')
371
+ .replace(/'/g, '&#39;')
288
372
  }
289
373
 
290
374
  function highlightText(text, query) {
291
- if (!query || !text) return text
292
-
293
- const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
294
- return text.replace(regex, '<mark>$1</mark>')
375
+ if (text == null || text === '') return ''
376
+
377
+ const source = String(text)
378
+ if (!query) return escapeHtml(source)
379
+
380
+ const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')
381
+ let result = ''
382
+ let lastIndex = 0
383
+
384
+ for (const match of source.matchAll(regex)) {
385
+ const start = match.index
386
+ const matched = match[0]
387
+ result += escapeHtml(source.slice(lastIndex, start))
388
+ result += `<mark>${escapeHtml(matched)}</mark>`
389
+ lastIndex = start + matched.length
390
+ }
391
+
392
+ result += escapeHtml(source.slice(lastIndex))
393
+ return result
295
394
  }
296
395
 
297
- // API Methods
298
396
  function addItem(item) {
299
397
  const existingIndex = items.value.findIndex(i => i.id === item.id)
300
398
  if (existingIndex >= 0) {
@@ -320,26 +418,62 @@ function setItems(newItems) {
320
418
  items.value = [...newItems]
321
419
  }
322
420
 
323
- function focus() {
324
- if (isMobile.value) {
325
- openOverlay()
421
+ function setRemoteItems(newItems) {
422
+ remoteItems.value = Array.isArray(newItems) ? [...newItems] : []
423
+ isLoading.value = false
424
+ }
425
+
426
+ async function open() {
427
+ if (!dialogRef.value) {
326
428
  return
327
429
  }
430
+
431
+ if (!dialogRef.value.open) {
432
+ dialogRef.value.showModal()
433
+ }
434
+
435
+ isOpen.value = true
436
+ emit('open')
437
+ emit('focus')
438
+
439
+ await nextTick()
328
440
  searchInput.value?.focus()
329
441
  }
330
442
 
331
- function blur() {
332
- if (isMobile.value && overlayOpen.value) {
333
- closeOverlay()
334
- return
443
+ function close() {
444
+ if (dialogRef.value?.open) {
445
+ dialogRef.value.close()
335
446
  }
336
- searchInput.value?.blur()
447
+ }
448
+
449
+ function onDialogClose() {
450
+ cancelSearch()
451
+ clearTimeout(debounceTimer.value)
452
+ isOpen.value = false
453
+ selectedIndex.value = -1
454
+ emit('close')
455
+ emit('blur')
456
+ }
457
+
458
+ function onDialogBackdropClick(event) {
459
+ if (event.target === dialogRef.value) {
460
+ close()
461
+ }
462
+ }
463
+
464
+ function focus() {
465
+ open()
466
+ }
467
+
468
+ function blur() {
469
+ close()
337
470
  }
338
471
 
339
472
  function clear() {
340
473
  searchQuery.value = ''
341
- showResults.value = false
342
474
  selectedIndex.value = -1
475
+ clearRemoteResults()
476
+ cancelSearch()
343
477
  }
344
478
 
345
479
  function refreshRoutes() {
@@ -348,26 +482,11 @@ function refreshRoutes() {
348
482
  }
349
483
  }
350
484
 
351
- watch(isMobile, (mobile) => {
352
- if (!mobile && overlayOpen.value) {
353
- closeOverlay()
354
- }
355
- })
356
-
357
- watch(overlayOpen, (open) => {
358
- if (typeof document === 'undefined') {
359
- return
360
- }
361
- document.body.style.overflow = open ? 'hidden' : ''
362
- })
363
-
364
- // Global shortcut management
365
485
  onMounted(() => {
366
486
  if (props.enableGlobalShortcut) {
367
487
  document.addEventListener('keydown', handleGlobalKeydown)
368
488
  }
369
-
370
- // Auto-import routes on mount
489
+
371
490
  if (props.autoImportRoutes) {
372
491
  importRoutesFromRouter()
373
492
  }
@@ -377,12 +496,10 @@ onUnmounted(() => {
377
496
  if (props.enableGlobalShortcut) {
378
497
  document.removeEventListener('keydown', handleGlobalKeydown)
379
498
  }
380
- if (typeof document !== 'undefined') {
381
- document.body.style.overflow = ''
382
- }
499
+ clearTimeout(debounceTimer.value)
500
+ cancelSearch()
383
501
  })
384
502
 
385
- // Watch for external items changes
386
503
  watch(() => props.items, (newItems) => {
387
504
  items.value = [...newItems]
388
505
  }, { deep: true })
@@ -393,89 +510,58 @@ defineExpose({
393
510
  clearItems,
394
511
  getItems,
395
512
  setItems,
513
+ setRemoteItems,
514
+ cancelSearch,
396
515
  focus,
397
516
  blur,
517
+ open,
518
+ close,
398
519
  clear,
399
520
  refreshRoutes,
400
- openOverlay,
401
- closeOverlay,
402
521
  searchQuery,
403
- filteredItems,
404
- showResults,
405
- overlayOpen
522
+ filteredItems: displayedItems,
523
+ displayedItems,
524
+ remoteItems,
525
+ isLoading,
526
+ searchError,
527
+ hasAsyncSearch,
528
+ isOpen,
529
+ showResults: isOpen,
406
530
  })
407
531
  </script>
408
532
 
409
533
  <style scoped>
410
- .quick-search-trigger {
411
- flex-shrink: 0;
534
+ .quick-search {
412
535
  display: inline-flex;
413
536
  align-items: center;
414
- justify-content: center;
415
- border: 0;
416
- color: #fff;
417
- padding: 0.5em;
418
- cursor: pointer;
419
- background: transparent;
420
- }
421
-
422
- .quick-search-trigger:hover {
423
- background-color: var(--header-hover-background-color);
424
- color: var(--header-hover-text-color);
537
+ flex-shrink: 0;
425
538
  }
426
539
 
427
- .quick-search-overlay-root {
428
- position: fixed;
429
- inset: 0;
430
- z-index: 2000;
431
- display: flex;
432
- align-items: flex-start;
540
+ .search-trigger {
541
+ display: inline-flex;
542
+ align-items: center;
433
543
  justify-content: center;
434
- padding: 1rem;
435
- padding-top: max(1rem, env(safe-area-inset-top));
436
- }
437
-
438
- .quick-search-backdrop {
439
- position: absolute;
440
- inset: 0;
441
- background: rgba(0, 0, 0, 0.45);
442
544
  }
443
545
 
444
- .quick-search {
445
- position: relative;
446
- width: 100%;
546
+ .quick-search-dialog {
547
+ width: min(32rem, calc(100vw - 2rem));
548
+ max-width: 32rem;
549
+ padding: 0;
550
+ border: 1px solid var(--border-color, #d7d7d7);
551
+ background: var(--standout-bg-color, #fff);
552
+ /* Dialog is rendered under <header>, so do not inherit header's white text */
553
+ color: var(--text-color, #334155);
447
554
  }
448
555
 
449
- .quick-search--overlay {
450
- position: relative;
451
- z-index: 1;
452
- width: min(100%, 28rem);
453
- margin-top: 10vh;
454
- padding: 0.75rem;
455
- border-radius: 0.5rem;
456
- background: #fff;
457
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
556
+ .quick-search-dialog::backdrop {
557
+ background-color: rgba(0, 0, 0, 0.5);
458
558
  }
459
559
 
460
- .quick-search-overlay-header {
560
+ .dialog-body {
461
561
  display: flex;
462
- align-items: center;
463
- justify-content: space-between;
464
- gap: 0.5rem;
465
- margin-bottom: 0.5rem;
466
- }
467
-
468
- .quick-search-overlay-title {
469
- font-weight: 600;
470
- color: #1f2937;
471
- }
472
-
473
- .quick-search-overlay-close {
474
- border: 0;
475
- padding: 0.35em;
476
- cursor: pointer;
477
- color: #1f2937;
478
- background: transparent;
562
+ flex-direction: column;
563
+ gap: 0.75rem;
564
+ padding: 1rem;
479
565
  }
480
566
 
481
567
  .search-container {
@@ -486,28 +572,13 @@ defineExpose({
486
572
 
487
573
  .search-input {
488
574
  width: 100%;
489
- padding: 0.4em;
490
- border: none;
491
- border-radius: .4em;
575
+ padding: 0.75em 2.25em 0.75em 0.75em;
576
+ border: 1px solid var(--border-color, #d7d7d7);
577
+ border-radius: 0.4em;
492
578
  font-size: 1rem;
493
579
  outline: none;
494
- transition: all 0.2s ease;
495
- background-color: #666;
496
- color: #fff;
497
- }
498
-
499
- .quick-search--overlay .search-input {
500
- background-color: #f3f4f6;
501
- color: #111827;
502
- padding: 0.65em 2.25em 0.65em 0.75em;
503
- }
504
-
505
- .quick-search--overlay .search-input::placeholder {
506
- color: #9ca3af;
507
- }
508
-
509
- .search-input::placeholder {
510
- color: #bbb;
580
+ background-color: #fff;
581
+ color: var(--text-color, #334155);
511
582
  }
512
583
 
513
584
  .search-input:focus {
@@ -520,34 +591,28 @@ defineExpose({
520
591
  right: 0.75rem;
521
592
  color: #6b7280;
522
593
  pointer-events: none;
594
+ display: inline-flex;
523
595
  }
524
596
 
525
- .search-results {
526
- position: absolute;
527
- top: 100%;
528
- left: 0;
529
- right: 0;
530
- background: #fff;
531
- border: 2px solid #e1e5e9;
532
- border-top: none;
533
- border-radius: 0 0 8px 8px;
534
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
535
- max-height: 300px;
536
- overflow-y: auto;
537
- z-index: 1000;
597
+ .search-icon .is-spinning {
598
+ animation: quick-search-spin 0.8s linear infinite;
538
599
  }
539
600
 
540
- .quick-search--overlay .search-results,
541
- .quick-search--overlay .no-results {
542
- position: relative;
543
- top: auto;
544
- left: auto;
545
- right: auto;
546
- margin-top: 0.5rem;
547
- border: 1px solid #e1e5e9;
548
- border-radius: 0.4rem;
549
- max-height: min(50vh, 320px);
550
- box-shadow: none;
601
+ @keyframes quick-search-spin {
602
+ to {
603
+ transform: rotate(360deg);
604
+ }
605
+ }
606
+
607
+ .search-status {
608
+ margin: 0;
609
+ }
610
+
611
+ .search-results {
612
+ max-height: min(20rem, 50vh);
613
+ overflow-y: auto;
614
+ border: 1px solid var(--border-color, #e1e5e9);
615
+ border-radius: 0.4em;
551
616
  }
552
617
 
553
618
  .search-result-item {
@@ -555,8 +620,7 @@ defineExpose({
555
620
  align-items: center;
556
621
  padding: 0.75rem 1rem;
557
622
  cursor: pointer;
558
- transition: background-color 0.15s ease;
559
- border-bottom: 1px solid #f3f4f6;
623
+ border-bottom: 1px solid var(--border-color, #f3f4f6);
560
624
  }
561
625
 
562
626
  .search-result-item:last-child {
@@ -566,6 +630,7 @@ defineExpose({
566
630
  .search-result-item:hover,
567
631
  .search-result-item.active {
568
632
  background-color: var(--hover-background-color);
633
+ color: var(--hover-text-color);
569
634
  }
570
635
 
571
636
  .result-content {
@@ -575,7 +640,6 @@ defineExpose({
575
640
 
576
641
  .result-title {
577
642
  font-weight: 500;
578
- color: #1f2937;
579
643
  margin-bottom: 0.25rem;
580
644
  }
581
645
 
@@ -596,26 +660,14 @@ defineExpose({
596
660
  margin-right: 0.75rem;
597
661
  color: #6b7280;
598
662
  flex-shrink: 0;
599
- }
600
-
601
- .no-results {
602
- position: absolute;
603
- top: 100%;
604
- left: 0;
605
- right: 0;
606
- background: #fff;
607
- border: 2px solid #e1e5e9;
608
- border-top: none;
609
- border-radius: 0 0 8px 8px;
610
- box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
611
- z-index: 1000;
663
+ display: inline-flex;
612
664
  }
613
665
 
614
666
  .no-results-content {
615
667
  display: flex;
616
668
  flex-direction: column;
617
669
  align-items: center;
618
- padding: 2rem 1rem;
670
+ padding: 1.5rem 1rem;
619
671
  color: #6b7280;
620
672
  text-align: center;
621
673
  }
@@ -625,26 +677,19 @@ defineExpose({
625
677
  font-size: 0.875rem;
626
678
  }
627
679
 
680
+ .search-hint {
681
+ margin: 0;
682
+ }
683
+
628
684
  mark {
629
- background-color: #fef3c7;
630
- color: #92400e;
685
+ background-color: var(--karma-important, #fef3c7);
686
+ color: inherit;
631
687
  padding: 0;
632
688
  border-radius: 2px;
633
689
  }
634
690
 
635
- /* Dark theme */
636
691
  html[data-theme="dark"] {
637
- .quick-search--overlay {
638
- background: #111827;
639
- }
640
-
641
- .quick-search-overlay-title,
642
- .quick-search-overlay-close {
643
- color: #f9fafb;
644
- }
645
-
646
- .quick-search--overlay .search-input {
647
- background-color: #1f2937;
692
+ .quick-search-dialog {
648
693
  color: #f9fafb;
649
694
  }
650
695
 
@@ -654,62 +699,18 @@ html[data-theme="dark"] {
654
699
  color: #f9fafb;
655
700
  }
656
701
 
657
- .search-input:focus {
658
- border-color: #60a5fa;
659
- box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
660
- }
661
-
662
- .search-icon {
663
- color: #9ca3af;
664
- }
665
-
666
- .search-results {
667
- background: #1f2937;
668
- border-color: #374151;
669
- }
670
-
671
- .search-result-item {
672
- border-bottom-color: #374151;
673
- }
674
-
675
- .search-result-item:hover,
676
- .search-result-item.active {
677
- background-color: #374151;
678
- }
679
-
680
- .result-title {
681
- color: #f9fafb;
682
- }
683
-
684
- .result-description {
685
- color: #d1d5db;
686
- }
687
-
688
- .result-category {
689
- color: #9ca3af;
690
- }
691
-
692
- .result-icon {
702
+ .search-icon,
703
+ .result-description,
704
+ .result-category,
705
+ .result-icon,
706
+ .no-results-content,
707
+ .search-status {
693
708
  color: #9ca3af;
694
709
  }
695
710
 
696
- .no-results {
697
- background: #1f2937;
698
- border-color: #374151;
699
- }
700
-
701
- .no-results-content {
702
- color: #9ca3af;
703
- }
704
-
705
- .quick-search--overlay .search-results,
706
- .quick-search--overlay .no-results {
707
- border-color: #374151;
708
- }
709
-
710
- mark {
711
- background-color: #451a03;
712
- color: #fbbf24;
711
+ .search-input:focus {
712
+ border-color: #60a5fa;
713
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
713
714
  }
714
715
  }
715
716
  </style>