[READ-ONLY] a fast, modern browser for the npm registry
at main 218 lines 6.9 kB view raw
1<script setup lang="ts"> 2const props = defineProps<{ 3 /** List of suggested usernames (e.g., org members) */ 4 suggestions: string[] 5 /** Placeholder text */ 6 placeholder?: string 7 /** Whether the input is disabled */ 8 disabled?: boolean 9 /** Accessible label for the input */ 10 label?: string 11}>() 12 13const emit = defineEmits<{ 14 /** Emitted when a user is selected/submitted */ 15 select: [username: string, isInSuggestions: boolean] 16}>() 17 18const inputValue = shallowRef('') 19const isOpen = shallowRef(false) 20const highlightedIndex = shallowRef(-1) 21const listRef = useTemplateRef('listRef') 22 23// Generate unique ID for accessibility 24const inputId = useId() 25const listboxId = `${inputId}-listbox` 26 27// Filter suggestions based on input 28const filteredSuggestions = computed(() => { 29 if (!inputValue.value.trim()) { 30 return props.suggestions.slice(0, 10) // Show first 10 when empty 31 } 32 const query = inputValue.value.toLowerCase().replace(/^@/, '') 33 return props.suggestions.filter(s => s.toLowerCase().includes(query)).slice(0, 10) 34}) 35 36// Check if current input matches a suggestion exactly 37const isExactMatch = computed(() => { 38 const normalized = inputValue.value.trim().replace(/^@/, '').toLowerCase() 39 return props.suggestions.some(s => s.toLowerCase() === normalized) 40}) 41 42// Show hint when typing a non-member username 43const showNewUserHint = computed(() => { 44 const value = inputValue.value.trim().replace(/^@/, '') 45 return value.length > 0 && !isExactMatch.value && filteredSuggestions.value.length === 0 46}) 47 48function handleInput() { 49 isOpen.value = true 50 highlightedIndex.value = -1 51} 52 53function handleFocus() { 54 isOpen.value = true 55} 56 57function handleBlur(event: FocusEvent) { 58 // Don't close if clicking within the dropdown 59 const relatedTarget = event.relatedTarget as HTMLElement | null 60 if (relatedTarget && listRef.value?.contains(relatedTarget)) { 61 return 62 } 63 // Delay to allow click to register 64 setTimeout(() => { 65 isOpen.value = false 66 highlightedIndex.value = -1 67 }, 150) 68} 69 70function selectSuggestion(username: string) { 71 inputValue.value = username 72 isOpen.value = false 73 highlightedIndex.value = -1 74 emit('select', username, true) 75 inputValue.value = '' 76} 77 78function handleSubmit() { 79 const username = inputValue.value.trim().replace(/^@/, '') 80 if (!username) return 81 82 const inSuggestions = props.suggestions.some(s => s.toLowerCase() === username.toLowerCase()) 83 emit('select', username, inSuggestions) 84 inputValue.value = '' 85 isOpen.value = false 86} 87 88function handleKeydown(event: KeyboardEvent) { 89 if (!isOpen.value) { 90 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { 91 isOpen.value = true 92 event.preventDefault() 93 } 94 return 95 } 96 97 switch (event.key) { 98 case 'ArrowDown': 99 event.preventDefault() 100 if (highlightedIndex.value < filteredSuggestions.value.length - 1) { 101 highlightedIndex.value++ 102 } 103 break 104 case 'ArrowUp': 105 event.preventDefault() 106 if (highlightedIndex.value > 0) { 107 highlightedIndex.value-- 108 } 109 break 110 case 'Enter': { 111 event.preventDefault() 112 const selectedSuggestion = filteredSuggestions.value[highlightedIndex.value] 113 if (highlightedIndex.value >= 0 && selectedSuggestion) { 114 selectSuggestion(selectedSuggestion) 115 } else { 116 handleSubmit() 117 } 118 break 119 } 120 case 'Escape': 121 isOpen.value = false 122 highlightedIndex.value = -1 123 break 124 } 125} 126 127// Scroll highlighted item into view 128watch(highlightedIndex, index => { 129 if (index >= 0 && listRef.value) { 130 const item = listRef.value.children[index] as HTMLElement 131 item?.scrollIntoView({ block: 'nearest' }) 132 } 133}) 134 135// Check for reduced motion preference 136const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') 137</script> 138 139<template> 140 <div class="relative"> 141 <label v-if="label" :for="inputId" class="sr-only">{{ label }}</label> 142 <input 143 :id="inputId" 144 v-model="inputValue" 145 type="text" 146 :placeholder="placeholder ?? $t('user.combobox.default_placeholder')" 147 :disabled="disabled" 148 v-bind="noCorrect" 149 role="combobox" 150 aria-autocomplete="list" 151 :aria-expanded="isOpen && (filteredSuggestions.length > 0 || showNewUserHint)" 152 aria-haspopup="listbox" 153 :aria-controls="listboxId" 154 :aria-activedescendant=" 155 highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined 156 " 157 class="w-full px-2 py-1 font-mono text-sm bg-bg-subtle border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-accent/70 disabled:opacity-50 disabled:cursor-not-allowed" 158 @input="handleInput" 159 @focus="handleFocus" 160 @blur="handleBlur" 161 @keydown="handleKeydown" 162 /> 163 164 <!-- Dropdown --> 165 <Transition 166 :enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'" 167 :enter-from-class="prefersReducedMotion ? '' : 'opacity-0'" 168 enter-to-class="opacity-100" 169 :leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'" 170 leave-from-class="opacity-100" 171 :leave-to-class="prefersReducedMotion ? '' : 'opacity-0'" 172 > 173 <ul 174 v-if="isOpen && (filteredSuggestions.length > 0 || showNewUserHint)" 175 :id="listboxId" 176 ref="listRef" 177 role="listbox" 178 :aria-label="label ?? $t('user.combobox.suggestions_label')" 179 class="absolute z-50 w-full mt-1 py-1 bg-bg-elevated border border-border rounded shadow-lg max-h-48 overflow-y-auto" 180 > 181 <!-- Suggestions from org --> 182 <li 183 v-for="(username, index) in filteredSuggestions" 184 :id="`${listboxId}-option-${index}`" 185 :key="username" 186 role="option" 187 :aria-selected="highlightedIndex === index" 188 class="px-2 py-1 font-mono text-sm transition-colors duration-100" 189 :class=" 190 highlightedIndex === index 191 ? 'bg-bg-muted text-fg' 192 : 'text-fg-muted hover:bg-bg-subtle hover:text-fg' 193 " 194 @mouseenter="highlightedIndex = index" 195 @click="selectSuggestion(username)" 196 > 197 @{{ username }} 198 </li> 199 200 <!-- Hint for new user --> 201 <li 202 v-if="showNewUserHint" 203 class="px-2 py-1 font-mono text-xs text-fg-subtle border-t border-border mt-1 pt-2" 204 role="status" 205 aria-live="polite" 206 > 207 <span class="i-lucide:info w-3 h-3 me-1 align-middle" aria-hidden="true" /> 208 {{ 209 $t('user.combobox.press_enter_to_add', { 210 username: inputValue.trim().replace(/^@/, ''), 211 }) 212 }} 213 <span class="text-amber-400">{{ $t('user.combobox.add_to_org_hint') }}</span> 214 </li> 215 </ul> 216 </Transition> 217 </div> 218</template>