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

feat: add selects to ui library (#1290)

authored by

Alex Savelyev and committed by
GitHub
f431b91c c139ddba

+579 -218
+2 -2
app/components/Input/Base.vue
··· 35 35 v-bind="props.noCorrect ? noCorrect : undefined" 36 36 @focus="emit('focus', $event)" 37 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)" 38 + class="appearance-none 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 39 :class="{ 40 40 'text-xs leading-[1.2] px-2 py-2 rounded-md': size === 'small', 41 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', 42 + 'text-base leading-[1.4] px-6 py-4 rounded-xl': size === 'large', 43 43 }" 44 44 :disabled=" 45 45 /** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */
+48 -41
app/components/Org/MembersPanel.vue
··· 35 35 // Search/filter 36 36 const searchQuery = shallowRef('') 37 37 const filterRole = shallowRef<MemberRoleFilter>('all') 38 - const filterTeam = shallowRef<string | null>(null) 38 + const filterTeam = shallowRef<string>('') 39 39 const sortBy = shallowRef<'name' | 'role'>('name') 40 40 const sortOrder = shallowRef<'asc' | 'desc'>('asc') 41 41 ··· 362 362 </div> 363 363 <!-- Team filter --> 364 364 <div v-if="teamNames.length > 0"> 365 - <label for="team-filter" class="sr-only">{{ $t('org.members.filter_by_team') }}</label> 366 - <select 365 + <SelectField 366 + :label="$t('org.members.filter_by_team')" 367 + hidden-label 367 368 id="team-filter" 368 369 v-model="filterTeam" 369 370 name="team-filter" 370 - class="px-2 py-1 font-mono text-xs bg-bg-subtle border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 371 - > 372 - <option :value="null">{{ $t('org.members.all_teams') }}</option> 373 - <option v-for="team in teamNames" :key="team" :value="team"> 374 - {{ team }} 375 - </option> 376 - </select> 371 + block 372 + size="sm" 373 + :items="[ 374 + { label: $t('org.members.all_teams'), value: '' }, 375 + ...teamNames.map(team => ({ label: team, value: team })), 376 + ]" 377 + /> 377 378 </div> 378 379 <div 379 380 class="flex items-center gap-1 text-xs" ··· 462 463 <label :for="`role-${member.name}`" class="sr-only">{{ 463 464 $t('org.members.change_role_for', { name: member.name }) 464 465 }}</label> 465 - <select 466 + <SelectField 467 + :label="$t('org.members.change_role_for', { name: member.name })" 468 + hidden-label 466 469 :id="`role-${member.name}`" 470 + :model-value="member.role" 471 + :name="`role-${member.name}`" 472 + block 473 + size="sm" 474 + :items="[ 475 + { label: getRoleLabel('developer'), value: 'developer' }, 476 + { label: getRoleLabel('admin'), value: 'admin' }, 477 + { label: getRoleLabel('owner'), value: 'owner' }, 478 + ]" 467 479 :value="member.role" 468 - :name="`role-${member.name}`" 469 - class="px-1.5 py-0.5 font-mono text-xs bg-bg-subtle border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 470 - @change=" 471 - handleChangeRole( 472 - member.name, 473 - ($event.target as HTMLSelectElement).value as 'developer' | 'admin' | 'owner', 474 - ) 475 - " 476 - > 477 - <option value="developer">{{ getRoleLabel('developer') }}</option> 478 - <option value="admin">{{ getRoleLabel('admin') }}</option> 479 - <option value="owner">{{ getRoleLabel('owner') }}</option> 480 - </select> 480 + @update:modelValue="value => handleChangeRole(member.name, value as MemberRole)" 481 + /> 481 482 <!-- Remove button --> 482 483 <button 483 484 type="button" ··· 528 529 size="small" 529 530 /> 530 531 <div class="flex items-center gap-2"> 531 - <label for="new-member-role" class="sr-only">{{ $t('org.members.role_label') }}</label> 532 - <select 532 + <SelectField 533 + :label="$t('org.members.role_label')" 534 + hidden-label 533 535 id="new-member-role" 534 536 v-model="newRole" 535 537 name="new-member-role" 536 - class="flex-1 px-2 py-1.5 font-mono text-sm bg-bg border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 537 - > 538 - <option value="developer">{{ $t('org.members.role.developer') }}</option> 539 - <option value="admin">{{ $t('org.members.role.admin') }}</option> 540 - <option value="owner">{{ $t('org.members.role.owner') }}</option> 541 - </select> 538 + block 539 + class="flex-1" 540 + size="sm" 541 + :items="[ 542 + { label: $t('org.members.role.developer'), value: 'developer' }, 543 + { label: $t('org.members.role.admin'), value: 'admin' }, 544 + { label: $t('org.members.role.owner'), value: 'owner' }, 545 + ]" 546 + /> 542 547 <!-- Team selection --> 543 - <label for="new-member-team" class="sr-only">{{ $t('org.members.team_label') }}</label> 544 - <select 548 + <SelectField 549 + :label="$t('org.members.team_label')" 550 + hidden-label 545 551 id="new-member-team" 546 552 v-model="newTeam" 547 553 name="new-member-team" 548 - class="flex-1 px-2 py-1.5 font-mono text-sm bg-bg border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 549 - > 550 - <option value="">{{ $t('org.members.no_team') }}</option> 551 - <option v-for="team in teamNames" :key="team" :value="team"> 552 - {{ team }} 553 - </option> 554 - </select> 554 + block 555 + class="flex-1" 556 + size="sm" 557 + :items="[ 558 + { label: $t('org.members.no_team'), value: '' }, 559 + ...teamNames.map(team => ({ label: team, value: team })), 560 + ]" 561 + /> 555 562 <button 556 563 type="submit" 557 564 :disabled="!newUsername.trim() || isAddingMember"
+25 -27
app/components/Package/AccessControls.vue
··· 243 243 <div v-if="showGrantAccess"> 244 244 <form class="space-y-2" @submit.prevent="handleGrantAccess"> 245 245 <div class="flex items-center gap-2"> 246 - <label for="grant-team-select" class="sr-only">{{ 247 - $t('package.access.select_team_label') 248 - }}</label> 249 - <select 246 + <SelectField 247 + :label="$t('package.access.select_team_label')" 248 + hidden-label 250 249 id="grant-team-select" 251 250 v-model="selectedTeam" 252 251 name="grant-team" 253 - class="flex-1 px-2 py-1.5 font-mono text-sm bg-bg-subtle border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 252 + block 253 + size="sm" 254 254 :disabled="isLoadingTeams" 255 - > 256 - <option value="" disabled> 257 - {{ 258 - isLoadingTeams 255 + :items="[ 256 + { 257 + label: isLoadingTeams 259 258 ? $t('package.access.loading_teams') 260 - : $t('package.access.select_team') 261 - }} 262 - </option> 263 - <option v-for="team in teams" :key="team" :value="team"> 264 - {{ orgName }}:{{ team }} 265 - </option> 266 - </select> 267 - </div> 268 - <div class="flex items-center gap-2"> 269 - <label for="grant-permission-select" class="sr-only">{{ 270 - $t('package.access.permission_label') 271 - }}</label> 272 - <select 259 + : $t('package.access.select_team'), 260 + value: '', 261 + disabled: true, 262 + }, 263 + ...teams.map(team => ({ label: `${orgName}:${team}`, value: team })), 264 + ]" 265 + /> 266 + <SelectField 267 + :label="$t('package.access.permission_label')" 268 + hidden-label 273 269 id="grant-permission-select" 274 270 v-model="permission" 275 271 name="grant-permission" 276 - class="flex-1 px-2 py-1.5 font-mono text-sm bg-bg-subtle border border-border rounded text-fg transition-colors duration-200 focus:border-border-hover" 277 - > 278 - <option value="read-only">{{ $t('package.access.permission.read_only') }}</option> 279 - <option value="read-write">{{ $t('package.access.permission.read_write') }}</option> 280 - </select> 272 + block 273 + size="sm" 274 + :items="[ 275 + { label: $t('package.access.permission.read_only'), value: 'read-only' }, 276 + { label: $t('package.access.permission.read_write'), value: 'read-write' }, 277 + ]" 278 + /> 281 279 <button 282 280 type="submit" 283 281 :disabled="!selectedTeam || isGranting"
+14 -34
app/components/Package/DownloadAnalytics.vue
··· 1580 1580 <div class="w-full relative" id="download-analytics" :aria-busy="pending ? 'true' : 'false'"> 1581 1581 <div class="w-full mb-4 flex flex-col gap-3"> 1582 1582 <div class="flex flex-col sm:flex-row gap-3 sm:gap-2 sm:items-end"> 1583 - <div class="flex flex-col gap-1 sm:shrink-0"> 1584 - <label 1585 - for="granularity" 1586 - class="text-3xs font-mono text-fg-subtle tracking-wide uppercase" 1587 - > 1588 - {{ $t('package.trends.granularity') }} 1589 - </label> 1590 - 1591 - <div 1592 - class="flex items-center bg-bg-subtle border border-border rounded-md overflow-hidden" 1593 - > 1594 - <select 1595 - id="granularity" 1596 - v-model="selectedGranularity" 1597 - :disabled="pending" 1598 - 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" 1599 - > 1600 - <option value="daily"> 1601 - {{ $t('package.trends.granularity_daily') }} 1602 - </option> 1603 - <option value="weekly"> 1604 - {{ $t('package.trends.granularity_weekly') }} 1605 - </option> 1606 - <option value="monthly"> 1607 - {{ $t('package.trends.granularity_monthly') }} 1608 - </option> 1609 - <option value="yearly"> 1610 - {{ $t('package.trends.granularity_yearly') }} 1611 - </option> 1612 - </select> 1613 - </div> 1614 - </div> 1583 + <SelectField 1584 + :label="$t('package.trends.granularity')" 1585 + id="granularity" 1586 + v-model="selectedGranularity" 1587 + :disabled="pending" 1588 + :items="[ 1589 + { label: $t('package.trends.granularity_daily'), value: 'daily' }, 1590 + { label: $t('package.trends.granularity_weekly'), value: 'weekly' }, 1591 + { label: $t('package.trends.granularity_monthly'), value: 'monthly' }, 1592 + { label: $t('package.trends.granularity_yearly'), value: 'yearly' }, 1593 + ]" 1594 + /> 1615 1595 1616 1596 <div class="grid grid-cols-2 gap-2 flex-1"> 1617 1597 <div class="flex flex-col gap-1"> 1618 1598 <label 1619 1599 for="startDate" 1620 - class="text-3xs font-mono text-fg-subtle tracking-wide uppercase" 1600 + class="text-2xs font-mono text-fg-subtle tracking-wide uppercase" 1621 1601 > 1622 1602 {{ $t('package.trends.start_date') }} 1623 1603 </label> ··· 1638 1618 </div> 1639 1619 1640 1620 <div class="flex flex-col gap-1"> 1641 - <label for="endDate" class="text-3xs font-mono text-fg-subtle tracking-wide uppercase"> 1621 + <label for="endDate" class="text-2xs font-mono text-fg-subtle tracking-wide uppercase"> 1642 1622 {{ $t('package.trends.end_date') }} 1643 1623 </label> 1644 1624 <div class="relative flex items-center">
+8 -20
app/components/Package/ListControls.vue
··· 73 73 </div> 74 74 75 75 <!-- Sort select --> 76 - <div class="relative shrink-0 flex"> 77 - <label for="package-sort" class="sr-only">{{ $t('package.list.sort_label') }}</label> 78 - <div class="relative"> 79 - <select 80 - id="package-sort" 81 - v-model="sortValue" 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" 83 - > 84 - <option v-for="option in sortOptions" :key="option.value" :value="option.value"> 85 - {{ option.label }} 86 - </option> 87 - </select> 88 - <div 89 - class="absolute inset-ie-3 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none" 90 - aria-hidden="true" 91 - > 92 - <div class="i-carbon:chevron-down w-4 h-4" /> 93 - </div> 94 - </div> 95 - </div> 76 + <SelectField 77 + :label="$t('package.list.sort_label')" 78 + hidden-label 79 + id="package-sort" 80 + class="relative shrink-0" 81 + v-model="sortValue" 82 + :items="sortOptions.map(option => ({ label: option.label, value: option.value }))" 83 + /> 96 84 </div> 97 85 98 86 <!-- Filtered count indicator -->
+21 -31
app/components/Package/ListToolbar.vue
··· 77 77 }) 78 78 79 79 // Handle sort key change from dropdown 80 - function handleSortKeyChange(event: Event) { 81 - const target = event.target as HTMLSelectElement 82 - const newKey = target.value as SortKey 83 - const config = SORT_KEYS.find(k => k.key === newKey) 84 - const direction = config?.defaultDirection ?? 'desc' 85 - sortOption.value = buildSortOption(newKey, direction) 86 - } 80 + const sortKeyModel = computed<SortKey>({ 81 + get: () => currentSort.value.key, 82 + set: newKey => { 83 + const config = SORT_KEYS.find(k => k.key === newKey) 84 + const direction = config?.defaultDirection ?? 'desc' 85 + sortOption.value = buildSortOption(newKey, direction) 86 + }, 87 + }) 87 88 88 89 // Toggle sort direction 89 90 function handleToggleDirection() { ··· 162 163 <!-- Sort controls --> 163 164 <div class="flex items-center gap-1 shrink-0 order-1 sm:order-1"> 164 165 <!-- Sort key dropdown --> 165 - <div class="relative"> 166 - <label for="sort-select" class="sr-only">{{ $t('filters.sort.label') }}</label> 167 - <select 168 - id="sort-select" 169 - :value="currentSort.key" 170 - class="appearance-none bg-bg-subtle border border-border rounded-md ps-3 pe-8 py-1.5 font-mono text-sm text-fg transition-colors duration-200 hover:border-border-hover" 171 - @change="handleSortKeyChange" 172 - > 173 - <option 174 - v-for="keyConfig in availableSortKeys" 175 - :key="keyConfig.key" 176 - :value="keyConfig.key" 177 - :disabled="keyConfig.disabled" 178 - > 179 - {{ getSortKeyLabelKey(keyConfig.key) }} 180 - </option> 181 - </select> 182 - <div 183 - class="flex items-center absolute inset-ie-2 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none" 184 - aria-hidden="true" 185 - > 186 - <span class="i-carbon-chevron-down w-4 h-4" /> 187 - </div> 188 - </div> 166 + <SelectField 167 + :label="$t('filters.sort.label')" 168 + hidden-label 169 + id="sort-select" 170 + v-model="sortKeyModel" 171 + :items=" 172 + availableSortKeys.map(keyConfig => ({ 173 + label: getSortKeyLabelKey(keyConfig.key), 174 + value: keyConfig.key, 175 + disabled: keyConfig.disabled, 176 + })) 177 + " 178 + /> 189 179 190 180 <!-- Sort direction toggle --> 191 181 <button
+16 -13
app/components/PaginationControls.vue
··· 12 12 const pageSize = defineModel<PageSize>('pageSize', { required: true }) 13 13 const currentPage = defineModel<number>('currentPage', { required: true }) 14 14 15 + const pageSizeSelectValue = computed(() => String(pageSize.value)) 16 + 15 17 // Whether we should show pagination controls (table view always uses pagination) 16 18 const shouldShowControls = computed(() => props.viewMode === 'table' || mode.value === 'paginated') 17 19 ··· 149 151 150 152 <!-- Page size (shown when paginated or table view) --> 151 153 <div v-if="effectiveMode === 'paginated'" class="relative shrink-0"> 152 - <label for="page-size" class="sr-only">{{ $t('filters.pagination.items_per_page') }}</label> 153 - <select 154 + <SelectField 155 + :label="$t('filters.pagination.items_per_page')" 156 + hidden-label 154 157 id="page-size" 155 - :value="pageSize" 156 - class="appearance-none bg-bg-subtle border border-border rounded-md ps-3 pe-8 py-1 font-mono text-sm text-fg transition-colors duration-200 hover:border-border-hover" 158 + v-model="pageSizeSelectValue" 157 159 @change="handlePageSizeChange" 158 - > 159 - <option v-for="size in PAGE_SIZE_OPTIONS" :key="size" :value="size"> 160 - {{ 161 - size === 'all' 162 - ? $t('filters.pagination.all_yolo') 163 - : $t('filters.pagination.per_page', { count: size }) 164 - }} 165 - </option> 166 - </select> 160 + :items=" 161 + PAGE_SIZE_OPTIONS.map(size => ({ 162 + label: 163 + size === 'all' 164 + ? $t('filters.pagination.all_yolo') 165 + : $t('filters.pagination.per_page', { count: size }), 166 + value: String(size), 167 + })) 168 + " 169 + /> 167 170 <div 168 171 class="flex items-center absolute inset-ie-2 top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none" 169 172 aria-hidden="true"
+33
app/components/Select/Base.vue
··· 1 + <script setup lang="ts"> 2 + const model = defineModel<string | undefined>({ default: undefined }) 3 + 4 + const SELECT_SIZES = { 5 + none: '', 6 + sm: 'text-xs px-2 py-1.75 rounded-md', 7 + md: 'text-sm px-3 py-2.25 rounded-lg', 8 + lg: 'text-base px-6 py-4 rounded-xl', 9 + } 10 + 11 + export type SelectBaseProps = { 12 + disabled?: boolean 13 + size?: keyof typeof SELECT_SIZES 14 + } 15 + 16 + const props = withDefaults(defineProps<SelectBaseProps>(), { 17 + size: 'md', 18 + }) 19 + </script> 20 + 21 + <template> 22 + <select 23 + v-model="model" 24 + class="bg-bg 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)" 25 + :class="[SELECT_SIZES[size]]" 26 + :disabled=" 27 + /** Catching Vue render-bug of invalid `disabled=false` attribute in the final HTML */ 28 + disabled ? true : undefined 29 + " 30 + > 31 + <slot /> 32 + </select> 33 + </template>
+77
app/components/Select/Field.vue
··· 1 + <script setup lang="ts"> 2 + import type { SelectBaseProps } from './Base.vue' 3 + 4 + const SELECT_FIELD_SIZES = { 5 + sm: 'text-xs py-1.75 ps-2 pe-6 rounded-md', 6 + md: 'text-sm py-2.25 ps-3 pe-9 rounded-lg', 7 + lg: 'text-base py-4 ps-6 pe-15 rounded-xl', 8 + } 9 + const SELECT_FIELD_ICON_SIZES = { 10 + sm: 'inset-ie-2 size-[0.75rem]', 11 + md: 'inset-ie-3 size-[1rem]', 12 + lg: 'inset-ie-5 size-[1.5rem]', 13 + } 14 + const SELECT_FIELD_LABEL_SIZES = { 15 + sm: 'text-2xs', 16 + md: 'text-xs', 17 + lg: 'text-sm', 18 + } 19 + 20 + const model = defineModel<string | undefined>({ default: undefined }) 21 + 22 + export interface SelectFieldProps extends SelectBaseProps { 23 + items: { label: string; value: string; disabled?: boolean }[] 24 + size?: keyof typeof SELECT_FIELD_SIZES 25 + selectAttrs?: Omit<SelectBaseProps, 'size' | 'id'> & 26 + Record<string, string | number | boolean | undefined> 27 + label?: string 28 + labelAttrs?: Record<string, string | number | boolean | undefined> 29 + /** Visually hide label */ 30 + hiddenLabel?: boolean 31 + id: string 32 + /** Render select full width */ 33 + block?: boolean 34 + } 35 + 36 + const props = withDefaults(defineProps<SelectFieldProps>(), { 37 + size: 'md', 38 + }) 39 + </script> 40 + 41 + <template> 42 + <div class="group/select"> 43 + <label 44 + v-if="label" 45 + :for="id" 46 + v-bind="labelAttrs" 47 + class="block mb-1 font-mono text-fg-subtle tracking-wide uppercase" 48 + :class="[hiddenLabel ? 'sr-only' : '', SELECT_FIELD_LABEL_SIZES[size]]" 49 + >{{ label }}</label 50 + > 51 + <div class="relative" :class="[block ? 'w-full' : 'w-fit']"> 52 + <SelectBase 53 + :disabled="disabled" 54 + size="none" 55 + class="appearance-none group-hover/select:border-fg-muted" 56 + :class="[SELECT_FIELD_SIZES[size], block ? 'w-full' : 'w-fit']" 57 + v-model="model" 58 + v-bind="selectAttrs" 59 + :id="id" 60 + > 61 + <option 62 + v-for="item in items" 63 + :key="item.value" 64 + :value="item.value" 65 + :disabled="item.disabled" 66 + > 67 + {{ item.label }} 68 + </option> 69 + </SelectBase> 70 + <span 71 + aria-hidden="true" 72 + class="block i-carbon:chevron-down absolute top-1/2 -translate-y-1/2 text-fg-subtle pointer-events-none group-hover/select:text-fg group-focus-within/select:text-fg" 73 + :class="[SELECT_FIELD_ICON_SIZES[size]]" 74 + /> 75 + </div> 76 + </div> 77 + </template>
+41 -50
app/pages/settings.vue
··· 77 77 <label for="theme-select" class="block text-sm text-fg font-medium"> 78 78 {{ $t('settings.theme') }} 79 79 </label> 80 - <select 80 + <SelectField 81 81 id="theme-select" 82 - :value="colorMode.preference" 83 - class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg duration-200 transition-colors hover:border-fg-subtle" 84 - @change=" 85 - colorMode.preference = ($event.target as HTMLSelectElement).value as 86 - | 'light' 87 - | 'dark' 88 - | 'system' 89 - " 90 - > 91 - <option value="system"> 92 - {{ $t('settings.theme_system') }} 93 - </option> 94 - <option value="light">{{ $t('settings.theme_light') }}</option> 95 - <option value="dark">{{ $t('settings.theme_dark') }}</option> 96 - </select> 82 + v-model="colorMode.preference" 83 + block 84 + size="sm" 85 + class="max-w-48" 86 + :items="[ 87 + { label: $t('settings.theme_system'), value: 'system' }, 88 + { label: $t('settings.theme_light'), value: 'light' }, 89 + { label: $t('settings.theme_dark'), value: 'dark' }, 90 + ]" 91 + /> 97 92 </div> 98 93 99 94 <!-- Accent colors --> ··· 163 158 </p> 164 159 165 160 <ClientOnly> 166 - <select 161 + <SelectField 167 162 id="search-provider-select" 168 - :value="settings.searchProvider" 169 - class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg cursor-pointer duration-200 transition-colors hover:border-fg-subtle" 170 - @change=" 171 - settings.searchProvider = ($event.target as HTMLSelectElement) 172 - .value as typeof settings.searchProvider 173 - " 174 - > 175 - <option value="npm"> 176 - {{ $t('settings.data_source.npm') }} 177 - </option> 178 - <option value="algolia"> 179 - {{ $t('settings.data_source.algolia') }} 180 - </option> 181 - </select> 163 + :items="[ 164 + { label: $t('settings.data_source.npm'), value: 'npm' }, 165 + { label: $t('settings.data_source.algolia'), value: 'algolia' }, 166 + ]" 167 + v-model="settings.searchProvider" 168 + block 169 + size="sm" 170 + class="max-w-48" 171 + /> 182 172 <template #fallback> 183 - <select 173 + <SelectField 184 174 id="search-provider-select" 185 175 disabled 186 - class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg opacity-50 cursor-wait duration-200 transition-colors hover:border-fg-subtle" 187 - > 188 - <option>{{ $t('common.loading') }}</option> 189 - </select> 176 + :items="[{ label: $t('common.loading'), value: 'loading' }]" 177 + block 178 + size="sm" 179 + class="max-w-48" 180 + /> 190 181 </template> 191 182 </ClientOnly> 192 183 ··· 226 217 </label> 227 218 228 219 <ClientOnly> 229 - <select 220 + <SelectField 230 221 id="language-select" 231 - :value="locale" 232 - class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg focus-visible:outline-accent/70 duration-200 transition-colors hover:border-fg-subtle" 233 - @change="setLocale(($event.target as HTMLSelectElement).value as typeof locale)" 234 - > 235 - <option v-for="loc in locales" :key="loc.code" :value="loc.code" :lang="loc.code"> 236 - {{ loc.name }} 237 - </option> 238 - </select> 222 + :items="locales.map(loc => ({ label: loc.name ?? '', value: loc.code }))" 223 + v-model="locale" 224 + @update:modelValue="setLocale($event as typeof locale)" 225 + block 226 + size="sm" 227 + class="max-w-48" 228 + /> 239 229 <template #fallback> 240 - <select 230 + <SelectField 241 231 id="language-select" 242 232 disabled 243 - class="w-full sm:w-auto min-w-48 bg-bg border border-border rounded-md px-3 py-2 text-sm text-fg opacity-50 cursor-wait duration-200 transition-colors hover:border-fg-subtle" 244 - > 245 - <option>{{ $t('common.loading') }}</option> 246 - </select> 233 + :items="[{ label: $t('common.loading'), value: 'loading' }]" 234 + block 235 + size="sm" 236 + class="max-w-48" 237 + /> 247 238 </template> 248 239 </ClientOnly> 249 240 </div>
+92
test/nuxt/a11y.spec.ts
··· 176 176 ReadmeTocDropdown, 177 177 SearchProviderToggle, 178 178 SearchSuggestionCard, 179 + SelectBase, 180 + SelectField, 179 181 SettingsAccentColorPicker, 180 182 SettingsBgThemePicker, 181 183 SettingsToggle, ··· 2226 2228 const component = await mountSuspended(InputBase, { 2227 2229 props: { noCorrect: false }, 2228 2230 attrs: { 'aria-label': 'Input with corrections' }, 2231 + }) 2232 + const results = await runAxe(component) 2233 + expect(results.violations).toEqual([]) 2234 + }) 2235 + }) 2236 + 2237 + describe('SelectBase', () => { 2238 + it('should have no accessibility violations with options and aria-label', async () => { 2239 + const component = await mountSuspended(SelectBase, { 2240 + attrs: { 'aria-label': 'Choose option' }, 2241 + slots: { 2242 + default: 2243 + '<option value="option1">option 1</option><option value="option2">option 2</option>', 2244 + }, 2245 + }) 2246 + const results = await runAxe(component) 2247 + expect(results.violations).toEqual([]) 2248 + }) 2249 + 2250 + it('should have no accessibility violations when disabled', async () => { 2251 + const component = await mountSuspended(SelectBase, { 2252 + props: { disabled: true }, 2253 + attrs: { 'aria-label': 'Disabled select' }, 2254 + slots: { default: '<option value="option1">option 1</option>' }, 2255 + }) 2256 + const results = await runAxe(component) 2257 + expect(results.violations).toEqual([]) 2258 + }) 2259 + 2260 + it('should have no accessibility violations with size small', async () => { 2261 + const component = await mountSuspended(SelectBase, { 2262 + props: { size: 'sm' }, 2263 + attrs: { 'aria-label': 'Small select' }, 2264 + slots: { default: '<option value="option1">option 1</option>' }, 2265 + }) 2266 + const results = await runAxe(component) 2267 + expect(results.violations).toEqual([]) 2268 + }) 2269 + }) 2270 + 2271 + describe('SelectField', () => { 2272 + it('should have no accessibility violations with label and items', async () => { 2273 + const component = await mountSuspended(SelectField, { 2274 + props: { 2275 + id: 'a11y-select-1', 2276 + label: 'Choose one', 2277 + items: [ 2278 + { label: 'Option 1', value: 'option1' }, 2279 + { label: 'Option 2', value: 'option2' }, 2280 + ], 2281 + }, 2282 + }) 2283 + const results = await runAxe(component) 2284 + expect(results.violations).toEqual([]) 2285 + }) 2286 + 2287 + it('should have no accessibility violations with hiddenLabel', async () => { 2288 + const component = await mountSuspended(SelectField, { 2289 + props: { 2290 + id: 'a11y-select-2', 2291 + label: 'Hidden', 2292 + hiddenLabel: true, 2293 + items: [{ label: 'Option 1', value: 'option1' }], 2294 + }, 2295 + }) 2296 + const results = await runAxe(component) 2297 + expect(results.violations).toEqual([]) 2298 + }) 2299 + 2300 + it('should have no accessibility violations when disabled', async () => { 2301 + const component = await mountSuspended(SelectField, { 2302 + props: { 2303 + id: 'a11y-select-3', 2304 + selectAttrs: { 'aria-label': 'Disabled select' }, 2305 + items: [{ label: 'Option 1', value: 'option1' }], 2306 + disabled: true, 2307 + }, 2308 + }) 2309 + const results = await runAxe(component) 2310 + expect(results.violations).toEqual([]) 2311 + }) 2312 + 2313 + it('should have no accessibility violations with size small', async () => { 2314 + const component = await mountSuspended(SelectField, { 2315 + props: { 2316 + id: 'a11y-select-4', 2317 + selectAttrs: { 'aria-label': 'Disabled select' }, 2318 + items: [{ label: 'Option 1', value: 'option1' }], 2319 + size: 'sm', 2320 + }, 2229 2321 }) 2230 2322 const results = await runAxe(component) 2231 2323 expect(results.violations).toEqual([])
+78
test/nuxt/components/Select/Base.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import SelectBase from '~/components/Select/Base.vue' 4 + 5 + describe('SelectBase', () => { 6 + describe('rendering', () => { 7 + it('renders native select with slot options', async () => { 8 + const component = await mountSuspended(SelectBase, { 9 + slots: { 10 + default: 11 + '<option value="option1">option 1</option><option value="option2">option 2</option>', 12 + }, 13 + }) 14 + const select = component.find('select') 15 + expect(select.exists()).toBe(true) 16 + expect(component.findAll('option')).toHaveLength(2) 17 + }) 18 + 19 + it('renders with initial modelValue', async () => { 20 + const component = await mountSuspended(SelectBase, { 21 + props: { modelValue: 'option2' }, 22 + slots: { 23 + default: 24 + '<option value="option1">option 1</option><option value="option2">option 2</option>', 25 + }, 26 + }) 27 + const select = component.find('select').element 28 + expect(select.value).toBe('option2') 29 + }) 30 + 31 + it('renders without disabled attribute when disabled is false', async () => { 32 + const component = await mountSuspended(SelectBase, { 33 + props: { disabled: false }, 34 + slots: { default: '<option value="option1">option 1</option>' }, 35 + }) 36 + const select = component.find('select') 37 + expect(select.attributes('disabled')).toBeUndefined() 38 + }) 39 + 40 + it('renders disabled when disabled is true', async () => { 41 + const component = await mountSuspended(SelectBase, { 42 + props: { disabled: true }, 43 + slots: { default: '<option value="option1">option 1</option>' }, 44 + }) 45 + const select = component.find('select').element 46 + expect(select.disabled).toBe(true) 47 + }) 48 + }) 49 + 50 + describe('v-model', () => { 51 + it('emits update:modelValue when selection changes', async () => { 52 + const component = await mountSuspended(SelectBase, { 53 + props: { modelValue: 'option1' }, 54 + slots: { 55 + default: 56 + '<option value="option1">option 1</option><option value="option2">option 2</option>', 57 + }, 58 + }) 59 + const select = component.find('select') 60 + await select.setValue('option2') 61 + expect(component.emitted('update:modelValue')).toBeTruthy() 62 + expect(component.emitted('update:modelValue')?.at(-1)).toEqual(['option2']) 63 + }) 64 + 65 + it('reflects modelValue prop changes', async () => { 66 + const component = await mountSuspended(SelectBase, { 67 + props: { modelValue: 'option1' }, 68 + slots: { 69 + default: 70 + '<option value="option1">option 1</option><option value="option2">option 2</option>', 71 + }, 72 + }) 73 + await component.setProps({ modelValue: 'option2' }) 74 + const select = component.find('select').element 75 + expect(select.value).toBe('option2') 76 + }) 77 + }) 78 + })
+124
test/nuxt/components/Select/Field.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import SelectField from '~/components/Select/Field.vue' 4 + 5 + const defaultItems = [ 6 + { label: 'Option 1', value: 'option1' }, 7 + { label: 'Option 2', value: 'option2' }, 8 + ] 9 + 10 + describe('SelectField', () => { 11 + describe('rendering', () => { 12 + it('renders options from items prop', async () => { 13 + const component = await mountSuspended(SelectField, { 14 + props: { id: 'select', items: defaultItems }, 15 + }) 16 + const options = component.findAll('option') 17 + expect(options).toHaveLength(2) 18 + expect(options[0]?.text()).toBe('Option 1') 19 + expect(options[1]?.text()).toBe('Option 2') 20 + expect(options[0]?.attributes('value')).toBe('option1') 21 + expect(options[1]?.attributes('value')).toBe('option2') 22 + }) 23 + 24 + it('renders label when provided', async () => { 25 + const component = await mountSuspended(SelectField, { 26 + props: { id: 'select', items: defaultItems, label: 'Choose one' }, 27 + }) 28 + const label = component.find('label') 29 + expect(label.exists()).toBe(true) 30 + expect(label.text()).toBe('Choose one') 31 + expect(label.attributes('for')).toBe('select') 32 + }) 33 + 34 + it('renders disabled option when item.disabled is true', async () => { 35 + const component = await mountSuspended(SelectField, { 36 + props: { 37 + id: 'select', 38 + items: [ 39 + { label: 'Enabled', value: 'on' }, 40 + { label: 'Disabled', value: 'off', disabled: true }, 41 + ], 42 + }, 43 + }) 44 + const options = component.findAll('option') 45 + expect(options[1]?.element?.disabled).toBe(true) 46 + }) 47 + 48 + it('applies block class when block is true', async () => { 49 + const component = await mountSuspended(SelectField, { 50 + props: { id: 'select', items: defaultItems, block: true }, 51 + }) 52 + const wrapper = component.find('.relative') 53 + expect(wrapper.classes()).toContain('w-full') 54 + const select = component.find('select') 55 + expect(select.classes()).toContain('w-full') 56 + }) 57 + }) 58 + 59 + describe('v-model', () => { 60 + it('emits update:modelValue when option is selected', async () => { 61 + const component = await mountSuspended(SelectField, { 62 + props: { id: 'select', items: defaultItems, modelValue: 'option1' }, 63 + }) 64 + const select = component.find('select') 65 + await select.setValue('option2') 66 + expect(component.emitted('update:modelValue')).toBeTruthy() 67 + expect(component.emitted('update:modelValue')?.at(-1)).toEqual(['option2']) 68 + }) 69 + 70 + it('reflects modelValue prop changes', async () => { 71 + const component = await mountSuspended(SelectField, { 72 + props: { id: 'select', items: defaultItems, modelValue: 'option1' }, 73 + }) 74 + await component.setProps({ modelValue: 'option2' }) 75 + const select = component.find('select').element 76 + expect(select.value).toBe('option2') 77 + }) 78 + }) 79 + 80 + describe('disabled', () => { 81 + it('passes disabled to SelectBase', async () => { 82 + const component = await mountSuspended(SelectField, { 83 + props: { id: 'select', items: defaultItems, disabled: true }, 84 + }) 85 + const select = component.find('select').element 86 + expect(select.disabled).toBe(true) 87 + }) 88 + }) 89 + 90 + describe('accessibility', () => { 91 + it('chevron has aria-hidden', async () => { 92 + const component = await mountSuspended(SelectField, { 93 + props: { id: 'select', items: defaultItems }, 94 + }) 95 + const chevron = component.find('span[aria-hidden="true"]') 96 + expect(chevron.exists()).toBe(true) 97 + }) 98 + 99 + it('render sr-only label when hiddenLabel is true', async () => { 100 + const component = await mountSuspended(SelectField, { 101 + props: { 102 + id: 'select', 103 + items: defaultItems, 104 + label: 'Hidden', 105 + hiddenLabel: true, 106 + }, 107 + }) 108 + const label = component.find('label') 109 + expect(label.exists()).toBe(true) 110 + expect(label.classes()).toContain('sr-only') 111 + expect(label.text()).toBe('Hidden') 112 + }) 113 + 114 + it('associates select with id', async () => { 115 + const component = await mountSuspended(SelectField, { 116 + props: { id: 'my-select', items: defaultItems, label: 'My Select' }, 117 + }) 118 + const select = component.find('select') 119 + expect(select.attributes('id')).toBe('my-select') 120 + const label = component.find('label') 121 + expect(label.attributes('for')).toBe('my-select') 122 + }) 123 + }) 124 + })