forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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>