[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add input component (#1173)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Alex Savelyev
Daniel Roe
and committed by
GitHub
de2f5e3a 840f3d40

+343 -92
+6 -2
app/components/AppHeader.vue
··· 122 122 123 123 <!-- Center: Search bar + nav items --> 124 124 <div 125 - class="flex-1 flex items-center justify-center md:gap-6" 126 - :class="{ 'hidden sm:flex': !isSearchExpanded }" 125 + class="flex-1 flex items-center md:gap-6" 126 + :class="{ 127 + 'hidden sm:flex': !isSearchExpanded, 128 + 'justify-end': isOnHomePage, 129 + 'justify-center': !isOnHomePage, 130 + }" 127 131 > 128 132 <!-- Search bar (hidden on mobile unless expanded) --> 129 133 <HeaderSearchBox
+7 -6
app/components/Compare/PackageSelector.vue
··· 139 139 140 140 <!-- Add package input --> 141 141 <div v-if="packages.length < maxPackages" class="relative"> 142 - <div class="relative group"> 142 + <div class="relative group flex items-center"> 143 143 <label for="package-search" class="sr-only"> 144 144 {{ $t('compare.selector.search_label') }} 145 145 </label> 146 146 <span 147 - class="absolute inset-y-0 start-3 flex items-center text-fg-subtle pointer-events-none group-focus-within:text-accent" 148 - aria-hidden="true" 147 + class="absolute inset-is-3 text-fg-subtle font-mono text-md pointer-events-none transition-colors duration-200 motion-reduce:transition-none [.group:hover:not(:focus-within)_&]:text-fg/80 group-focus-within:text-accent z-1" 149 148 > 150 - <span class="i-carbon:search w-4 h-4" /> 149 + / 151 150 </span> 152 - <input 151 + <InputBase 153 152 id="package-search" 154 153 v-model="inputValue" 155 154 type="text" ··· 158 157 ? $t('compare.selector.search_first') 159 158 : $t('compare.selector.search_add') 160 159 " 161 - class="w-full bg-bg-subtle border border-border rounded-lg ps-10 pe-4 py-2.5 font-mono text-sm text-fg placeholder:text-fg-subtle motion-reduce:transition-none duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70)" 160 + no-correct 161 + size="medium" 162 + class="w-full min-w-25 ps-7" 162 163 aria-autocomplete="list" 163 164 @focus="isInputFocused = true" 164 165 @blur="handleBlur"
+4 -2
app/components/Filter/Panel.vue
··· 239 239 </button> 240 240 </div> 241 241 </div> 242 - <input 242 + <InputBase 243 243 id="filter-search" 244 244 type="text" 245 245 :value="filters.text" 246 246 :placeholder="searchPlaceholder" 247 247 autocomplete="off" 248 - class="w-full bg-bg-subtle border border-border rounded-md px-4 py-3 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)" 248 + class="w-full min-w-25" 249 + size="medium" 250 + no-correct 249 251 @input="handleTextInput" 250 252 /> 251 253 </div>
+4 -3
app/components/Header/AuthModal.client.vue
··· 89 89 > 90 90 {{ $t('auth.modal.handle_label') }} 91 91 </label> 92 - <input 92 + <InputBase 93 93 id="handle-input" 94 94 v-model="handleInput" 95 95 type="text" 96 96 name="handle" 97 97 :placeholder="$t('auth.modal.handle_placeholder')" 98 - v-bind="noCorrect" 99 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 hover:border-fg-subtle focus:border-accent focus-visible:(outline-2 outline-accent/70)" 98 + no-correct 99 + class="w-full" 100 + size="medium" 100 101 /> 101 102 <p v-if="errorMessage" class="text-red-500 text-xs mt-1" role="alert"> 102 103 {{ errorMessage }}
+7 -5
app/components/Header/ConnectorModal.vue
··· 161 161 > 162 162 {{ $t('connector.modal.token_label') }} 163 163 </label> 164 - <input 164 + <InputBase 165 165 id="connector-token" 166 166 v-model="tokenInput" 167 167 type="password" 168 168 name="connector-token" 169 169 :placeholder="$t('connector.modal.token_placeholder')" 170 - v-bind="noCorrect" 171 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg placeholder:text-fg-subtle transition-colors duration-200 hover:border-fg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:outline-accent/70" 170 + no-correct 171 + class="w-full" 172 + size="medium" 172 173 /> 173 174 </div> 174 175 ··· 183 184 > 184 185 {{ $t('connector.modal.port_label') }} 185 186 </label> 186 - <input 187 + <InputBase 187 188 id="connector-port" 188 189 v-model="portInput" 189 190 type="text" 190 191 name="connector-port" 191 192 inputmode="numeric" 192 193 autocomplete="off" 193 - class="w-full px-3 py-2 font-mono text-sm bg-bg-subtle border border-border rounded-md text-fg transition-colors duration-200 hover:border-fg-subtle focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:outline-accent/70" 194 + class="w-full" 195 + size="medium" 194 196 /> 195 197 </div> 196 198 </details>
+6 -14
app/components/Header/SearchBox.vue
··· 70 70 }, 71 71 ) 72 72 73 - function handleSearchBlur() { 74 - isSearchFocused.value = false 75 - emit('blur') 76 - } 77 - function handleSearchFocus() { 78 - isSearchFocused.value = true 79 - emit('focus') 80 - } 81 - 82 73 function handleSubmit() { 83 74 if (pagesWithLocalFilter.has(route.name as string)) { 84 75 router.push({ ··· 114 105 / 115 106 </span> 116 107 117 - <input 108 + <InputBase 118 109 id="header-search" 119 110 ref="inputRef" 120 111 v-model="searchQuery" 121 112 type="search" 122 113 name="q" 123 114 :placeholder="$t('search.placeholder')" 124 - v-bind="noCorrect" 125 - class="w-full min-w-25 bg-bg-subtle border border-border rounded-md ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)" 126 - @focus="handleSearchFocus" 127 - @blur="handleSearchBlur" 115 + no-correct 116 + class="w-full min-w-25 ps-7" 117 + @focus="isSearchFocused = true" 118 + @blur="isSearchFocused = false" 119 + size="small" 128 120 /> 129 121 <button type="submit" class="sr-only">{{ $t('search.button') }}</button> 130 122 </div>
+49
app/components/Input/Base.vue
··· 1 + <script setup lang="ts"> 2 + import { noCorrect } from '~/utils/input' 3 + 4 + const model = defineModel<string>({ default: '' }) 5 + 6 + const props = withDefaults( 7 + defineProps<{ 8 + disabled?: boolean 9 + size?: 'small' | 'medium' | 'large' 10 + noCorrect?: boolean 11 + }>(), 12 + { 13 + size: 'medium', 14 + noCorrect: true, 15 + }, 16 + ) 17 + 18 + const emit = defineEmits<{ 19 + focus: [event: FocusEvent] 20 + blur: [event: FocusEvent] 21 + }>() 22 + 23 + const el = useTemplateRef('el') 24 + 25 + defineExpose({ 26 + focus: () => el.value?.focus(), 27 + blur: () => el.value?.blur(), 28 + }) 29 + </script> 30 + 31 + <template> 32 + <input 33 + ref="el" 34 + v-model="model" 35 + v-bind="props.noCorrect ? noCorrect : undefined" 36 + @focus="emit('focus', $event)" 37 + @blur="emit('blur', $event)" 38 + class="bg-bg-subtle border border-border font-mono text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent outline-offset-2 focus:border-accent focus-visible:outline-accent/70 disabled:(opacity-50 cursor-not-allowed)" 39 + :class="{ 40 + 'text-xs leading-[1.2] px-2 py-2 rounded-md': size === 'small', 41 + 'text-sm leading-none px-3 py-2.5 rounded-lg': size === 'medium', 42 + 'text-base leading-none px-6 py-3.5 h-14 rounded-xl': size === 'large', 43 + }" 44 + :disabled=" 45 + /** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */ 46 + disabled ? true : undefined 47 + " 48 + /> 49 + </template>
+9 -7
app/components/Org/MembersPanel.vue
··· 331 331 aria-hidden="true" 332 332 /> 333 333 <label for="members-search" class="sr-only">{{ $t('org.members.filter_label') }}</label> 334 - <input 334 + <InputBase 335 335 id="members-search" 336 336 v-model="searchQuery" 337 337 type="search" 338 338 name="members-search" 339 339 :placeholder="$t('org.members.filter_placeholder')" 340 - v-bind="noCorrect" 341 - class="w-full ps-7 pe-2 py-1.5 font-mono text-sm bg-bg-subtle border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70)" 340 + no-correct 341 + class="w-full min-w-25 ps-7" 342 + size="small" 342 343 /> 343 344 </div> 344 345 <div ··· 516 517 <label for="new-member-username" class="sr-only">{{ 517 518 $t('org.members.username_label') 518 519 }}</label> 519 - <input 520 + <InputBase 520 521 id="new-member-username" 521 522 v-model="newUsername" 522 523 type="text" 523 524 name="new-member-username" 524 525 :placeholder="$t('org.members.username_placeholder')" 525 - v-bind="noCorrect" 526 - class="w-full px-2 py-1.5 font-mono text-sm bg-bg border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-accent/70" 526 + no-correct 527 + class="w-full min-w-25" 528 + size="small" 527 529 /> 528 530 <div class="flex items-center gap-2"> 529 531 <label for="new-member-role" class="sr-only">{{ $t('org.members.role_label') }}</label> ··· 553 555 <button 554 556 type="submit" 555 557 :disabled="!newUsername.trim() || isAddingMember" 556 - class="px-3 py-1.5 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 558 + class="px-3 py-2 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 557 559 > 558 560 {{ isAddingMember ? '…' : $t('org.members.add_button') }} 559 561 </button>
+4 -3
app/components/Org/OperationsQueue.vue
··· 242 242 </div> 243 243 <form class="flex items-center gap-2" @submit.prevent="handleRetryWithOtp"> 244 244 <label for="otp-input" class="sr-only">{{ $t('operations.queue.otp_label') }}</label> 245 - <input 245 + <InputBase 246 246 id="otp-input" 247 247 v-model="otpInput" 248 248 type="text" ··· 252 252 :placeholder="$t('operations.queue.otp_placeholder')" 253 253 autocomplete="one-time-code" 254 254 spellcheck="false" 255 - class="flex-1 px-3 py-1.5 font-mono text-sm bg-bg border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-accent/70" 255 + class="flex-1 min-w-25" 256 + size="small" 256 257 /> 257 258 <button 258 259 type="submit" 259 260 :disabled="!otpInput.trim() || isExecuting" 260 - class="px-3 py-1.5 font-mono text-xs text-bg bg-amber-500 rounded transition-all duration-200 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50" 261 + class="px-3 py-2 font-mono text-xs text-bg bg-amber-500 rounded transition-all duration-200 hover:bg-amber-400 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/50" 261 262 > 262 263 {{ isExecuting ? $t('operations.queue.retrying') : $t('operations.queue.retry_otp') }} 263 264 </button>
+15 -12
app/components/Org/TeamsPanel.vue
··· 287 287 aria-hidden="true" 288 288 /> 289 289 <label for="teams-search" class="sr-only">{{ $t('org.teams.filter_label') }}</label> 290 - <input 290 + <InputBase 291 291 id="teams-search" 292 292 v-model="searchQuery" 293 293 type="search" 294 294 name="teams-search" 295 295 :placeholder="$t('org.teams.filter_placeholder')" 296 - v-bind="noCorrect" 297 - class="w-full ps-7 pe-2 py-1.5 font-mono text-sm bg-bg-subtle border border-border rounded text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70)" 296 + no-correct 297 + class="w-full min-w-25 ps-7" 298 + size="medium" 298 299 /> 299 300 </div> 300 301 <div ··· 425 426 > 426 427 ~{{ user }} 427 428 </NuxtLink> 428 - <span class="font-mono text-sm text-fg">{{ teamName }}</span> 429 + <span class="font-mono text-sm text-fg mx-2">{{ teamName }}</span> 429 430 <button 430 431 type="button" 431 432 class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 rounded focus-visible:outline-accent/70" ··· 446 447 <label :for="`add-user-${teamName}`" class="sr-only">{{ 447 448 $t('org.teams.username_to_add', { team: teamName }) 448 449 }}</label> 449 - <input 450 + <InputBase 450 451 :id="`add-user-${teamName}`" 451 452 v-model="newUserUsername" 452 453 type="text" 453 454 :name="`add-user-${teamName}`" 454 455 :placeholder="$t('org.teams.username_placeholder')" 455 - v-bind="noCorrect" 456 - class="flex-1 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" 456 + no-correct 457 + class="flex-1 min-w-25" 458 + size="medium" 457 459 /> 458 460 <button 459 461 type="submit" ··· 497 499 <form class="flex items-center gap-2" @submit.prevent="handleCreateTeam"> 498 500 <div class="flex-1 flex items-center"> 499 501 <span 500 - class="px-2 py-1.5 font-mono text-sm text-fg-subtle bg-bg border border-ie-0 border-border rounded-is" 502 + class="px-2 py-3 leading-none font-mono text-sm text-fg-subtle bg-bg border border-ie-0 border-border rounded-is" 501 503 > 502 504 {{ orgName }}: 503 505 </span> 504 506 <label for="new-team-name" class="sr-only">{{ $t('org.teams.team_name_label') }}</label> 505 - <input 507 + <InputBase 506 508 id="new-team-name" 507 509 v-model="newTeamName" 508 510 type="text" 509 511 name="new-team-name" 510 512 :placeholder="$t('org.teams.team_name_placeholder')" 511 - v-bind="noCorrect" 512 - class="flex-1 px-2 py-1.5 font-mono text-sm bg-bg border border-border rounded-ie text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-border-hover focus-visible:outline-accent/70" 513 + no-correct 514 + class="flex-1 min-w-25 rounded-is-none" 515 + size="medium" 513 516 /> 514 517 </div> 515 518 <button 516 519 type="submit" 517 520 :disabled="!newTeamName.trim() || isCreatingTeam" 518 - class="px-3 py-1.5 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 521 + class="px-3 py-2 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 519 522 > 520 523 {{ isCreatingTeam ? '…' : $t('org.teams.create_button') }} 521 524 </button>
+1 -1
app/components/Package/AccessControls.vue
··· 281 281 <button 282 282 type="submit" 283 283 :disabled="!selectedTeam || isGranting" 284 - class="px-3 py-1.5 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 284 + class="px-3 py-2 font-mono text-xs text-bg bg-fg rounded transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70" 285 285 > 286 286 {{ isGranting ? '…' : $t('package.access.grant_button') }} 287 287 </button>
+17 -13
app/components/Package/DownloadAnalytics.vue
··· 1489 1489 id="granularity" 1490 1490 v-model="selectedGranularity" 1491 1491 :disabled="pending" 1492 - class="w-full px-2.5 py-1.75 bg-bg-subtle font-mono text-sm text-fg outline-none appearance-none focus-visible:outline-accent/70" 1492 + class="w-full px-4 py-3 leading-none bg-bg-subtle font-mono text-sm text-fg outline-none appearance-none focus-visible:outline-accent/70" 1493 1493 > 1494 1494 <option value="daily"> 1495 1495 {{ $t('package.trends.granularity_daily') }} ··· 1515 1515 > 1516 1516 {{ $t('package.trends.start_date') }} 1517 1517 </label> 1518 - <div 1519 - class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-accent/70)" 1520 - > 1521 - <span class="i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0" aria-hidden="true" /> 1522 - <input 1518 + <div class="relative flex items-center"> 1519 + <span 1520 + class="absolute inset-is-2 i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none" 1521 + aria-hidden="true" 1522 + /> 1523 + <InputBase 1523 1524 id="startDate" 1524 1525 v-model="startDate" 1525 1526 :disabled="pending" 1526 1527 type="date" 1527 - class="w-full min-w-0 bg-transparent font-mono text-sm text-fg outline-none [color-scheme:light] dark:[color-scheme:dark]" 1528 + class="w-full min-w-0 bg-transparent ps-7" 1529 + size="medium" 1528 1530 /> 1529 1531 </div> 1530 1532 </div> ··· 1533 1535 <label for="endDate" class="text-3xs font-mono text-fg-subtle tracking-wide uppercase"> 1534 1536 {{ $t('package.trends.end_date') }} 1535 1537 </label> 1536 - <div 1537 - class="flex items-center gap-2 px-2.5 py-1.75 bg-bg-subtle border border-border rounded-md focus-within:(border-border-hover ring-2 ring-accent/70)" 1538 - > 1539 - <span class="i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0" aria-hidden="true" /> 1540 - <input 1538 + <div class="relative flex items-center"> 1539 + <span 1540 + class="absolute inset-is-2 i-carbon:calendar w-4 h-4 text-fg-subtle shrink-0 pointer-events-none" 1541 + aria-hidden="true" 1542 + /> 1543 + <InputBase 1541 1544 id="endDate" 1542 1545 v-model="endDate" 1543 1546 :disabled="pending" 1544 1547 type="date" 1545 - class="w-full min-w-0 bg-transparent font-mono text-sm text-fg outline-none [color-scheme:light] dark:[color-scheme:dark]" 1548 + class="w-full min-w-0 bg-transparent ps-7" 1549 + size="medium" 1546 1550 /> 1547 1551 </div> 1548 1552 </div>
+5 -4
app/components/Package/ListControls.vue
··· 61 61 > 62 62 <div class="i-carbon:search w-4 h-4" /> 63 63 </div> 64 - <input 64 + <InputBase 65 65 id="package-filter" 66 66 v-model="filterValue" 67 67 type="search" 68 68 :placeholder="placeholder ?? $t('package.list.filter_placeholder')" 69 - v-bind="noCorrect" 70 - class="w-full bg-bg-subtle border border-border rounded-lg ps-10 pe-4 py-2 font-mono text-sm text-fg placeholder:text-fg-subtle transition-colors duration-200 focus:border-accent focus-visible:(outline-2 outline-accent/70)" 69 + no-correct 70 + class="w-full min-w-25 ps-10" 71 + size="medium" 71 72 /> 72 73 </div> 73 74 ··· 78 79 <select 79 80 id="package-sort" 80 81 v-model="sortValue" 81 - class="appearance-none bg-bg-subtle border border-border rounded-lg ps-3 pe-8 py-2 font-mono text-sm text-fg transition-colors duration-200 focus:(border-border-hover outline-none) hover:border-border-hover" 82 + class="appearance-none bg-bg-subtle border border-border rounded-lg ps-3 pe-8 py-3 leading-none font-mono text-sm text-fg transition-colors duration-200 focus:(border-border-hover outline-none) hover:border-border-hover" 82 83 > 83 84 <option v-for="option in sortOptions" :key="option.value" :value="option.value"> 84 85 {{ option.label }}
+4 -3
app/components/Package/Maintainers.vue
··· 254 254 <label for="add-owner-username" class="sr-only">{{ 255 255 $t('package.maintainers.username_to_add') 256 256 }}</label> 257 - <input 257 + <InputBase 258 258 id="add-owner-username" 259 259 v-model="newOwnerUsername" 260 260 type="text" 261 261 name="add-owner-username" 262 262 :placeholder="$t('package.maintainers.username_placeholder')" 263 - v-bind="noCorrect" 264 - class="flex-1 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" 263 + no-correct 264 + class="flex-1 min-w-25 m-1" 265 + size="small" 265 266 /> 266 267 <ButtonBase type="submit" :disabled="!newOwnerUsername.trim() || isAdding"> 267 268 {{ isAdding ? '…' : $t('package.maintainers.add_button') }}
+8 -7
app/pages/index.vue
··· 3 3 import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks' 4 4 5 5 const searchQuery = shallowRef('') 6 - const searchInputRef = useTemplateRef('searchInputRef') 7 - const { focused: isSearchFocused } = useFocus(searchInputRef) 6 + const isSearchFocused = shallowRef(false) 8 7 9 8 async function search() { 10 9 const query = searchQuery.value.trim() ··· 73 72 74 73 <div class="relative group" :class="{ 'is-focused': isSearchFocused }"> 75 74 <div 76 - class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100" 75 + class="absolute z-1 -inset-px pointer-events-none rounded-lg bg-gradient-to-r from-fg/0 to-accent/5 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100" 77 76 /> 78 77 79 78 <div class="search-box relative flex items-center"> ··· 83 82 / 84 83 </span> 85 84 86 - <input 85 + <InputBase 87 86 id="home-search" 88 - ref="searchInputRef" 89 87 v-model="searchQuery" 90 88 type="search" 91 89 name="q" 92 90 autofocus 93 91 :placeholder="$t('search.placeholder')" 94 - v-bind="noCorrect" 95 - class="w-full bg-bg-subtle border border-border rounded-xl ps-8 pe-24 h-14 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 motion-reduce:transition-none hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)" 92 + no-correct 93 + size="large" 94 + class="w-full ps-8 pe-24" 95 + @focus="isSearchFocused = true" 96 + @blur="isSearchFocused = false" 96 97 @input="handleInput" 97 98 /> 98 99
+54
test/nuxt/a11y.spec.ts
··· 139 139 HeaderAccountMenu, 140 140 HeaderConnectorModal, 141 141 HeaderSearchBox, 142 + InputBase, 142 143 LicenseDisplay, 143 144 LoadingSpinner, 144 145 PackageProvenanceSection, ··· 2171 2172 describe('HeaderSearchBox', () => { 2172 2173 it('should have no accessibility violations', async () => { 2173 2174 const component = await mountSuspended(HeaderSearchBox) 2175 + const results = await runAxe(component) 2176 + expect(results.violations).toEqual([]) 2177 + }) 2178 + }) 2179 + 2180 + describe('InputBase', () => { 2181 + it('should have no accessibility violations (with aria-label)', async () => { 2182 + const component = await mountSuspended(InputBase, { 2183 + attrs: { 'aria-label': 'Search input' }, 2184 + }) 2185 + const results = await runAxe(component) 2186 + expect(results.violations).toEqual([]) 2187 + }) 2188 + 2189 + it('should have no accessibility violations with placeholder', async () => { 2190 + const component = await mountSuspended(InputBase, { 2191 + attrs: { 'placeholder': 'Search...', 'aria-label': 'Search' }, 2192 + }) 2193 + const results = await runAxe(component) 2194 + expect(results.violations).toEqual([]) 2195 + }) 2196 + 2197 + it('should have no accessibility violations when disabled', async () => { 2198 + const component = await mountSuspended(InputBase, { 2199 + attrs: { 'disabled': '', 'aria-label': 'Disabled input' }, 2200 + }) 2201 + const results = await runAxe(component) 2202 + expect(results.violations).toEqual([]) 2203 + }) 2204 + 2205 + it('should have no accessibility violations with size small', async () => { 2206 + const component = await mountSuspended(InputBase, { 2207 + props: { size: 'small' }, 2208 + attrs: { 'aria-label': 'Small input' }, 2209 + }) 2210 + const results = await runAxe(component) 2211 + expect(results.violations).toEqual([]) 2212 + }) 2213 + 2214 + it('should have no accessibility violations with size large', async () => { 2215 + const component = await mountSuspended(InputBase, { 2216 + props: { size: 'large' }, 2217 + attrs: { 'aria-label': 'Large input' }, 2218 + }) 2219 + const results = await runAxe(component) 2220 + expect(results.violations).toEqual([]) 2221 + }) 2222 + 2223 + it('should have no accessibility violations with noCorrect false', async () => { 2224 + const component = await mountSuspended(InputBase, { 2225 + props: { noCorrect: false }, 2226 + attrs: { 'aria-label': 'Input with corrections' }, 2227 + }) 2174 2228 const results = await runAxe(component) 2175 2229 expect(results.violations).toEqual([]) 2176 2230 })
+143
test/nuxt/components/Input/Base.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import InputBase from '~/components/Input/Base.vue' 4 + 5 + describe('InputBase', () => { 6 + describe('rendering', () => { 7 + it('renders with default empty value', async () => { 8 + const component = await mountSuspended(InputBase) 9 + const input = component.find('input') 10 + expect((input.element as HTMLInputElement).value).toBe('') 11 + }) 12 + 13 + it('renders with initial modelValue', async () => { 14 + const component = await mountSuspended(InputBase, { 15 + props: { modelValue: 'hello' }, 16 + }) 17 + const input = component.find('input') 18 + expect((input.element as HTMLInputElement).value).toBe('hello') 19 + }) 20 + 21 + it('renders empty string when modelValue is undefined or null', async () => { 22 + const withUndefined = await mountSuspended(InputBase, { 23 + props: { modelValue: undefined }, 24 + }) 25 + expect((withUndefined.find('input').element as HTMLInputElement).value).toBe('') 26 + 27 + const withNull = await mountSuspended(InputBase, { 28 + props: { modelValue: null as unknown as string }, 29 + }) 30 + expect((withNull.find('input').element as HTMLInputElement).value).toBe('') 31 + }) 32 + }) 33 + 34 + describe('v-model', () => { 35 + it('updates modelValue when user types', async () => { 36 + const component = await mountSuspended(InputBase, { 37 + props: { modelValue: '' }, 38 + }) 39 + const input = component.find('input') 40 + await input.setValue('test') 41 + expect(component.emitted('update:modelValue')).toBeTruthy() 42 + expect(component.emitted('update:modelValue')?.at(-1)).toEqual(['test']) 43 + }) 44 + 45 + it('reflects modelValue prop changes', async () => { 46 + const component = await mountSuspended(InputBase, { 47 + props: { modelValue: 'initial' }, 48 + }) 49 + await component.setProps({ modelValue: 'updated' }) 50 + const input = component.find('input') 51 + expect((input.element as HTMLInputElement).value).toBe('updated') 52 + }) 53 + }) 54 + 55 + describe('noCorrect prop', () => { 56 + it('applies noCorrect attributes when noCorrect is true (default)', async () => { 57 + const component = await mountSuspended(InputBase) 58 + const input = component.find('input') 59 + expect(input.attributes('autocapitalize')).toBe('off') 60 + expect(input.attributes('autocomplete')).toBe('off') 61 + expect(input.attributes('autocorrect')).toBe('off') 62 + expect(input.attributes('spellcheck')).toBe('false') 63 + }) 64 + 65 + it('does not apply noCorrect attributes when noCorrect is false', async () => { 66 + const component = await mountSuspended(InputBase, { 67 + props: { noCorrect: false }, 68 + }) 69 + const input = component.find('input') 70 + expect(input.attributes('autocapitalize')).toBeUndefined() 71 + expect(input.attributes('autocomplete')).toBeUndefined() 72 + expect(input.attributes('autocorrect')).toBeUndefined() 73 + expect(input.attributes('spellcheck')).toBeUndefined() 74 + }) 75 + }) 76 + 77 + describe('focus and blur', () => { 78 + it('emits focus when input is focused', async () => { 79 + const component = await mountSuspended(InputBase) 80 + const input = component.find('input') 81 + await input.trigger('focus') 82 + expect(component.emitted('focus')).toHaveLength(1) 83 + }) 84 + 85 + it('emits blur when input loses focus', async () => { 86 + const component = await mountSuspended(InputBase) 87 + const input = component.find('input') 88 + await input.trigger('focus') 89 + await input.trigger('blur') 90 + expect(component.emitted('blur')).toHaveLength(1) 91 + }) 92 + }) 93 + 94 + describe('exposed API', () => { 95 + it('exposes focus() that focuses the input', async () => { 96 + const container = document.createElement('div') 97 + document.body.appendChild(container) 98 + const component = await mountSuspended(InputBase, { attachTo: container }) 99 + const input = component.find('input') 100 + expect(container.contains(document.activeElement)).toBe(false) 101 + component.vm.focus() 102 + await component.vm.$nextTick() 103 + expect(container.contains(document.activeElement)).toBe(true) 104 + expect(document.activeElement).toBe(input.element) 105 + container.remove() 106 + }) 107 + 108 + it('exposes blur() that blurs the input', async () => { 109 + const container = document.createElement('div') 110 + document.body.appendChild(container) 111 + const component = await mountSuspended(InputBase, { attachTo: container }) 112 + const input = component.find('input') 113 + input.element.focus() 114 + expect(container.contains(document.activeElement)).toBe(true) 115 + expect(document.activeElement).toBe(input.element) 116 + component.vm.blur() 117 + await component.vm.$nextTick() 118 + expect(container.contains(document.activeElement)).toBe(false) 119 + expect(document.activeElement).not.toBe(input.element) 120 + container.remove() 121 + }) 122 + }) 123 + 124 + describe('accessibility (attrs fallthrough)', () => { 125 + it('accepts placeholder via attrs', async () => { 126 + const component = await mountSuspended(InputBase, { 127 + attrs: { 'placeholder': 'Search packages...', 'aria-label': 'Search input' }, 128 + }) 129 + const input = component.find('input') 130 + expect(input.attributes('placeholder')).toBe('Search packages...') 131 + expect(input.attributes('aria-label')).toBe('Search input') 132 + }) 133 + 134 + it('accepts disabled via attrs', async () => { 135 + const component = await mountSuspended(InputBase, { 136 + attrs: { disabled: '' }, 137 + }) 138 + const input = component.find('input') 139 + expect(input.attributes('disabled')).toBeDefined() 140 + expect((input.element as HTMLInputElement).disabled).toBe(true) 141 + }) 142 + }) 143 + })
-10
test/nuxt/components/compare/PackageSelector.spec.ts
··· 119 119 input = component.find('input') 120 120 expect(input.attributes('placeholder')).toBeTruthy() 121 121 }) 122 - 123 - it('has search icon', async () => { 124 - const component = await mountSuspended(PackageSelector, { 125 - props: { 126 - modelValue: [], 127 - }, 128 - }) 129 - 130 - expect(component.find('.i-carbon\\:search').exists()).toBe(true) 131 - }) 132 122 }) 133 123 134 124 describe('adding packages', () => {