picocrank 1.21.4 → 1.22.0

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,18 @@ 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()
288
363
  }
289
364
 
290
365
  function highlightText(text, query) {
291
366
  if (!query || !text) return text
292
-
367
+
293
368
  const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
294
369
  return text.replace(regex, '<mark>$1</mark>')
295
370
  }
296
371
 
297
- // API Methods
298
372
  function addItem(item) {
299
373
  const existingIndex = items.value.findIndex(i => i.id === item.id)
300
374
  if (existingIndex >= 0) {
@@ -320,26 +394,62 @@ function setItems(newItems) {
320
394
  items.value = [...newItems]
321
395
  }
322
396
 
323
- function focus() {
324
- if (isMobile.value) {
325
- openOverlay()
397
+ function setRemoteItems(newItems) {
398
+ remoteItems.value = Array.isArray(newItems) ? [...newItems] : []
399
+ isLoading.value = false
400
+ }
401
+
402
+ async function open() {
403
+ if (!dialogRef.value) {
326
404
  return
327
405
  }
406
+
407
+ if (!dialogRef.value.open) {
408
+ dialogRef.value.showModal()
409
+ }
410
+
411
+ isOpen.value = true
412
+ emit('open')
413
+ emit('focus')
414
+
415
+ await nextTick()
328
416
  searchInput.value?.focus()
329
417
  }
330
418
 
331
- function blur() {
332
- if (isMobile.value && overlayOpen.value) {
333
- closeOverlay()
334
- return
419
+ function close() {
420
+ if (dialogRef.value?.open) {
421
+ dialogRef.value.close()
422
+ }
423
+ }
424
+
425
+ function onDialogClose() {
426
+ cancelSearch()
427
+ clearTimeout(debounceTimer.value)
428
+ isOpen.value = false
429
+ selectedIndex.value = -1
430
+ emit('close')
431
+ emit('blur')
432
+ }
433
+
434
+ function onDialogBackdropClick(event) {
435
+ if (event.target === dialogRef.value) {
436
+ close()
335
437
  }
336
- searchInput.value?.blur()
438
+ }
439
+
440
+ function focus() {
441
+ open()
442
+ }
443
+
444
+ function blur() {
445
+ close()
337
446
  }
338
447
 
339
448
  function clear() {
340
449
  searchQuery.value = ''
341
- showResults.value = false
342
450
  selectedIndex.value = -1
451
+ clearRemoteResults()
452
+ cancelSearch()
343
453
  }
344
454
 
345
455
  function refreshRoutes() {
@@ -348,26 +458,11 @@ function refreshRoutes() {
348
458
  }
349
459
  }
350
460
 
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
461
  onMounted(() => {
366
462
  if (props.enableGlobalShortcut) {
367
463
  document.addEventListener('keydown', handleGlobalKeydown)
368
464
  }
369
-
370
- // Auto-import routes on mount
465
+
371
466
  if (props.autoImportRoutes) {
372
467
  importRoutesFromRouter()
373
468
  }
@@ -377,12 +472,10 @@ onUnmounted(() => {
377
472
  if (props.enableGlobalShortcut) {
378
473
  document.removeEventListener('keydown', handleGlobalKeydown)
379
474
  }
380
- if (typeof document !== 'undefined') {
381
- document.body.style.overflow = ''
382
- }
475
+ clearTimeout(debounceTimer.value)
476
+ cancelSearch()
383
477
  })
384
478
 
385
- // Watch for external items changes
386
479
  watch(() => props.items, (newItems) => {
387
480
  items.value = [...newItems]
388
481
  }, { deep: true })
@@ -393,89 +486,58 @@ defineExpose({
393
486
  clearItems,
394
487
  getItems,
395
488
  setItems,
489
+ setRemoteItems,
490
+ cancelSearch,
396
491
  focus,
397
492
  blur,
493
+ open,
494
+ close,
398
495
  clear,
399
496
  refreshRoutes,
400
- openOverlay,
401
- closeOverlay,
402
497
  searchQuery,
403
- filteredItems,
404
- showResults,
405
- overlayOpen
498
+ filteredItems: displayedItems,
499
+ displayedItems,
500
+ remoteItems,
501
+ isLoading,
502
+ searchError,
503
+ hasAsyncSearch,
504
+ isOpen,
505
+ showResults: isOpen,
406
506
  })
407
507
  </script>
408
508
 
409
509
  <style scoped>
410
- .quick-search-trigger {
411
- flex-shrink: 0;
510
+ .quick-search {
412
511
  display: inline-flex;
413
512
  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);
513
+ flex-shrink: 0;
425
514
  }
426
515
 
427
- .quick-search-overlay-root {
428
- position: fixed;
429
- inset: 0;
430
- z-index: 2000;
431
- display: flex;
432
- align-items: flex-start;
516
+ .search-trigger {
517
+ display: inline-flex;
518
+ align-items: center;
433
519
  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
520
  }
443
521
 
444
- .quick-search {
445
- position: relative;
446
- width: 100%;
522
+ .quick-search-dialog {
523
+ width: min(32rem, calc(100vw - 2rem));
524
+ max-width: 32rem;
525
+ padding: 0;
526
+ border: 1px solid var(--border-color, #d7d7d7);
527
+ background: var(--standout-bg-color, #fff);
528
+ /* Dialog is rendered under <header>, so do not inherit header's white text */
529
+ color: var(--text-color, #334155);
447
530
  }
448
531
 
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);
532
+ .quick-search-dialog::backdrop {
533
+ background-color: rgba(0, 0, 0, 0.5);
458
534
  }
459
535
 
460
- .quick-search-overlay-header {
536
+ .dialog-body {
461
537
  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;
538
+ flex-direction: column;
539
+ gap: 0.75rem;
540
+ padding: 1rem;
479
541
  }
480
542
 
481
543
  .search-container {
@@ -486,28 +548,13 @@ defineExpose({
486
548
 
487
549
  .search-input {
488
550
  width: 100%;
489
- padding: 0.4em;
490
- border: none;
491
- border-radius: .4em;
551
+ padding: 0.75em 2.25em 0.75em 0.75em;
552
+ border: 1px solid var(--border-color, #d7d7d7);
553
+ border-radius: 0.4em;
492
554
  font-size: 1rem;
493
555
  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;
556
+ background-color: #fff;
557
+ color: var(--text-color, #334155);
511
558
  }
512
559
 
513
560
  .search-input:focus {
@@ -520,34 +567,28 @@ defineExpose({
520
567
  right: 0.75rem;
521
568
  color: #6b7280;
522
569
  pointer-events: none;
570
+ display: inline-flex;
523
571
  }
524
572
 
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;
573
+ .search-icon .is-spinning {
574
+ animation: quick-search-spin 0.8s linear infinite;
538
575
  }
539
576
 
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;
577
+ @keyframes quick-search-spin {
578
+ to {
579
+ transform: rotate(360deg);
580
+ }
581
+ }
582
+
583
+ .search-status {
584
+ margin: 0;
585
+ }
586
+
587
+ .search-results {
588
+ max-height: min(20rem, 50vh);
589
+ overflow-y: auto;
590
+ border: 1px solid var(--border-color, #e1e5e9);
591
+ border-radius: 0.4em;
551
592
  }
552
593
 
553
594
  .search-result-item {
@@ -555,8 +596,7 @@ defineExpose({
555
596
  align-items: center;
556
597
  padding: 0.75rem 1rem;
557
598
  cursor: pointer;
558
- transition: background-color 0.15s ease;
559
- border-bottom: 1px solid #f3f4f6;
599
+ border-bottom: 1px solid var(--border-color, #f3f4f6);
560
600
  }
561
601
 
562
602
  .search-result-item:last-child {
@@ -566,6 +606,7 @@ defineExpose({
566
606
  .search-result-item:hover,
567
607
  .search-result-item.active {
568
608
  background-color: var(--hover-background-color);
609
+ color: var(--hover-text-color);
569
610
  }
570
611
 
571
612
  .result-content {
@@ -575,7 +616,6 @@ defineExpose({
575
616
 
576
617
  .result-title {
577
618
  font-weight: 500;
578
- color: #1f2937;
579
619
  margin-bottom: 0.25rem;
580
620
  }
581
621
 
@@ -596,26 +636,14 @@ defineExpose({
596
636
  margin-right: 0.75rem;
597
637
  color: #6b7280;
598
638
  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;
639
+ display: inline-flex;
612
640
  }
613
641
 
614
642
  .no-results-content {
615
643
  display: flex;
616
644
  flex-direction: column;
617
645
  align-items: center;
618
- padding: 2rem 1rem;
646
+ padding: 1.5rem 1rem;
619
647
  color: #6b7280;
620
648
  text-align: center;
621
649
  }
@@ -625,26 +653,19 @@ defineExpose({
625
653
  font-size: 0.875rem;
626
654
  }
627
655
 
656
+ .search-hint {
657
+ margin: 0;
658
+ }
659
+
628
660
  mark {
629
- background-color: #fef3c7;
630
- color: #92400e;
661
+ background-color: var(--karma-important, #fef3c7);
662
+ color: inherit;
631
663
  padding: 0;
632
664
  border-radius: 2px;
633
665
  }
634
666
 
635
- /* Dark theme */
636
667
  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;
668
+ .quick-search-dialog {
648
669
  color: #f9fafb;
649
670
  }
650
671
 
@@ -654,62 +675,18 @@ html[data-theme="dark"] {
654
675
  color: #f9fafb;
655
676
  }
656
677
 
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 {
678
+ .search-icon,
679
+ .result-description,
680
+ .result-category,
681
+ .result-icon,
682
+ .no-results-content,
683
+ .search-status {
693
684
  color: #9ca3af;
694
685
  }
695
686
 
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;
687
+ .search-input:focus {
688
+ border-color: #60a5fa;
689
+ box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
713
690
  }
714
691
  }
715
692
  </style>