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

feat: extract button and link component, unify and improve design (#1071)

Co-authored-by: Robin <robin.kehl@singular-it.de>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Nathan Knowler <nathan@knowler.dev>
Co-authored-by: Daniel Roe <daniel@roe.dev>

+667 -802
+3 -11
app/app.vue
··· 121 121 <template> 122 122 <div class="min-h-screen flex flex-col bg-bg text-fg"> 123 123 <NuxtPwaAssets /> 124 - <a href="#main-content" class="skip-link font-mono">{{ $t('common.skip_link') }}</a> 124 + <LinkBase to="#main-content" variant="button-primary" class="skip-link">{{ 125 + $t('common.skip_link') 126 + }}</LinkBase> 125 127 126 128 <AppHeader :show-logo="!isHomepage" /> 127 129 ··· 140 142 .skip-link { 141 143 position: fixed; 142 144 top: -100%; 143 - inset-inline-start: 0; 144 - padding: 0.5rem 1rem; 145 - background: var(--fg); 146 - color: var(--bg); 147 - font-size: 0.875rem; 148 145 z-index: 100; 149 - transition: top 0.2s ease; 150 146 } 151 147 152 - .skip-link:hover { 153 - color: var(--bg); 154 - text-decoration: underline; 155 - } 156 148 .skip-link:focus { 157 149 top: 0; 158 150 }
+9 -32
app/assets/main.css
··· 159 159 -webkit-font-smoothing: antialiased; 160 160 -moz-osx-font-smoothing: grayscale; 161 161 text-rendering: optimizeLegibility; 162 - scroll-padding-top: 5rem; /* Offset for fixed header - otherwise anchor headers are cutted */ 162 + /* Offset for fixed header - otherwise anchor headers are cutted */ 163 + scroll-padding-top: 5rem; 163 164 scrollbar-gutter: stable; 164 165 } 165 166 ··· 185 186 line-height: 1.6; 186 187 } 187 188 188 - /* Default link styling for accessibility on dark background */ 189 - a { 190 - color: var(--fg); 191 - text-decoration: underline; 192 - text-underline-offset: 3px; 193 - text-decoration-color: var(--fg-subtle); 194 - transition: 195 - color 0.2s ease, 196 - text-decoration-color 0.2s ease; 197 - } 198 - 199 - a:hover { 200 - color: var(--accent); 201 - text-decoration-color: var(--accent); 202 - } 203 - 204 - a:focus-visible { 205 - outline: 2px solid var(--accent); 206 - outline-offset: 2px; 207 - border-radius: 4px; 189 + :focus-visible, 190 + :-moz-focusring { 191 + /* weird Firefox behavior makes it necessary to add `!important` 192 + or otherwise the selector would need to be more specific, 193 + which it explicitly should not be. */ 194 + outline: 2px solid var(--accent) !important; 195 + outline-offset: 2px !important; 208 196 } 209 197 210 198 /* Reset dd margin (browser default is margin-left: 40px) */ ··· 214 202 215 203 /* Reset button styles */ 216 204 button { 217 - background: transparent; 218 - border: none; 219 205 cursor: pointer; 220 - font: inherit; 221 - padding: 0; 222 - } 223 - 224 - button:focus-visible, 225 - select:focus-visible { 226 - outline: 2px solid var(--accent); 227 - outline-offset: 2px; 228 - border-radius: 4px; 229 206 } 230 207 231 208 /* Selection */
+13 -40
app/components/AppFooter.vue
··· 14 14 <BuildEnvironment v-if="!isHome" footer /> 15 15 </div> 16 16 <!-- Desktop: Show all links. Mobile: Links are in MobileMenu --> 17 - <div class="hidden sm:flex items-center gap-6 min-h-11"> 18 - <NuxtLink :to="{ name: 'about' }" class="link-subtle font-mono text-xs flex items-center"> 17 + <div class="hidden sm:flex items-center gap-6 min-h-11 text-xs"> 18 + <LinkBase :to="{ name: 'about' }"> 19 19 {{ $t('footer.about') }} 20 - </NuxtLink> 21 - <NuxtLink 22 - :to="{ name: 'privacy' }" 23 - class="link-subtle font-mono text-xs flex items-center gap-1" 24 - > 20 + </LinkBase> 21 + <LinkBase :to="{ name: 'privacy' }"> 25 22 {{ $t('privacy_policy.title') }} 26 - </NuxtLink> 27 - <a 28 - href="https://docs.npmx.dev" 29 - target="_blank" 30 - rel="noopener noreferrer" 31 - class="link-subtle font-mono text-xs flex items-center gap-1" 32 - > 23 + </LinkBase> 24 + <LinkBase to="https://docs.npmx.dev"> 33 25 {{ $t('footer.docs') }} 34 - <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> 35 - </a> 36 - <a 37 - href="https://repo.npmx.dev" 38 - target="_blank" 39 - rel="noopener noreferrer" 40 - class="link-subtle font-mono text-xs flex items-center gap-1" 41 - > 26 + </LinkBase> 27 + <LinkBase to="https://repo.npmx.dev"> 42 28 {{ $t('footer.source') }} 43 - <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> 44 - </a> 45 - <a 46 - href="https://social.npmx.dev" 47 - target="_blank" 48 - rel="noopener noreferrer" 49 - class="link-subtle font-mono text-xs flex items-center gap-1" 50 - > 29 + </LinkBase> 30 + <LinkBase to="https://social.npmx.dev"> 51 31 {{ $t('footer.social') }} 52 - <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> 53 - </a> 54 - <a 55 - href="https://chat.npmx.dev" 56 - target="_blank" 57 - rel="noopener noreferrer" 58 - class="link-subtle font-mono text-xs flex items-center gap-1" 59 - > 32 + </LinkBase> 33 + <LinkBase to="https://chat.npmx.dev"> 60 34 {{ $t('footer.chat') }} 61 - <span class="i-carbon:launch rtl-flip w-3 h-3" aria-hidden="true" /> 62 - </a> 35 + </LinkBase> 63 36 </div> 64 37 </div> 65 38 <p class="text-xs text-fg-muted text-center sm:text-start m-0">
+23 -35
app/components/AppHeader.vue
··· 1 1 <script setup lang="ts"> 2 + import { LinkBase } from '#components' 2 3 import { isEditableElement } from '~/utils/input' 3 4 4 5 withDefaults( ··· 150 151 </div> 151 152 152 153 <!-- End: Desktop nav items + Mobile menu button --> 153 - <div class="flex-shrink-0 flex items-center gap-0.5 sm:gap-2"> 154 + <div class="hidden sm:flex flex-shrink-0"> 154 155 <!-- Desktop: Compare link --> 155 - <NuxtLink 156 + <LinkBase 157 + class="border-none" 158 + variant="button-secondary" 156 159 :to="{ name: 'compare' }" 157 - class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 px-2 py-1.5 hover:bg-bg-subtle focus-visible:outline-accent/70 rounded" 158 - aria-keyshortcuts="c" 160 + keyshortcut="c" 159 161 > 160 162 {{ $t('nav.compare') }} 161 - <kbd 162 - class="inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded" 163 - aria-hidden="true" 164 - > 165 - c 166 - </kbd> 167 - </NuxtLink> 163 + </LinkBase> 168 164 169 165 <!-- Desktop: Settings link --> 170 - <NuxtLink 166 + <LinkBase 167 + class="border-none" 168 + variant="button-secondary" 171 169 :to="{ name: 'settings' }" 172 - class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-2 px-2 py-1.5 hover:bg-bg-subtle focus-visible:outline-accent/70 rounded" 173 - aria-keyshortcuts="," 170 + keyshortcut="," 174 171 > 175 172 {{ $t('nav.settings') }} 176 - <kbd 177 - class="inline-flex items-center justify-center w-5 h-5 text-xs bg-bg-muted border border-border rounded" 178 - aria-hidden="true" 179 - > 180 - , 181 - </kbd> 182 - </NuxtLink> 173 + </LinkBase> 183 174 184 - <!-- Desktop: Account menu --> 185 - <div class="hidden sm:block"> 186 - <HeaderAccountMenu /> 187 - </div> 188 - 189 - <!-- Mobile: Menu button (always visible, click to open menu) --> 190 - <button 191 - type="button" 192 - class="sm:hidden flex items-center p-2 -m-2 text-fg-subtle hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded" 193 - :aria-label="$t('nav.open_menu')" 194 - @click="showMobileMenu = true" 195 - > 196 - <span class="w-6 h-6 inline-block i-carbon:menu" aria-hidden="true" /> 197 - </button> 175 + <HeaderAccountMenu /> 198 176 </div> 177 + 178 + <!-- Mobile: Menu button (always visible, click to open menu) --> 179 + <ButtonBase 180 + type="button" 181 + class="sm:hidden flex" 182 + :aria-label="$t('nav.open_menu')" 183 + :aria-expanded="showMobileMenu" 184 + @click="showMobileMenu = !showMobileMenu" 185 + classicon="i-carbon:menu" 186 + /> 199 187 </nav> 200 188 201 189 <!-- Mobile menu -->
+5 -13
app/components/BuildEnvironment.vue
··· 17 17 <NuxtTime :datetime="buildInfo.time" :locale="locale" relative /> 18 18 </i18n-t> 19 19 <span>&middot;</span> 20 - <NuxtLink 20 + <LinkBase 21 21 v-if="buildInfo.env === 'release'" 22 - external 23 - :href="`https://github.com/npmx-dev/npmx.dev/tag/v${buildInfo.version}`" 24 - target="_blank" 25 - class="hover:text-fg transition-colors" 22 + :to="`https://github.com/npmx-dev/npmx.dev/tag/v${buildInfo.version}`" 26 23 > 27 24 v{{ buildInfo.version }} 28 - </NuxtLink> 25 + </LinkBase> 29 26 <span v-else class="tracking-wider">{{ buildInfo.env }}</span> 30 27 31 28 <template v-if="buildInfo.commit && buildInfo.branch !== 'release'"> 32 29 <span>&middot;</span> 33 - <NuxtLink 34 - external 35 - :href="`https://github.com/npmx-dev/npmx.dev/commit/${buildInfo.commit}`" 36 - target="_blank" 37 - class="hover:text-fg transition-colors" 38 - > 30 + <LinkBase :to="`https://github.com/npmx-dev/npmx.dev/commit/${buildInfo.commit}`"> 39 31 {{ buildInfo.shortCommit }} 40 - </NuxtLink> 32 + </LinkBase> 41 33 </template> 42 34 </div> 43 35 </template>
+69
app/components/Button/Base.vue
··· 1 + <script setup lang="ts"> 2 + const props = withDefaults( 3 + defineProps<{ 4 + 'disabled'?: boolean 5 + 'type'?: 'button' | 'submit' 6 + 'variant'?: 'primary' | 'secondary' 7 + 'size'?: 'small' | 'medium' 8 + 'keyshortcut'?: string 9 + 10 + /** 11 + * Do not use this directly. Use keyshortcut instead; it generates the correct HTML and displays the shortcut in the UI. 12 + */ 13 + 'aria-keyshortcuts'?: never 14 + 15 + 'classicon'?: string 16 + }>(), 17 + { 18 + type: 'button', 19 + variant: 'secondary', 20 + size: 'medium', 21 + }, 22 + ) 23 + 24 + const el = useTemplateRef('el') 25 + 26 + defineExpose({ 27 + focus: () => el.value?.focus(), 28 + }) 29 + </script> 30 + 31 + <template> 32 + <button 33 + ref="el" 34 + class="group cursor-pointer inline-flex gap-x-1 items-center justify-center font-mono border border-border rounded-md transition-all duration-200 disabled:(opacity-40 cursor-not-allowed border-transparent)" 35 + :class="{ 36 + 'text-sm px-4 py-2': size === 'medium', 37 + 'text-xs px-2 py-0.5': size === 'small', 38 + 'bg-transparent text-fg hover:enabled:(bg-fg/10) focus-visible:enabled:(bg-fg/10) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(bg-fg text-bg/50))': 39 + variant === 'secondary', 40 + 'text-bg bg-fg hover:enabled:(bg-fg/50) focus-visible:enabled:(bg-fg/50) aria-pressed:(bg-fg text-bg border-fg hover:enabled:(text-bg/50))': 41 + variant === 'primary', 42 + }" 43 + :type="props.type" 44 + :disabled=" 45 + /** 46 + * Unfortunately Vue _sometimes_ doesn't handle `disabled` correct, 47 + * resulting in an invalid `disabled=false` attribute in the final HTML. 48 + * 49 + * This fixes this. 50 + */ 51 + disabled ? true : undefined 52 + " 53 + :aria-keyshortcuts="keyshortcut" 54 + > 55 + <span 56 + v-if="classicon" 57 + :class="[size === 'small' ? 'size-3' : 'size-4', classicon]" 58 + aria-hidden="true" 59 + /> 60 + <slot /> 61 + <kbd 62 + v-if="keyshortcut" 63 + class="ms-2 inline-flex items-center justify-center w-4 h-4 text-xs text-fg bg-bg-muted border border-border rounded no-underline" 64 + aria-hidden="true" 65 + > 66 + {{ keyshortcut }} 67 + </kbd> 68 + </button> 69 + </template>
+14
app/components/Button/Group.vue
··· 1 + <script setup lang="ts"> 2 + const props = defineProps<{ 3 + as?: string | Component 4 + }>() 5 + </script> 6 + 7 + <template> 8 + <component 9 + :is="props.as || 'div'" 10 + class="flex items-center shrink-0 ms-auto [&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none" 11 + > 12 + <slot /> 13 + </component> 14 + </template>
+6 -12
app/components/ColumnPicker.vue
··· 69 69 70 70 <template> 71 71 <div class="relative"> 72 - <button 72 + <ButtonBase 73 73 ref="buttonRef" 74 - type="button" 75 - class="btn-ghost inline-flex items-center gap-1.5 px-3 py-1.5 border border-border rounded-md hover:border-border-hover focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 76 74 :aria-expanded="isOpen" 77 75 aria-haspopup="true" 78 76 :aria-controls="menuId" 79 77 @click.stop="isOpen = !isOpen" 78 + classicon="i-carbon-column" 80 79 > 81 - <span class="i-carbon-column w-4 h-4" aria-hidden="true" /> 82 - <span class="font-mono text-sm">{{ $t('filters.columns.title') }}</span> 83 - </button> 80 + {{ $t('filters.columns.title') }} 81 + </ButtonBase> 84 82 85 83 <Transition name="dropdown"> 86 84 <div ··· 136 134 </div> 137 135 138 136 <div class="border-t border-border py-1"> 139 - <button 140 - type="button" 141 - class="w-full px-3 py-2 text-start text-sm font-mono text-fg-muted hover:bg-bg-muted hover:text-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-inset" 142 - @click="handleReset" 143 - > 137 + <ButtonBase @click="handleReset"> 144 138 {{ $t('filters.columns.reset') }} 145 - </button> 139 + </ButtonBase> 146 140 </div> 147 141 </div> 148 142 </div>
+4 -3
app/components/Filter/Panel.vue
··· 307 307 {{ $t('filters.keywords') }} 308 308 </legend> 309 309 <div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')"> 310 - <TagButton 310 + <ButtonBase 311 311 v-for="keyword in displayedKeywords" 312 312 :key="keyword" 313 - :pressed="filters.keywords.includes(keyword)" 313 + size="small" 314 + :aria-pressed="filters.keywords.includes(keyword)" 314 315 @click="emit('toggleKeyword', keyword)" 315 316 > 316 317 {{ keyword }} 317 - </TagButton> 318 + </ButtonBase> 318 319 <button 319 320 v-if="hasMoreKeywords" 320 321 type="button"
+3 -3
app/components/Header/AccountMenu.client.vue
··· 57 57 58 58 <template> 59 59 <div ref="accountMenuRef" class="relative flex min-w-24 justify-end"> 60 - <button 60 + <ButtonBase 61 61 type="button" 62 - class="relative flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors duration-200 hover:bg-bg-subtle hover:text-accent focus-visible:outline-accent/70" 63 62 :aria-expanded="isOpen" 64 63 aria-haspopup="true" 65 64 @click="isOpen = !isOpen" 65 + class="border-none" 66 66 > 67 67 <!-- Stacked avatars when connected --> 68 68 <div ··· 126 126 > 127 127 {{ operationCount }} 128 128 </span> 129 - </button> 129 + </ButtonBase> 130 130 131 131 <!-- Dropdown menu --> 132 132 <Transition
+8 -15
app/components/Header/AuthModal.client.vue
··· 101 101 </details> 102 102 </div> 103 103 104 - <button 105 - type="submit" 106 - :disabled="!handleInput.trim()" 107 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 108 - > 104 + <ButtonBase type="submit" variant="primary" :disabled="!handleInput.trim()" class="w-full"> 109 105 {{ $t('auth.modal.connect') }} 110 - </button> 111 - <button 112 - type="button" 113 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 114 - @click="handleCreateAccount" 115 - > 106 + </ButtonBase> 107 + <ButtonBase type="button" variant="primary" class="w-full" @click="handleCreateAccount"> 116 108 {{ $t('auth.modal.create_account') }} 117 - </button> 109 + </ButtonBase> 118 110 <hr class="color-border" /> 119 - <button 111 + <ButtonBase 120 112 type="button" 121 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg flex items-center justify-center gap-2" 113 + variant="primary" 114 + class="w-full flex items-center justify-center gap-2" 122 115 @click="handleBlueskySignIn" 123 116 > 124 117 {{ $t('auth.modal.connect_bluesky') }} ··· 128 121 d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z" 129 122 ></path> 130 123 </svg> 131 - </button> 124 + </ButtonBase> 132 125 </form> 133 126 </Modal> 134 127 </template>
+4 -3
app/components/Header/ConnectorModal.vue
··· 220 220 </p> 221 221 </div> 222 222 223 - <button 223 + <ButtonBase 224 224 type="submit" 225 + variant="primary" 225 226 :disabled="!tokenInput.trim() || isConnecting" 226 - class="w-full px-4 py-2 font-mono text-sm text-bg bg-fg rounded-md transition-all duration-200 hover:bg-fg/90 disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-accent/70 focus-visible:ring-offset-2 focus-visible:ring-offset-bg" 227 + class="w-full" 227 228 > 228 229 {{ isConnecting ? $t('connector.modal.connecting') : $t('connector.modal.connect') }} 229 - </button> 230 + </ButtonBase> 230 231 </form> 231 232 </Modal> 232 233 </template>
+117
app/components/Link/Base.vue
··· 1 + <script setup lang="ts"> 2 + import type { NuxtLinkProps } from '#app' 3 + 4 + const props = withDefaults( 5 + defineProps< 6 + { 7 + /** Disabled links will be displayed as plain text */ 8 + 'disabled'?: boolean 9 + /** 10 + * `type` should never be used, because this will always be a link. 11 + * */ 12 + 'type'?: never 13 + 'variant'?: 'button-primary' | 'button-secondary' | 'link' 14 + 'size'?: 'small' | 'medium' 15 + 16 + 'keyshortcut'?: string 17 + 18 + /** 19 + * Do not use this directly. Use keyshortcut instead; it generates the correct HTML and displays the shortcut in the UI. 20 + */ 21 + 'aria-keyshortcuts'?: never 22 + 23 + /** 24 + * Don't use this directly. This will automatically be set to `_blank` for external links passed via `to`. 25 + */ 26 + 'target'?: never 27 + 28 + /** 29 + * Don't use this directly. This will automatically be set for external links passed via `to`. 30 + */ 31 + 'rel'?: never 32 + 33 + 'classicon'?: string 34 + 35 + 'to'?: NuxtLinkProps['to'] 36 + 37 + /** always use `to` instead of `href` */ 38 + 'href'?: never 39 + } & NuxtLinkProps 40 + >(), 41 + { variant: 'link', size: 'medium' }, 42 + ) 43 + 44 + const isLinkExternal = computed( 45 + () => 46 + !!props.to && 47 + typeof props.to === 'string' && 48 + (props.to.startsWith('http:') || props.to.startsWith('https:') || props.to.startsWith('//')), 49 + ) 50 + const isLinkAnchor = computed( 51 + () => !!props.to && typeof props.to === 'string' && props.to.startsWith('#'), 52 + ) 53 + 54 + /** size is only applicable for button like links */ 55 + const isLink = computed(() => props.variant === 'link') 56 + const isButton = computed(() => props.variant !== 'link') 57 + const isButtonSmall = computed(() => props.size === 'small' && props.variant !== 'link') 58 + const isButtonMedium = computed(() => props.size === 'medium' && props.variant !== 'link') 59 + </script> 60 + 61 + <template> 62 + <span 63 + v-if="disabled" 64 + :class="{ 65 + 'opacity-50 inline-flex gap-x-1 items-center justify-center font-mono border border-transparent rounded-md': 66 + isButton, 67 + 'text-sm px-4 py-2': isButtonMedium, 68 + 'text-xs px-2 py-0.5': isButtonSmall, 69 + 'text-bg bg-fg': variant === 'button-primary', 70 + 'bg-transparent text-fg': variant === 'button-secondary', 71 + }" 72 + ><slot 73 + /></span> 74 + <NuxtLink 75 + v-else 76 + class="group inline-flex gap-x-1 items-center justify-center" 77 + :class="{ 78 + 'underline-offset-[0.2rem] underline decoration-1 decoration-fg/30': !isLinkAnchor && isLink, 79 + 'font-mono text-fg hover:(decoration-accent text-accent) focus-visible:(decoration-accent text-accent) transition-colors duration-200': 80 + isLink, 81 + 'font-mono border border-border rounded-md transition-all duration-200': isButton, 82 + 'text-sm px-4 py-2': isButtonMedium, 83 + 'text-xs px-2 py-0.5': isButtonSmall, 84 + 'bg-transparent text-fg hover:(bg-fg/10) focus-visible:(bg-fg/10)': 85 + variant === 'button-secondary', 86 + 'text-bg bg-fg hover:(bg-fg/50) focus-visible:(bg-fg/50)': variant === 'button-primary', 87 + }" 88 + :to="to" 89 + :aria-keyshortcuts="keyshortcut" 90 + :target="isLinkExternal ? '_blank' : undefined" 91 + > 92 + <span 93 + v-if="classicon" 94 + :class="[isButtonSmall ? 'size-3' : 'size-4', classicon]" 95 + aria-hidden="true" 96 + /> 97 + <slot /> 98 + <!-- automatically show icon indicating external link --> 99 + <span 100 + v-if="isLinkExternal && !classicon" 101 + class="i-carbon:launch rtl-flip w-3 h-3 opacity-50" 102 + aria-hidden="true" 103 + /> 104 + <span 105 + v-else-if="isLinkAnchor && isLink" 106 + class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200" 107 + aria-hidden="true" 108 + /> 109 + <kbd 110 + v-if="keyshortcut" 111 + class="ms-2 inline-flex items-center justify-center w-4 h-4 text-xs text-fg bg-bg-muted border border-border rounded no-underline" 112 + aria-hidden="true" 113 + > 114 + {{ keyshortcut }} 115 + </kbd> 116 + </NuxtLink> 117 + </template>
+3 -5
app/components/Modal.client.vue
··· 53 53 <h2 :id="modalTitleId" class="font-mono text-lg font-medium"> 54 54 {{ modalTitle }} 55 55 </h2> 56 - <button 56 + <ButtonBase 57 57 type="button" 58 - class="text-fg-subtle w-8 h-8 p-1.5 -m-1.5 hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded" 59 58 :aria-label="$t('common.close')" 60 59 @click="handleModalClose" 61 - > 62 - <span class="i-carbon-close w-5 h-5" aria-hidden="true" /> 63 - </button> 60 + classicon="i-carbon-close" 61 + /> 64 62 </div> 65 63 <!-- Modal body content --> 66 64 <slot />
+4 -3
app/components/Package/Card.vue
··· 163 163 :aria-label="$t('package.card.keywords')" 164 164 class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none items-center" 165 165 > 166 - <TagButton 166 + <ButtonBase 167 167 v-for="keyword in result.package.keywords.slice(0, 5)" 168 168 class="pointer-events-auto" 169 + size="small" 169 170 :key="keyword" 170 - :pressed="props.filters?.keywords.includes(keyword)" 171 + :aria-pressed="props.filters?.keywords.includes(keyword)" 171 172 :title="`Filter by ${keyword}`" 172 173 :data-result-index="index" 173 174 @click.stop="emit('clickKeyword', keyword)" 174 175 > 175 176 {{ keyword }} 176 - </TagButton> 177 + </ButtonBase> 177 178 <span 178 179 v-if="result.package.keywords.length > 5" 179 180 class="text-fg-subtle text-xs pointer-events-auto"
+23 -44
app/components/Package/Dependencies.vue
··· 81 81 :key="dep" 82 82 class="flex items-center justify-between py-1 text-sm gap-2" 83 83 > 84 - <NuxtLink 85 - :to="packageRoute(dep)" 86 - class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0 flex-1" 87 - dir="ltr" 88 - > 84 + <LinkBase :to="packageRoute(dep)" dir="ltr"> 89 85 {{ dep }} 90 - </NuxtLink> 86 + </LinkBase> 91 87 <span class="flex items-center gap-1 max-w-[40%]" dir="ltr"> 92 88 <span 93 89 v-if="outdatedDeps[dep]" ··· 98 94 > 99 95 <span class="i-carbon:warning-alt w-3 h-3" /> 100 96 </span> 101 - <NuxtLink 97 + <LinkBase 102 98 v-if="getVulnerableDepInfo(dep)" 103 99 :to="packageRoute(dep, getVulnerableDepInfo(dep)!.version)" 104 100 class="shrink-0" 105 101 :class="SEVERITY_TEXT_COLORS[getHighestSeverity(getVulnerableDepInfo(dep)!.counts)]" 106 102 :title="`${getVulnerableDepInfo(dep)!.counts.total} vulnerabilities`" 103 + classicon="i-carbon:security" 107 104 > 108 - <span class="i-carbon:security w-3 h-3" aria-hidden="true" /> 109 105 <span class="sr-only">{{ $t('package.dependencies.view_vulnerabilities') }}</span> 110 - </NuxtLink> 111 - <NuxtLink 106 + </LinkBase> 107 + <LinkBase 112 108 v-if="getDeprecatedDepInfo(dep)" 113 109 :to="packageRoute(dep, getDeprecatedDepInfo(dep)!.version)" 114 110 class="shrink-0 text-purple-500" 115 111 :title="getDeprecatedDepInfo(dep)!.message" 112 + classicon="i-carbon:warning-hex" 116 113 > 117 - <span class="i-carbon-warning-hex w-3 h-3" aria-hidden="true" /> 118 114 <span class="sr-only">{{ $t('package.deprecated.label') }}</span> 119 - </NuxtLink> 120 - <NuxtLink 115 + </LinkBase> 116 + <LinkBase 121 117 :to="packageRoute(dep, version)" 122 - class="font-mono text-xs text-end truncate" 118 + class="truncate" 123 119 :class="getVersionClass(outdatedDeps[dep])" 124 120 :title="outdatedDeps[dep] ? getOutdatedTooltip(outdatedDeps[dep], $t) : version" 125 121 > 126 122 {{ version }} 127 - </NuxtLink> 123 + </LinkBase> 128 124 <span v-if="outdatedDeps[dep]" class="sr-only"> 129 125 ({{ getOutdatedTooltip(outdatedDeps[dep], $t) }}) 130 126 </span> ··· 165 161 class="flex items-center justify-between py-1 text-sm gap-1 min-w-0" 166 162 > 167 163 <div class="flex items-center gap-1 min-w-0 flex-1"> 168 - <NuxtLink 169 - :to="packageRoute(peer.name)" 170 - class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate" 171 - dir="ltr" 172 - > 164 + <LinkBase :to="packageRoute(peer.name)" class="truncate" dir="ltr"> 173 165 {{ peer.name }} 174 - </NuxtLink> 175 - <span 176 - v-if="peer.optional" 177 - class="px-1 py-0.5 font-mono text-[10px] text-fg-subtle bg-bg-muted border border-border rounded shrink-0" 178 - :title="$t('package.dependencies.optional')" 179 - > 166 + </LinkBase> 167 + <TagStatic v-if="peer.optional" :title="$t('package.dependencies.optional')"> 180 168 {{ $t('package.dependencies.optional') }} 181 - </span> 169 + </TagStatic> 182 170 </div> 183 - <NuxtLink 171 + <LinkBase 184 172 :to="packageRoute(peer.name, peer.version)" 185 - class="font-mono text-xs text-fg-subtle max-w-[40%] truncate" 173 + class="truncate" 186 174 :title="peer.version" 187 175 dir="ltr" 188 176 > 189 177 {{ peer.version }} 190 - </NuxtLink> 178 + </LinkBase> 191 179 </li> 192 180 </ul> 193 181 <button ··· 226 214 :key="dep" 227 215 class="flex items-center justify-between py-1 text-sm gap-2" 228 216 > 229 - <NuxtLink 230 - :to="packageRoute(dep)" 231 - class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0 flex-1" 232 - dir="ltr" 233 - > 217 + <LinkBase :to="packageRoute(dep)" class="truncate" dir="ltr"> 234 218 {{ dep }} 235 - </NuxtLink> 236 - <NuxtLink 237 - :to="packageRoute(dep, version)" 238 - class="font-mono text-xs text-fg-subtle max-w-[40%] text-end truncate" 239 - :title="version" 240 - dir="ltr" 241 - > 219 + </LinkBase> 220 + <LinkBase :to="packageRoute(dep, version)" class="truncate" :title="version" dir="ltr"> 242 221 {{ version }} 243 - </NuxtLink> 222 + </LinkBase> 244 223 </li> 245 224 </ul> 246 225 <button 247 226 v-if="sortedOptionalDependencies.length > 10 && !optionalDepsExpanded" 248 227 type="button" 249 - class="mt-2 font-mono text-xs text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70" 228 + class="mt-2 truncate" 250 229 @click="optionalDepsExpanded = true" 251 230 > 252 231 {{
+2 -2
app/components/Package/InstallScripts.vue
··· 72 72 :key="dep" 73 73 class="flex items-center justify-between py-0.5 text-sm gap-2" 74 74 > 75 - <NuxtLink 75 + <LinkBase 76 76 :to="packageRoute(dep)" 77 77 class="font-mono text-fg-muted hover:text-fg transition-colors duration-200 truncate min-w-0" 78 78 > 79 79 {{ dep }} 80 - </NuxtLink> 80 + </LinkBase> 81 81 <span class="flex items-center gap-1"> 82 82 <span 83 83 v-if="
+6 -2
app/components/Package/Keywords.vue
··· 7 7 <CollapsibleSection v-if="keywords?.length" :title="$t('package.keywords_title')" id="keywords"> 8 8 <ul class="flex flex-wrap gap-1.5 list-none m-0 p-1"> 9 9 <li v-for="keyword in keywords.slice(0, 15)" :key="keyword"> 10 - <TagLink :to="{ name: 'search', query: { q: `keywords:${keyword}` } }"> 10 + <LinkBase 11 + variant="button-secondary" 12 + size="small" 13 + :to="{ name: 'search', query: { q: `keywords:${keyword}` } }" 14 + > 11 15 {{ keyword }} 12 - </TagLink> 16 + </LinkBase> 13 17 </li> 14 18 </ul> 15 19 </CollapsibleSection>
+14 -28
app/components/Package/Maintainers.vue
··· 183 183 class="flex items-center justify-between gap-2" 184 184 > 185 185 <div class="flex items-center gap-2 min-w-0"> 186 - <NuxtLink 186 + <LinkBase 187 187 v-if="maintainer.name" 188 188 :to="{ 189 189 name: '~username', ··· 193 193 dir="ltr" 194 194 > 195 195 ~{{ maintainer.name }} 196 - </NuxtLink> 196 + </LinkBase> 197 197 <span v-else class="font-mono text-sm text-fg-muted" dir="ltr">{{ 198 198 maintainer.email 199 199 }}</span> ··· 217 217 </div> 218 218 219 219 <!-- Remove button (only when can manage and not self) --> 220 - <button 220 + <ButtonBase 221 221 v-if="canManageOwners && maintainer.name && maintainer.name !== npmUser" 222 222 type="button" 223 - class="p-1 text-fg-subtle hover:text-red-400 transition-colors duration-200 shrink-0 rounded focus-visible:outline-accent/70" 223 + class="hover:text-red-400" 224 224 :aria-label=" 225 225 $t('package.maintainers.remove_owner', { 226 226 name: maintainer.name, ··· 229 229 @click="handleRemoveOwner(maintainer.name)" 230 230 > 231 231 <span class="i-carbon-close w-3.5 h-3.5" aria-hidden="true" /> 232 - </button> 232 + </ButtonBase> 233 233 </li> 234 234 </ul> 235 235 236 236 <!-- Show more/less toggle (only when not managing and there are hidden maintainers) --> 237 - <button 237 + <ButtonBase 238 238 v-if="!canManageOwners && hiddenMaintainersCount > 0" 239 - type="button" 240 - class="mt-2 text-xs text-fg-muted hover:text-fg transition-colors duration-200 focus-visible:outline-accent/70 rounded" 241 239 @click="showAllMaintainers = !showAllMaintainers" 242 240 > 243 241 {{ ··· 247 245 count: hiddenMaintainersCount, 248 246 }) 249 247 }} 250 - </button> 248 + </ButtonBase> 251 249 252 250 <!-- Add owner form (only when can manage) --> 253 251 <div v-if="canManageOwners" class="mt-3"> ··· 265 263 v-bind="noCorrect" 266 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" 267 265 /> 268 - <button 269 - type="submit" 270 - :disabled="!newOwnerUsername.trim() || isAdding" 271 - class="px-2 py-1 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" 272 - > 266 + <ButtonBase type="submit" :disabled="!newOwnerUsername.trim() || isAdding"> 273 267 {{ isAdding ? '…' : $t('package.maintainers.add_button') }} 274 - </button> 275 - <button 276 - type="button" 277 - class="p-1 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70" 268 + </ButtonBase> 269 + <ButtonBase 278 270 :aria-label="$t('package.maintainers.cancel_add')" 279 271 @click="showAddOwner = false" 280 - > 281 - <span class="i-carbon-close w-4 h-4" aria-hidden="true" /> 282 - </button> 272 + classicon="i-carbon-close" 273 + /> 283 274 </form> 284 275 </div> 285 - <button 286 - v-else 287 - type="button" 288 - class="w-full px-3 py-1.5 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded transition-colors duration-200 hover:text-fg hover:border-border-hover focus-visible:outline-accent/70" 289 - @click="showAddOwner = true" 290 - > 276 + <ButtonBase v-else type="button" @click="showAddOwner = true"> 291 277 {{ $t('package.maintainers.add_owner') }} 292 - </button> 278 + </ButtonBase> 293 279 </div> 294 280 </CollapsibleSection> 295 281 </template>
+31 -56
app/components/Package/MetricsBadges.vue
··· 1 1 <script setup lang="ts"> 2 - import { NuxtLink } from '#components' 2 + import { LinkBase, TagStatic } from '#components' 3 3 4 4 const props = defineProps<{ 5 5 packageName: string ··· 58 58 <!-- TypeScript types badge --> 59 59 <li v-if="!props.isBinary" class="contents"> 60 60 <TooltipApp :text="typesTooltip"> 61 - <component 62 - :is="typesHref ? NuxtLink : 'span'" 61 + <LinkBase 62 + v-if="typesHref" 63 + variant="button-secondary" 64 + size="small" 63 65 :to="typesHref" 64 - :tabindex="!typesHref ? 0 : undefined" 65 - class="flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded transition-colors duration-200 focus-visible:(outline-2 outline-accent)" 66 - :class="[ 66 + classicon="i-carbon-checkmark" 67 + > 68 + {{ $t('package.metrics.types_label') }} 69 + </LinkBase> 70 + <TagStatic 71 + v-else 72 + :variant="hasTypes && !isLoading ? 'default' : 'ghost'" 73 + :tabindex="0" 74 + :classicon=" 67 75 isLoading 68 - ? 'text-fg-subtle bg-bg-subtle border border-border-subtle' 76 + ? 'i-carbon-circle-dash motion-safe:animate-spin' 69 77 : hasTypes 70 - ? 'text-fg-muted bg-bg-muted border border-border' 71 - : 'text-fg-subtle bg-bg-subtle border border-border-subtle', 72 - typesHref 73 - ? 'hover:text-fg hover:border-border-hover focus-visible:outline-accent/70' 74 - : '', 75 - ]" 78 + ? 'i-carbon-checkmark' 79 + : 'i-carbon-close' 80 + " 76 81 > 77 - <span 78 - v-if="isLoading" 79 - class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin" 80 - aria-hidden="true" 81 - /> 82 - <span 83 - v-else 84 - class="w-3 h-3" 85 - :class="hasTypes ? 'i-carbon-checkmark' : 'i-carbon-close'" 86 - aria-hidden="true" 87 - /> 88 82 {{ $t('package.metrics.types_label') }} 89 - </component> 83 + </TagStatic> 90 84 </TooltipApp> 91 85 </li> 92 86 ··· 95 89 <TooltipApp 96 90 :text="isLoading ? '' : hasEsm ? $t('package.metrics.esm') : $t('package.metrics.no_esm')" 97 91 > 98 - <span 92 + <TagStatic 99 93 tabindex="0" 100 - class="flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded transition-colors duration-200 focus-visible:(outline-2 outline-accent)" 101 - :class=" 94 + :variant="hasEsm && !isLoading ? 'default' : 'ghost'" 95 + :classicon=" 102 96 isLoading 103 - ? 'text-fg-subtle bg-bg-subtle border border-border-subtle' 97 + ? 'i-carbon-circle-dash motion-safe:animate-spin' 104 98 : hasEsm 105 - ? 'text-fg-muted bg-bg-muted border border-border' 106 - : 'text-fg-subtle bg-bg-subtle border border-border-subtle' 99 + ? 'i-carbon-checkmark' 100 + : 'i-carbon-close' 107 101 " 108 102 > 109 - <span 110 - v-if="isLoading" 111 - class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin" 112 - aria-hidden="true" 113 - /> 114 - <span 115 - v-else 116 - class="w-3 h-3" 117 - :class="hasEsm ? 'i-carbon-checkmark' : 'i-carbon-close'" 118 - aria-hidden="true" 119 - /> 120 103 ESM 121 - </span> 104 + </TagStatic> 122 105 </TooltipApp> 123 106 </li> 124 107 125 108 <!-- CJS badge --> 126 109 <li v-if="isLoading || hasCjs" class="contents"> 127 110 <TooltipApp :text="isLoading ? '' : $t('package.metrics.cjs')"> 128 - <span 111 + <TagStatic 129 112 tabindex="0" 130 - class="flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded transition-colors duration-200 focus-visible:(outline-2 outline-accent)" 131 - :class=" 132 - isLoading 133 - ? 'text-fg-subtle bg-bg-subtle border border-border-subtle' 134 - : 'text-fg-muted bg-bg-muted border border-border' 113 + :variant="isLoading ? 'ghost' : 'default'" 114 + :classicon=" 115 + isLoading ? 'i-carbon-circle-dash motion-safe:animate-spin' : 'i-carbon-checkmark' 135 116 " 136 117 > 137 - <span 138 - v-if="isLoading" 139 - class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin" 140 - aria-hidden="true" 141 - /> 142 - <span v-else class="i-carbon-checkmark w-3 h-3" aria-hidden="true" /> 143 118 CJS 144 - </span> 119 + </TagStatic> 145 120 </TooltipApp> 146 121 </li> 147 122 </ul>
+4 -3
app/components/Package/TableRow.vue
··· 130 130 class="flex flex-wrap gap-1 justify-end" 131 131 :aria-label="$t('package.card.keywords')" 132 132 > 133 - <TagButton 133 + <ButtonBase 134 134 v-for="keyword in pkg.keywords.slice(0, 3)" 135 135 :key="keyword" 136 - :pressed="props.filters?.keywords.includes(keyword)" 136 + size="small" 137 + :aria-pressed="props.filters?.keywords.includes(keyword)" 137 138 :title="`Filter by ${keyword}`" 138 139 @click.stop="emit('clickKeyword', keyword)" 139 140 :class="{ 'group-hover:bg-bg-elevated': !props.filters?.keywords.includes(keyword) }" 140 141 > 141 142 {{ keyword }} 142 - </TagButton> 143 + </ButtonBase> 143 144 <span 144 145 v-if="pkg.keywords.length > 3" 145 146 class="text-fg-subtle text-xs"
+39 -73
app/components/Package/Versions.vue
··· 304 304 id="versions" 305 305 > 306 306 <template #actions> 307 - <a 308 - :href="`https://majors.nullvoxpopuli.com/q?packages=${packageName}`" 309 - target="_blank" 310 - rel="noopener noreferrer" 311 - class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded" 307 + <LinkBase 308 + variant="button-secondary" 309 + :to="`https://majors.nullvoxpopuli.com/q?packages=${packageName}`" 312 310 :title="$t('package.downloads.community_distribution')" 311 + classicon="i-carbon:load-balancer-network" 313 312 > 314 - <span class="i-carbon:load-balancer-network w-3.5 h-3.5" aria-hidden="true" /> 315 313 <span class="sr-only">{{ $t('package.downloads.community_distribution') }}</span> 316 - </a> 314 + </LinkBase> 317 315 </template> 318 316 <div class="space-y-0.5 min-w-0"> 319 317 <!-- Dist-tag rows (limited to MAX_VISIBLE_TAGS) --> ··· 357 355 <div class="flex-1 py-1.5 min-w-0 flex gap-2 justify-between items-center"> 358 356 <div class="overflow-hidden"> 359 357 <div> 360 - <NuxtLink 358 + <LinkBase 361 359 :to="versionRoute(row.primaryVersion.version)" 362 - class="font-mono text-sm transition-colors duration-200 truncate inline-flex items-center gap-1 focus-visible:outline-none focus-visible:text-accent" 360 + class="text-sm" 363 361 :class=" 364 - row.primaryVersion.deprecated 365 - ? 'text-red-400 hover:text-red-300' 366 - : 'text-fg-muted hover:text-fg' 362 + row.primaryVersion.deprecated ? 'text-red-400 hover:text-red-300' : undefined 367 363 " 368 364 :title=" 369 365 row.primaryVersion.deprecated ··· 372 368 }) 373 369 : row.primaryVersion.version 374 370 " 371 + :classicon="row.primaryVersion.deprecated ? 'i-carbon-warning-hex' : undefined" 375 372 > 376 - <span 377 - v-if="row.primaryVersion.deprecated" 378 - class="i-carbon-warning-hex w-3.5 h-3.5 shrink-0" 379 - aria-hidden="true" 380 - /> 381 373 <span dir="ltr"> 382 374 {{ row.primaryVersion.version }} 383 375 </span> 384 - </NuxtLink> 376 + </LinkBase> 385 377 </div> 386 378 <div v-if="row.tags.length" class="flex items-center gap-1 mt-0.5 flex-wrap"> 387 379 <span ··· 420 412 > 421 413 <div v-for="v in getTagVersions(row.tag).slice(1)" :key="v.version" class="py-1"> 422 414 <div class="flex items-center justify-between gap-2"> 423 - <NuxtLink 415 + <LinkBase 424 416 :to="versionRoute(v.version)" 425 - class="font-mono text-xs transition-colors duration-200 truncate inline-flex items-center gap-1" 426 - :class=" 427 - v.deprecated 428 - ? 'text-red-400 hover:text-red-300' 429 - : 'text-fg-subtle hover:text-fg-muted' 430 - " 417 + class="text-xs truncate" 418 + :class="v.deprecated ? 'text-red-400 hover:text-red-300' : undefined" 431 419 :title=" 432 420 v.deprecated 433 421 ? $t('package.versions.deprecated_title', { version: v.version }) 434 422 : v.version 435 423 " 424 + :classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined" 436 425 > 437 - <span 438 - v-if="v.deprecated" 439 - class="i-carbon-warning-hex w-3 h-3 shrink-0" 440 - aria-hidden="true" 441 - /> 442 426 <span dir="ltr"> 443 427 {{ v.version }} 444 428 </span> 445 - </NuxtLink> 429 + </LinkBase> 446 430 <div class="flex items-center gap-2 shrink-0"> 447 431 <DateTime 448 432 v-if="v.time" ··· 525 509 <!-- Hidden tag rows (overflow from visible tags) --> 526 510 <div v-for="row in hiddenTagRows" :key="row.id" class="py-1"> 527 511 <div class="flex items-center justify-between gap-2"> 528 - <NuxtLink 512 + <LinkBase 529 513 :to="versionRoute(row.primaryVersion.version)" 530 - class="font-mono text-xs transition-colors duration-200 truncate inline-flex items-center gap-1" 514 + class="text-xs truncate" 531 515 :class=" 532 - row.primaryVersion.deprecated 533 - ? 'text-red-400 hover:text-red-300' 534 - : 'text-fg-muted hover:text-fg' 516 + row.primaryVersion.deprecated ? 'text-red-400 hover:text-red-300' : undefined 535 517 " 536 518 :title=" 537 519 row.primaryVersion.deprecated ··· 540 522 }) 541 523 : row.primaryVersion.version 542 524 " 525 + :classicon="row.primaryVersion.deprecated ? 'i-carbon-warning-hex' : undefined" 543 526 > 544 - <span 545 - v-if="row.primaryVersion.deprecated" 546 - class="i-carbon-warning-hex w-3 h-3 shrink-0" 547 - aria-hidden="true" 548 - /> 549 527 <span dir="ltr"> 550 528 {{ row.primaryVersion.version }} 551 529 </span> 552 - </NuxtLink> 530 + </LinkBase> 553 531 <div class="flex items-center gap-2 shrink-0 pe-2"> 554 532 <DateTime 555 533 v-if="row.primaryVersion.time" ··· 602 580 aria-hidden="true" 603 581 /> 604 582 </button> 605 - <NuxtLink 583 + <LinkBase 606 584 v-if="group.versions[0]?.version" 607 585 :to="versionRoute(group.versions[0]?.version)" 608 - class="font-mono text-xs transition-colors duration-200 truncate inline-flex items-center gap-1" 586 + class="text-xs truncate" 609 587 :class=" 610 588 group.versions[0]?.deprecated 611 589 ? 'text-red-400 hover:text-red-300' 612 - : 'text-fg-muted hover:text-fg' 590 + : undefined 613 591 " 614 592 :title=" 615 593 group.versions[0]?.deprecated ··· 618 596 }) 619 597 : group.versions[0]?.version 620 598 " 599 + :classicon=" 600 + group.versions[0]?.deprecated ? 'i-carbon-warning-hex' : undefined 601 + " 621 602 > 622 - <span 623 - v-if="group.versions[0]?.deprecated" 624 - class="i-carbon-warning-hex w-3 h-3 shrink-0" 625 - aria-hidden="true" 626 - /> 627 603 <span dir="ltr"> 628 604 {{ group.versions[0]?.version }} 629 605 </span> 630 - </NuxtLink> 606 + </LinkBase> 631 607 </div> 632 608 <div class="flex items-center gap-2 shrink-0 pe-2"> 633 609 <DateTime ··· 665 641 <div class="flex items-center justify-between gap-2"> 666 642 <div class="flex items-center gap-2 min-w-0"> 667 643 <span class="w-4 shrink-0" /> 668 - <NuxtLink 644 + <LinkBase 669 645 v-if="group.versions[0]?.version" 670 646 :to="versionRoute(group.versions[0]?.version)" 671 - class="font-mono text-xs transition-colors duration-200 truncate inline-flex items-center gap-1" 647 + class="text-xs truncate" 672 648 :class=" 673 649 group.versions[0]?.deprecated 674 650 ? 'text-red-400 hover:text-red-300' 675 - : 'text-fg-muted hover:text-fg' 651 + : undefined 676 652 " 677 653 :title=" 678 654 group.versions[0]?.deprecated ··· 681 657 }) 682 658 : group.versions[0]?.version 683 659 " 660 + :classicon=" 661 + group.versions[0]?.deprecated ? 'i-carbon-warning-hex' : undefined 662 + " 684 663 > 685 - <span 686 - v-if="group.versions[0]?.deprecated" 687 - class="i-carbon-warning-hex w-3 h-3 shrink-0" 688 - aria-hidden="true" 689 - /> 690 664 <span dir="ltr"> 691 665 {{ group.versions[0]?.version }} 692 666 </span> 693 - </NuxtLink> 667 + </LinkBase> 694 668 </div> 695 669 <div class="flex items-center gap-2 shrink-0 pe-2"> 696 670 <DateTime ··· 727 701 > 728 702 <div v-for="v in group.versions.slice(1)" :key="v.version" class="py-1"> 729 703 <div class="flex items-center justify-between gap-2"> 730 - <NuxtLink 704 + <LinkBase 731 705 :to="versionRoute(v.version)" 732 - class="font-mono text-xs transition-colors duration-200 truncate inline-flex items-center gap-1" 733 - :class=" 734 - v.deprecated 735 - ? 'text-red-400 hover:text-red-300' 736 - : 'text-fg-subtle hover:text-fg-muted' 737 - " 706 + class="text-xs truncate" 707 + :class="v.deprecated ? 'text-red-400 hover:text-red-300' : undefined" 738 708 :title=" 739 709 v.deprecated 740 710 ? $t('package.versions.deprecated_title', { version: v.version }) 741 711 : v.version 742 712 " 713 + :classicon="v.deprecated ? 'i-carbon-warning-hex' : undefined" 743 714 > 744 - <span 745 - v-if="v.deprecated" 746 - class="i-carbon-warning-hex w-3 h-3 shrink-0" 747 - aria-hidden="true" 748 - /> 749 715 <span dir="ltr"> 750 716 {{ v.version }} 751 717 </span> 752 - </NuxtLink> 718 + </LinkBase> 753 719 <div class="flex items-center gap-2 shrink-0 pe-2"> 754 720 <DateTime 755 721 v-if="v.time"
+3 -3
app/components/Package/WeeklyDownloadStats.vue
··· 211 211 <div class="space-y-8"> 212 212 <CollapsibleSection id="downloads" :title="$t('package.downloads.title')"> 213 213 <template #actions> 214 - <button 214 + <ButtonBase 215 215 type="button" 216 216 @click="openChartModal" 217 217 class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded" 218 218 :title="$t('package.downloads.analyze')" 219 + classicon="i-carbon:data-analytics" 219 220 > 220 - <span class="i-carbon:data-analytics w-4 h-4" aria-hidden="true" /> 221 221 <span class="sr-only">{{ $t('package.downloads.analyze') }}</span> 222 - </button> 222 + </ButtonBase> 223 223 </template> 224 224 225 225 <div class="w-full overflow-hidden">
-31
app/components/Tag/Button.vue
··· 1 - <script setup lang="ts"> 2 - const props = defineProps<{ 3 - disabled?: boolean 4 - /** 5 - * type should never be used, because this will always be a button. 6 - * 7 - * If you want a link use `TagLink` instead. 8 - * */ 9 - type?: never 10 - pressed?: boolean 11 - }>() 12 - </script> 13 - 14 - <template> 15 - <button 16 - class="inline-flex items-center px-2 py-0.5 text-xs font-mono border border-solid rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 17 - :class="[ 18 - pressed 19 - ? 'bg-fg text-bg border-fg hover:(text-text-bg/50 bg-fg-muted)' 20 - : 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)', 21 - { 22 - 'opacity-50 cursor-not-allowed': disabled, 23 - }, 24 - ]" 25 - type="button" 26 - :disabled="disabled ? true : undefined" 27 - :aria-pressed="pressed" 28 - > 29 - <slot /> 30 - </button> 31 - </template>
-37
app/components/Tag/Link.vue
··· 1 - <script setup lang="ts"> 2 - import type { NuxtLinkProps } from '#app' 3 - 4 - const { current, ...props } = defineProps< 5 - { 6 - /** Disabled links will be displayed as plain text */ 7 - disabled?: boolean 8 - /** 9 - * `type` should never be used, because this will always be a link. 10 - * 11 - * If you want a button use `TagButton` instead. 12 - * */ 13 - type?: never 14 - current?: boolean 15 - } & 16 - /** This makes sure the link always has either `to` or `href` */ 17 - (Required<Pick<NuxtLinkProps, 'to'>> | Required<Pick<NuxtLinkProps, 'href'>>) & 18 - NuxtLinkProps 19 - >() 20 - </script> 21 - 22 - <template> 23 - <!-- This is only a placeholder implementation yet. It will probably need some additional styling, but note: A disabled link is just text. --> 24 - <span v-if="disabled" class="opacity-50"><slot /></span> 25 - <NuxtLink 26 - v-else 27 - class="inline-flex items-center px-2 py-0.5 text-xs font-mono border rounded transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1" 28 - :class="{ 29 - 'bg-bg-muted text-fg-muted border-border hover:(text-fg border-border-hover)': !current, 30 - 'bg-fg text-bg border-fg hover:(text-text-bg/50)': current, 31 - 'opacity-50 cursor-not-allowed': disabled, 32 - }" 33 - v-bind="props" 34 - > 35 - <slot /> 36 - </NuxtLink> 37 - </template>
+12 -2
app/components/Tag/Static.vue
··· 1 1 <script setup lang="ts"> 2 - const props = withDefaults(defineProps<{ as?: string | Component }>(), { as: 'span' }) 2 + const props = withDefaults( 3 + defineProps<{ 4 + as?: string | Component 5 + variant?: 'ghost' | 'default' 6 + 7 + classicon?: string 8 + }>(), 9 + { as: 'span', variant: 'default' }, 10 + ) 3 11 </script> 4 12 5 13 <template> 6 14 <component 7 15 :is="as" 8 - class="inline-flex items-center px-2 py-0.5 text-xs font-mono text-fg-muted bg-bg-muted border border-border rounded" 16 + class="bg-bg-muted text-fg-muted inline-flex gap-x-1 items-center justify-center font-mono border border-transparent rounded-md text-xs px-2 py-0.5" 17 + :class="{ 'opacity-40': variant === 'ghost' }" 9 18 > 19 + <span v-if="classicon" :class="['size-3', classicon]" aria-hidden="true" /> 10 20 <slot /> 11 21 </component> 12 22 </template>
+19 -49
app/pages/about.vue
··· 75 75 <strong class="text-fg">{{ $t('about.what_we_are.better_ux_dx') }}</strong> 76 76 </template> 77 77 <template #jsr> 78 - <a 79 - href="https://jsr.io/" 80 - target="_blank" 81 - rel="noopener noreferrer" 82 - class="link text-fg" 83 - >JSR</a 84 - > 78 + <LinkBase to="https://jsr.io/">JSR</LinkBase> 85 79 </template> 86 80 </i18n-t> 87 81 </p> ··· 113 107 > 114 108 <template #already>{{ $t('about.what_we_are_not.words.already') }}</template> 115 109 <template #people> 116 - <a 117 - :href="pmLinks.npm" 118 - target="_blank" 119 - rel="noopener noreferrer" 120 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 121 - >{{ $t('about.what_we_are_not.words.people') }}</a 122 - > 110 + <LinkBase :to="pmLinks.npm">{{ 111 + $t('about.what_we_are_not.words.people') 112 + }}</LinkBase> 123 113 </template> 124 114 <template #building> 125 - <a 126 - :href="pmLinks.pnpm" 127 - target="_blank" 128 - rel="noopener noreferrer" 129 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 130 - >{{ $t('about.what_we_are_not.words.building') }}</a 131 - > 115 + <LinkBase :to="pmLinks.pnpm">{{ 116 + $t('about.what_we_are_not.words.building') 117 + }}</LinkBase> 132 118 </template> 133 119 <template #really> 134 - <a 135 - :href="pmLinks.yarn" 136 - target="_blank" 137 - rel="noopener noreferrer" 138 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 139 - >{{ $t('about.what_we_are_not.words.really') }}</a 140 - > 120 + <LinkBase :to="pmLinks.yarn">{{ 121 + $t('about.what_we_are_not.words.really') 122 + }}</LinkBase> 141 123 </template> 142 124 <template #cool> 143 - <a 144 - :href="pmLinks.bun" 145 - target="_blank" 146 - rel="noopener noreferrer" 147 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 148 - >{{ $t('about.what_we_are_not.words.cool') }}</a 149 - > 125 + <LinkBase :to="pmLinks.bun">{{ 126 + $t('about.what_we_are_not.words.cool') 127 + }}</LinkBase> 150 128 </template> 151 129 <template #package> 152 - <a 153 - :href="pmLinks.deno" 154 - target="_blank" 155 - rel="noopener noreferrer" 156 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 157 - >{{ $t('about.what_we_are_not.words.package') }}</a 158 - > 130 + <LinkBase :to="pmLinks.deno">{{ 131 + $t('about.what_we_are_not.words.package') 132 + }}</LinkBase> 159 133 </template> 160 134 <template #managers> 161 - <a 162 - :href="pmLinks.vlt" 163 - target="_blank" 164 - rel="noopener noreferrer" 165 - class="text-fg-muted hover:text-fg underline decoration-fg-subtle/50 hover:decoration-fg" 166 - >{{ $t('about.what_we_are_not.words.managers') }}</a 167 - > 135 + <LinkBase :to="pmLinks.vlt">{{ 136 + $t('about.what_we_are_not.words.managers') 137 + }}</LinkBase> 168 138 </template> 169 139 </i18n-t> 170 140 </span>
+7 -12
app/pages/index.vue
··· 96 96 @input="handleInput" 97 97 /> 98 98 99 - <button 99 + <ButtonBase 100 100 type="submit" 101 - class="absolute group inset-ie-2.5 px-2.5 sm:ps-4 sm:pe-4 py-2 font-mono text-sm text-bg bg-fg/90 rounded-md transition-[background-color,transform] duration-200 hover:bg-fg! group-focus-within:bg-fg/80 active:scale-95 focus-visible:outline-accent/70" 101 + variant="primary" 102 + class="absolute inset-ie-2" 103 + classicon="i-carbon:search" 102 104 > 103 - <span 104 - class="inline-block i-carbon:search align-middle w-4 h-4 sm:me-2" 105 - aria-hidden="true" 106 - ></span> 107 105 <span class="sr-only sm:not-sr-only"> 108 106 {{ $t('search.button') }} 109 107 </span> 110 - </button> 108 + </ButtonBase> 111 109 </div> 112 110 </div> 113 111 </form> ··· 123 121 > 124 122 <ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-3 list-none m-0 p-0"> 125 123 <li v-for="framework in SHOWCASED_FRAMEWORKS" :key="framework.name"> 126 - <NuxtLink 127 - :to="packageRoute(framework.package)" 128 - class="link-subtle font-mono text-sm inline-flex items-center gap-2 group" 129 - > 124 + <LinkBase :to="packageRoute(framework.package)" class="gap-2 text-sm"> 130 125 <span 131 126 class="w-1 h-1 rounded-full bg-accent group-hover:bg-fg transition-colors duration-200" 132 127 /> 133 128 {{ framework.name }} 134 - </NuxtLink> 129 + </LinkBase> 135 130 </li> 136 131 </ul> 137 132 </nav>
+3 -1
app/pages/org/[org].vue
··· 244 244 <p class="text-fg-muted mb-4"> 245 245 {{ error?.message ?? $t('org.page.failed_to_load') }} 246 246 </p> 247 - <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 247 + <LinkBase variant="button-secondary" :to="{ name: 'index' }">{{ 248 + $t('common.go_back_home') 249 + }}</LinkBase> 248 250 </div> 249 251 250 252 <!-- Empty state -->
+14 -18
app/pages/package-code/[...path].vue
··· 373 373 <!-- Error: no version --> 374 374 <div v-if="!version" class="container py-20 text-center"> 375 375 <p class="text-fg-muted mb-4">{{ $t('code.version_required') }}</p> 376 - <NuxtLink :to="packageRoute(packageName)" class="btn">{{ 376 + <LinkBase variant="button-secondary" :to="packageRoute(packageName)">{{ 377 377 $t('code.go_to_package') 378 - }}</NuxtLink> 378 + }}</LinkBase> 379 379 </div> 380 380 381 381 <!-- Loading state --> ··· 387 387 <!-- Error state --> 388 388 <div v-else-if="treeStatus === 'error'" class="container py-20 text-center" role="alert"> 389 389 <p class="text-fg-muted mb-4">{{ $t('code.failed_to_load_tree') }}</p> 390 - <NuxtLink :to="packageRoute(packageName, version)" class="btn">{{ 390 + <LinkBase variant="button-secondary" :to="packageRoute(packageName, version)">{{ 391 391 $t('code.back_to_package') 392 - }}</NuxtLink> 392 + }}</LinkBase> 393 393 </div> 394 394 395 395 <!-- Main content: file tree + file viewer --> ··· 491 491 <p class="text-fg-subtle text-sm mb-4"> 492 492 {{ $t('code.file_size_warning', { size: formatBytes(currentNode?.size ?? 0) }) }} 493 493 </p> 494 - <a 495 - :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 496 - target="_blank" 497 - rel="noopener noreferrer" 498 - class="btn inline-flex items-center gap-2" 494 + <LinkBase 495 + variant="button-secondary" 496 + :to="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 497 + class="inline-flex items-center gap-2" 499 498 > 500 499 {{ $t('code.view_raw') }} 501 - <span class="i-carbon:launch w-4 h-4" /> 502 - </a> 500 + </LinkBase> 503 501 </div> 504 502 505 503 <!-- Loading file content --> ··· 545 543 <div class="i-carbon:warning-alt w-8 h-8 mx-auto text-fg-subtle mb-4" /> 546 544 <p class="text-fg-muted mb-2">{{ $t('code.failed_to_load') }}</p> 547 545 <p class="text-fg-subtle text-sm mb-4">{{ $t('code.unavailable_hint') }}</p> 548 - <a 549 - :href="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 550 - target="_blank" 551 - rel="noopener noreferrer" 552 - class="btn inline-flex items-center gap-2" 546 + <LinkBase 547 + variant="button-secondary" 548 + :to="`https://cdn.jsdelivr.net/npm/${packageName}@${version}/${filePath}`" 549 + class="inline-flex items-center gap-2" 553 550 > 554 551 {{ $t('code.view_raw') }} 555 - <span class="i-carbon:launch w-4 h-4" /> 556 - </a> 552 + </LinkBase> 557 553 </div> 558 554 559 555 <!-- Directory listing (when no file selected or viewing a directory) -->
+130 -223
app/pages/package/[[org]]/[name].vue
··· 13 13 import { isEditableElement } from '~/utils/input' 14 14 import { formatBytes } from '~/utils/formatters' 15 15 import { getDependencyCount } from '~/utils/npm/dependency-count' 16 - import { NuxtLink } from '#components' 17 16 import { useModal } from '~/composables/useModal' 18 17 import { useAtproto } from '~/composables/atproto/useAtproto' 19 18 import { togglePackageLike } from '~/utils/atproto/likes' 19 + import { LinkBase } from '#components' 20 20 21 21 defineOgImageComponent('Package', { 22 22 name: () => packageName.value, ··· 529 529 :title="pkg.name" 530 530 dir="ltr" 531 531 > 532 - <NuxtLink 533 - v-if="orgName" 534 - :to="{ name: 'org', params: { org: orgName } }" 535 - class="text-fg-muted hover:text-fg transition-colors duration-200" 536 - > 532 + <LinkBase v-if="orgName" :to="{ name: 'org', params: { org: orgName } }"> 537 533 @{{ orgName }} 538 - </NuxtLink> 534 + </LinkBase> 539 535 <span v-if="orgName">/</span> 540 536 <span :class="{ 'text-fg-muted': orgName }"> 541 537 {{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }} ··· 570 566 <span class="i-carbon:arrow-right rtl-flip w-3 h-3" aria-hidden="true" /> 571 567 </template> 572 568 573 - <NuxtLink 569 + <LinkBase 574 570 v-if="requestedVersion && resolvedVersion !== requestedVersion" 575 571 :to="packageRoute(pkg.name, resolvedVersion)" 576 572 :title="$t('package.view_permalink')" 577 573 dir="ltr" 578 - >{{ resolvedVersion }}</NuxtLink 574 + >{{ resolvedVersion }}</LinkBase 579 575 > 580 576 <span dir="ltr" v-else>v{{ resolvedVersion }}</span> 581 577 ··· 590 586 " 591 587 position="bottom" 592 588 > 593 - <a 594 - href="#provenance" 589 + <LinkBase 590 + variant="button-secondary" 591 + size="small" 592 + to="#provenance" 595 593 :aria-label="$t('package.provenance_section.view_more_details')" 596 - class="inline-flex items-center justify-center gap-1.5 text-fg-muted hover:text-emerald-500 transition-colors duration-200 min-w-6 min-h-6" 597 - > 598 - <span class="i-lucide-shield-check w-3.5 h-3.5 shrink-0" aria-hidden="true" /> 599 - </a> 594 + classicon="i-lucide-shield-check" 595 + /> 600 596 </TooltipApp> 601 597 </template> 602 598 <span ··· 634 630 class="i-carbon-circle-dash w-3 h-3 motion-safe:animate-spin" 635 631 aria-hidden="true" 636 632 /> 637 - <button 633 + <ButtonBase 638 634 v-else 639 635 @click="likeAction" 640 - type="button" 636 + size="small" 641 637 :title=" 642 638 likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 643 639 " 644 - class="inline-flex items-center gap-1.5 font-mono text-sm text-fg hover:text-fg-muted transition-colors duration-200" 645 640 :aria-label=" 646 641 likesData?.userHasLiked ? $t('package.likes.unlike') : $t('package.likes.like') 647 642 " 643 + :aria-pressed="likesData?.userHasLiked" 644 + :classicon=" 645 + likesData?.userHasLiked 646 + ? 'i-lucide-heart-minus text-red-500' 647 + : 'i-lucide-heart-plus' 648 + " 648 649 > 649 - <span 650 - :class=" 651 - likesData?.userHasLiked 652 - ? 'i-lucide-heart-minus text-red-500' 653 - : 'i-lucide-heart-plus' 654 - " 655 - class="w-4 h-4" 656 - aria-hidden="true" 657 - /> 658 - <span>{{ 659 - formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) 660 - }}</span> 661 - </button> 650 + {{ formatCompactNumber(likesData?.totalLikes ?? 0, { decimals: 1 }) }} 651 + </ButtonBase> 662 652 </TooltipApp> 663 653 <template #fallback> 664 654 <div ··· 674 664 </div> 675 665 676 666 <!-- Internal navigation: Docs + Code + Compare (hidden on mobile, shown in external links instead) --> 677 - <nav 667 + <ButtonGroup 678 668 v-if="resolvedVersion" 669 + as="nav" 679 670 :aria-label="$t('package.navigation')" 680 - class="hidden sm:flex items-center gap-0.5 p-0.5 bg-bg-subtle border border-border-subtle rounded-md shrink-0 ms-auto self-center" 671 + class="hidden sm:flex" 681 672 > 682 - <NuxtLink 673 + <LinkBase 674 + variant="button-secondary" 683 675 v-if="docsLink" 684 676 :to="docsLink" 685 - class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border inline-flex items-center gap-1.5" 686 - aria-keyshortcuts="d" 677 + keyshortcut="d" 678 + classicon="i-carbon:document" 687 679 > 688 - <span class="i-carbon:document w-3 h-3" aria-hidden="true" /> 689 680 {{ $t('package.links.docs') }} 690 - <kbd 691 - class="inline-flex items-center justify-center w-4 h-4 text-xs bg-bg-muted border border-border rounded" 692 - aria-hidden="true" 693 - > 694 - d 695 - </kbd> 696 - </NuxtLink> 697 - <NuxtLink 681 + </LinkBase> 682 + <LinkBase 683 + variant="button-secondary" 698 684 :to="{ name: 'code', params: { path: [pkg.name, 'v', resolvedVersion] } }" 699 - class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border inline-flex items-center gap-1.5" 700 - aria-keyshortcuts="." 685 + keyshortcut="." 686 + classicon="i-carbon:code" 701 687 > 702 - <span class="i-carbon:code w-3 h-3" aria-hidden="true" /> 703 688 {{ $t('package.links.code') }} 704 - <kbd 705 - class="inline-flex items-center justify-center w-4 h-4 text-xs bg-bg-muted border border-border rounded" 706 - aria-hidden="true" 707 - > 708 - . 709 - </kbd> 710 - </NuxtLink> 711 - <NuxtLink 689 + </LinkBase> 690 + <LinkBase 691 + variant="button-secondary" 712 692 :to="{ name: 'compare', query: { packages: pkg.name } }" 713 - class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 border border-transparent text-fg-subtle hover:text-fg hover:bg-bg hover:shadow hover:border-border inline-flex items-center gap-1.5" 714 - aria-keyshortcuts="c" 693 + keyshortcut="c" 694 + classicon="i-carbon:compare" 715 695 > 716 - <span class="i-carbon:compare w-3 h-3" aria-hidden="true" /> 717 696 {{ $t('package.links.compare') }} 718 - <kbd 719 - class="inline-flex items-center justify-center w-4 h-4 text-xs bg-bg-muted border border-border rounded" 720 - aria-hidden="true" 721 - > 722 - c 723 - </kbd> 724 - </NuxtLink> 725 - </nav> 697 + </LinkBase> 698 + </ButtonGroup> 726 699 </div> 727 700 </header> 728 701 ··· 740 713 </div> 741 714 742 715 <!-- External links --> 743 - <ul class="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:gap-4 list-none m-0 p-0 mt-3"> 716 + <ul 717 + class="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:gap-4 list-none m-0 p-0 mt-3 text-sm" 718 + > 744 719 <li v-if="repositoryUrl"> 745 - <a 746 - :href="repositoryUrl" 747 - target="_blank" 748 - rel="noopener noreferrer" 749 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 750 - > 751 - <span class="w-4 h-4" :class="repoProviderIcon" aria-hidden="true" /> 720 + <LinkBase :to="repositoryUrl" :classicon="repoProviderIcon"> 752 721 <span v-if="repoRef"> 753 722 {{ repoRef.owner }}<span class="opacity-50">/</span>{{ repoRef.repo }} 754 723 </span> 755 724 <span v-else>{{ $t('package.links.repo') }}</span> 756 - </a> 725 + </LinkBase> 757 726 </li> 758 727 <li v-if="repositoryUrl && repoMeta && starsLink"> 759 - <a 760 - :href="starsLink" 761 - target="_blank" 762 - rel="noopener noreferrer" 763 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 764 - > 765 - <span class="w-4 h-4 i-carbon:star" aria-hidden="true" /> 728 + <LinkBase :to="starsLink" classicon="i-carbon:star"> 766 729 {{ formatCompactNumber(stars, { decimals: 1 }) }} 767 - </a> 730 + </LinkBase> 768 731 </li> 769 732 <li v-if="forks && forksLink"> 770 - <a 771 - :href="forksLink" 772 - target="_blank" 773 - rel="noopener noreferrer" 774 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 775 - > 776 - <span class="i-carbon:fork w-4 h-4" aria-hidden="true" /> 733 + <LinkBase :to="forksLink" classicon="i-carbon:fork"> 777 734 {{ formatCompactNumber(forks, { decimals: 1 }) }} 778 - </a> 735 + </LinkBase> 779 736 </li> 780 737 <li v-if="homepageUrl"> 781 - <a 782 - :href="homepageUrl" 783 - target="_blank" 784 - rel="noopener noreferrer" 785 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 786 - > 787 - <span class="i-carbon:link w-4 h-4" aria-hidden="true" /> 738 + <LinkBase :to="homepageUrl" classicon="i-carbon:link"> 788 739 {{ $t('package.links.homepage') }} 789 - </a> 740 + </LinkBase> 790 741 </li> 791 742 <li v-if="displayVersion?.bugs?.url"> 792 - <a 793 - :href="displayVersion.bugs.url" 794 - target="_blank" 795 - rel="noopener noreferrer" 796 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 797 - > 798 - <span class="i-carbon:warning w-4 h-4" aria-hidden="true" /> 743 + <LinkBase :to="displayVersion.bugs.url" classicon="i-carbon:warning"> 799 744 {{ $t('package.links.issues') }} 800 - </a> 745 + </LinkBase> 801 746 </li> 802 747 <li> 803 - <a 804 - :href="`https://www.npmjs.com/package/${pkg.name}`" 805 - target="_blank" 806 - rel="noopener noreferrer" 807 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 748 + <LinkBase 749 + :to="`https://www.npmjs.com/package/${pkg.name}`" 808 750 :title="$t('common.view_on_npm')" 751 + classicon="i-carbon:logo-npm" 809 752 > 810 - <span class="i-carbon:logo-npm w-4 h-4" aria-hidden="true" /> 811 753 npm 812 - </a> 754 + </LinkBase> 813 755 </li> 814 756 <li v-if="jsrInfo?.exists && jsrInfo.url"> 815 - <a 816 - :href="jsrInfo.url" 817 - target="_blank" 818 - rel="noopener noreferrer" 819 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 757 + <LinkBase 758 + :to="jsrInfo.url" 820 759 :title="$t('badges.jsr.title')" 760 + classicon="i-simple-icons:jsr" 821 761 > 822 - <span class="i-simple-icons:jsr w-4 h-4" aria-hidden="true" /> 823 762 {{ $t('package.links.jsr') }} 824 - </a> 763 + </LinkBase> 825 764 </li> 826 765 <li v-if="fundingUrl"> 827 - <a 828 - :href="fundingUrl" 829 - target="_blank" 830 - rel="noopener noreferrer" 831 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 832 - > 833 - <span class="i-carbon:favorite w-4 h-4" aria-hidden="true" /> 766 + <LinkBase :to="fundingUrl" classicon="i-carbon:favorite"> 834 767 {{ $t('package.links.fund') }} 835 - </a> 768 + </LinkBase> 836 769 </li> 837 770 <!-- Mobile-only: Docs + Code + Compare links --> 838 771 <li v-if="docsLink && displayVersion" class="sm:hidden"> 839 - <NuxtLink 840 - :to="docsLink" 841 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 842 - > 843 - <span class="i-carbon:document w-4 h-4" aria-hidden="true" /> 772 + <LinkBase :to="docsLink" classicon="i-carbon:document"> 844 773 {{ $t('package.links.docs') }} 845 - </NuxtLink> 774 + </LinkBase> 846 775 </li> 847 776 <li v-if="resolvedVersion" class="sm:hidden"> 848 - <NuxtLink 777 + <LinkBase 849 778 :to="{ name: 'code', params: { path: [pkg.name, 'v', resolvedVersion] } }" 850 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 779 + classicon="i-carbon:code" 851 780 > 852 - <span class="i-carbon:code w-4 h-4" aria-hidden="true" /> 853 781 {{ $t('package.links.code') }} 854 - </NuxtLink> 782 + </LinkBase> 855 783 </li> 856 784 <li class="sm:hidden"> 857 - <NuxtLink 785 + <LinkBase 858 786 :to="{ name: 'compare', query: { packages: pkg.name } }" 859 - class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 787 + classicon="i-carbon:compare" 860 788 > 861 - <span class="i-carbon:compare w-4 h-4" aria-hidden="true" /> 862 789 {{ $t('package.links.compare') }} 863 - </NuxtLink> 790 + </LinkBase> 864 791 </li> 865 792 </ul> 866 793 </div> ··· 903 830 {{ $t('package.stats.deps') }} 904 831 </dt> 905 832 <dd class="font-mono text-sm text-fg flex items-center justify-start gap-2"> 906 - <!-- Direct deps (muted) --> 907 - <span class="text-fg-muted">{{ getDependencyCount(displayVersion) }}</span> 833 + <span class="flex items-center gap-1"> 834 + <!-- Direct deps (muted) --> 835 + <span class="text-fg-muted">{{ getDependencyCount(displayVersion) }}</span> 908 836 909 - <!-- Separator and total transitive deps --> 910 - <template v-if="getDependencyCount(displayVersion) !== totalDepsCount"> 911 - <span class="text-fg-subtle mx-1">/</span> 837 + <!-- Separator and total transitive deps --> 838 + <template v-if="getDependencyCount(displayVersion) !== totalDepsCount"> 839 + <span class="text-fg-subtle">/</span> 912 840 913 - <ClientOnly> 914 - <span 915 - v-if=" 916 - vulnTreeStatus === 'pending' || (installSizeStatus === 'pending' && !vulnTree) 917 - " 918 - class="inline-flex items-center gap-1 text-fg-subtle" 919 - > 841 + <ClientOnly> 920 842 <span 921 - class="i-carbon:circle-dash w-3 h-3 motion-safe:animate-spin" 922 - aria-hidden="true" 923 - /> 924 - </span> 925 - <span v-else-if="totalDepsCount !== null">{{ totalDepsCount }}</span> 926 - <span v-else class="text-fg-subtle">-</span> 927 - <template #fallback> 928 - <span class="text-fg-subtle">-</span> 929 - </template> 930 - </ClientOnly> 931 - </template> 932 - 933 - <a 934 - v-if="getDependencyCount(displayVersion) > 0" 935 - :href="`https://npmgraph.js.org/?q=${pkg.name}`" 936 - target="_blank" 937 - rel="noopener noreferrer" 938 - class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded" 939 - :title="$t('package.stats.view_dependency_graph')" 940 - > 941 - <span class="i-carbon:network-3 w-3.5 h-3.5" aria-hidden="true" /> 942 - <span class="sr-only">{{ $t('package.stats.view_dependency_graph') }}</span> 943 - </a> 843 + v-if=" 844 + vulnTreeStatus === 'pending' || 845 + (installSizeStatus === 'pending' && !vulnTree) 846 + " 847 + class="inline-flex items-center gap-1 text-fg-subtle" 848 + > 849 + <span 850 + class="i-carbon:circle-dash w-3 h-3 motion-safe:animate-spin" 851 + aria-hidden="true" 852 + /> 853 + </span> 854 + <span v-else-if="totalDepsCount !== null">{{ totalDepsCount }}</span> 855 + <span v-else class="text-fg-subtle">-</span> 856 + <template #fallback> 857 + <span class="text-fg-subtle">-</span> 858 + </template> 859 + </ClientOnly> 860 + </template> 861 + </span> 862 + <ButtonGroup v-if="getDependencyCount(displayVersion) > 0"> 863 + <LinkBase 864 + variant="button-secondary" 865 + size="small" 866 + :to="`https://npmgraph.js.org/?q=${pkg.name}`" 867 + :title="$t('package.stats.view_dependency_graph')" 868 + classicon="i-carbon:network-3" 869 + > 870 + <span class="sr-only">{{ $t('package.stats.view_dependency_graph') }}</span> 871 + </LinkBase> 944 872 945 - <a 946 - v-if="getDependencyCount(displayVersion) > 0" 947 - :href="`https://node-modules.dev/grid/depth#install=${pkg.name}${resolvedVersion ? `@${resolvedVersion}` : ''}`" 948 - target="_blank" 949 - rel="noopener noreferrer" 950 - class="text-fg-subtle hover:text-fg transition-colors duration-200 inline-flex items-center justify-center min-w-6 min-h-6 -m-1 p-1 focus-visible:outline-accent/70 rounded" 951 - :title="$t('package.stats.inspect_dependency_tree')" 952 - > 953 - <span class="i-lucide-view w-3.5 h-3.5" aria-hidden="true" /> 954 - <span class="sr-only">{{ $t('package.stats.inspect_dependency_tree') }}</span> 955 - </a> 873 + <LinkBase 874 + variant="button-secondary" 875 + size="small" 876 + :to="`https://node-modules.dev/grid/depth#install=${pkg.name}${resolvedVersion ? `@${resolvedVersion}` : ''}`" 877 + :title="$t('package.stats.inspect_dependency_tree')" 878 + classicon="i-carbon:tree-view" 879 + > 880 + <span class="sr-only">{{ $t('package.stats.inspect_dependency_tree') }}</span> 881 + </LinkBase> 882 + </ButtonGroup> 956 883 </dd> 957 884 </div> 958 885 ··· 1094 1021 id="get-started-heading" 1095 1022 class="group text-xs text-fg-subtle uppercase tracking-wider" 1096 1023 > 1097 - <a 1098 - href="#get-started" 1099 - class="inline-flex items-center gap-1.5 py-1 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 1100 - > 1024 + <LinkBase to="#get-started"> 1101 1025 {{ $t('package.get_started.title') }} 1102 - <span 1103 - class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200" 1104 - aria-hidden="true" 1105 - /> 1106 - </a> 1026 + </LinkBase> 1107 1027 </h2> 1108 1028 <!-- Package manager dropdown --> 1109 1029 <PackageManagerSelect /> ··· 1147 1067 <section id="readme" class="area-readme min-w-0 scroll-mt-20"> 1148 1068 <div class="flex flex-wrap items-center justify-between mb-3 px-1"> 1149 1069 <h2 id="readme-heading" class="group text-xs text-fg-subtle uppercase tracking-wider"> 1150 - <a 1151 - href="#readme" 1152 - class="inline-flex items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline mt-1" 1153 - > 1070 + <LinkBase to="#readme"> 1154 1071 {{ $t('package.readme.title') }} 1155 - <span 1156 - class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200" 1157 - aria-hidden="true" 1158 - /> 1159 - </a> 1072 + </LinkBase> 1160 1073 </h2> 1161 1074 <ClientOnly> 1162 - <div class="flex items-center gap-2"> 1075 + <div class="flex gap-2"> 1163 1076 <!-- Copy readme as Markdown button --> 1164 1077 <TooltipApp 1165 1078 v-if="readmeData?.md" 1166 1079 :text="$t('package.readme.copy_as_markdown')" 1167 1080 position="bottom" 1168 1081 > 1169 - <button 1170 - type="button" 1082 + <ButtonBase 1171 1083 @click="copyReadme()" 1172 - class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 inline-flex items-center gap-1.5" 1173 - :class=" 1174 - copiedReadme ? 'text-accent bg-accent/10' : 'text-fg-subtle bg-bg hover:text-fg' 1175 - " 1084 + :aria-pressed="copiedReadme" 1176 1085 :aria-label=" 1177 1086 copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown') 1178 1087 " 1088 + :classicon="copiedReadme ? 'i-carbon:checkmark' : 'i-simple-icons:markdown'" 1179 1089 > 1180 - <span 1181 - :class="copiedReadme ? 'i-carbon:checkmark' : 'i-simple-icons:markdown'" 1182 - class="size-3" 1183 - aria-hidden="true" 1184 - /> 1185 1090 {{ copiedReadme ? $t('common.copied') : $t('common.copy') }} 1186 - </button> 1091 + </ButtonBase> 1187 1092 </TooltipApp> 1188 1093 <ReadmeTocDropdown 1189 1094 v-if="readmeData?.toc && readmeData.toc.length > 1" ··· 1242 1147 <div class="area-sidebar"> 1243 1148 <!-- Sidebar --> 1244 1149 <div 1245 - class="sidebar-scroll sticky top-34 space-y-6 sm:space-y-8 min-w-0 overflow-y-auto pe-2.5 lg:(max-h-[calc(100dvh-8.5rem)] overscroll-contain) xl:(top-22 pt-2 max-h-[calc(100dvh-6rem)])" 1150 + class="sidebar-scroll sticky top-34 space-y-6 sm:space-y-8 min-w-0 overflow-y-auto pe-2.5 lg:(max-h-[calc(100dvh-8.5rem)] overscroll-contain) xl:(top-22 pt-2 max-h-[calc(100dvh-6rem)]) pt-1" 1246 1151 > 1247 1152 <!-- Team access controls (for scoped packages when connected) --> 1248 1153 <ClientOnly> ··· 1318 1223 <p class="text-fg-muted mb-8"> 1319 1224 {{ error?.message ?? $t('package.not_found_message') }} 1320 1225 </p> 1321 - <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 1226 + <LinkBase variant="button-secondary" :to="{ name: 'index' }">{{ 1227 + $t('common.go_back_home') 1228 + }}</LinkBase> 1322 1229 </div> 1323 1230 </main> 1324 1231 </template>
+3 -1
app/pages/~[username]/index.vue
··· 228 228 <p class="text-fg-muted mb-4"> 229 229 {{ error?.message ?? $t('user.page.failed_to_load') }} 230 230 </p> 231 - <NuxtLink :to="{ name: 'index' }" class="btn">{{ $t('common.go_back_home') }}</NuxtLink> 231 + <LinkBase variant="button-secondary" :to="{ name: 'index' }">{{ 232 + $t('common.go_back_home') 233 + }}</LinkBase> 232 234 </div> 233 235 234 236 <!-- Package list -->
+7 -4
app/pages/~[username]/orgs.vue
··· 81 81 } 82 82 } 83 83 84 + error.value = $t('header.orgs_dropdown.error') 85 + 84 86 // Load on mount and when connection status changes 85 87 watch(isOwnProfile, loadOrgs, { immediate: true }) 86 88 ··· 158 160 <!-- Not own profile state --> 159 161 <div v-else-if="!isOwnProfile" class="py-12 text-center"> 160 162 <p class="text-fg-muted">{{ $t('user.orgs_page.own_orgs_only') }}</p> 161 - <NuxtLink 163 + <LinkBase 164 + variant="button-secondary" 162 165 :to="{ name: '~username-orgs', params: { username: npmUser! } }" 163 - class="btn mt-4" 164 - >{{ $t('user.orgs_page.view_your_orgs') }}</NuxtLink 166 + class="mt-4" 167 + >{{ $t('user.orgs_page.view_your_orgs') }}</LinkBase 165 168 > 166 169 </div> 167 170 ··· 171 174 <!-- Error state --> 172 175 <div v-else-if="error" role="alert" class="py-12 text-center"> 173 176 <p class="text-fg-muted mb-4">{{ error }}</p> 174 - <button type="button" class="btn" @click="loadOrgs">{{ $t('common.try_again') }}</button> 177 + <ButtonBase @click="loadOrgs">{{ $t('common.try_again') }}</ButtonBase> 175 178 </div> 176 179 177 180 <!-- Empty state -->
+64 -23
test/nuxt/a11y.spec.ts
··· 81 81 AppLogo, 82 82 BaseCard, 83 83 BuildEnvironment, 84 + ButtonBase, 85 + LinkBase, 84 86 CallToAction, 85 87 CodeDirectoryListing, 86 88 CodeFileTree, ··· 140 142 SettingsBgThemePicker, 141 143 SettingsToggle, 142 144 TagStatic, 143 - TagButton, 144 - TagLink, 145 145 TagRadioButton, 146 146 TerminalExecute, 147 147 TerminalInstall, ··· 290 290 }) 291 291 }) 292 292 293 - describe('TagButton', () => { 293 + describe('ButtonBase', () => { 294 294 it('should have no accessibility violations', async () => { 295 - const component = await mountSuspended(TagButton, { 296 - slots: { default: 'Tag content' }, 295 + const component = await mountSuspended(ButtonBase, { 296 + slots: { default: 'Button content' }, 297 297 }) 298 298 const results = await runAxe(component) 299 299 expect(results.violations).toEqual([]) 300 300 }) 301 301 302 - it('should have no accessibility violations when pressed', async () => { 303 - const component = await mountSuspended(TagButton, { 304 - props: { pressed: true }, 305 - slots: { default: 'Tag content' }, 302 + it('should have no accessibility violations for disabled state', async () => { 303 + const component = await mountSuspended(ButtonBase, { 304 + props: { disabled: true }, 305 + slots: { default: 'Button content' }, 306 306 }) 307 307 const results = await runAxe(component) 308 308 expect(results.violations).toEqual([]) 309 309 }) 310 310 311 - it('should have no accessibility violations for disabled state', async () => { 312 - const component = await mountSuspended(TagButton, { 313 - props: { disabled: true }, 314 - slots: { default: 'Tag content' }, 311 + it('should have no accessibility violations as primary button', async () => { 312 + const component = await mountSuspended(ButtonBase, { 313 + props: { variant: 'primary' }, 314 + slots: { default: 'Button content' }, 315 + }) 316 + const results = await runAxe(component) 317 + expect(results.violations).toEqual([]) 318 + }) 319 + 320 + it('should have no accessibility violations with size small', async () => { 321 + const component = await mountSuspended(ButtonBase, { 322 + props: { size: 'small' }, 323 + slots: { default: 'Button content' }, 315 324 }) 316 325 const results = await runAxe(component) 317 326 expect(results.violations).toEqual([]) 318 327 }) 319 328 }) 320 329 321 - describe('TagLink', () => { 330 + describe('LinkBase', () => { 322 331 it('should have no accessibility violations', async () => { 323 - const component = await mountSuspended(TagLink, { 324 - props: { href: 'http://example.com' }, 325 - slots: { default: 'Tag content' }, 332 + const component = await mountSuspended(LinkBase, { 333 + props: { to: 'http://example.com' }, 334 + slots: { default: 'Button link content' }, 326 335 }) 327 336 const results = await runAxe(component) 328 337 expect(results.violations).toEqual([]) 329 338 }) 330 339 331 340 it("should have no accessibility violations when it's the current link", async () => { 332 - const component = await mountSuspended(TagLink, { 333 - props: { href: 'http://example.com', current: true }, 334 - slots: { default: 'Tag content' }, 341 + const component = await mountSuspended(LinkBase, { 342 + props: { to: 'http://example.com', current: true }, 343 + slots: { default: 'Button link content' }, 335 344 }) 336 345 const results = await runAxe(component) 337 346 expect(results.violations).toEqual([]) 338 347 }) 339 348 340 349 it('should have no accessibility violations when disabled (plain text)', async () => { 341 - const component = await mountSuspended(TagLink, { 342 - props: { href: 'http://example.com', disabled: true }, 343 - slots: { default: 'Tag content' }, 350 + const component = await mountSuspended(LinkBase, { 351 + props: { to: 'http://example.com', disabled: true }, 352 + slots: { default: 'Button link content' }, 353 + }) 354 + const results = await runAxe(component) 355 + expect(results.violations).toEqual([]) 356 + }) 357 + 358 + it('should have no accessibility violations as secondary button', async () => { 359 + const component = await mountSuspended(LinkBase, { 360 + props: { to: 'http://example.com', disabled: true, variant: 'button-secondary' }, 361 + slots: { default: 'Button link content' }, 362 + }) 363 + const results = await runAxe(component) 364 + expect(results.violations).toEqual([]) 365 + }) 366 + 367 + it('should have no accessibility violations as primary button', async () => { 368 + const component = await mountSuspended(LinkBase, { 369 + props: { to: 'http://example.com', disabled: true, variant: 'button-primary' }, 370 + slots: { default: 'Button link content' }, 371 + }) 372 + const results = await runAxe(component) 373 + expect(results.violations).toEqual([]) 374 + }) 375 + 376 + it('should have no accessibility violations as small button', async () => { 377 + const component = await mountSuspended(LinkBase, { 378 + props: { 379 + to: 'http://example.com', 380 + disabled: true, 381 + variant: 'button-secondary', 382 + size: 'small', 383 + }, 384 + slots: { default: 'Button link content' }, 344 385 }) 345 386 const results = await runAxe(component) 346 387 expect(results.violations).toEqual([])
+1
test/unit/a11y-component-coverage.spec.ts
··· 43 43 'UserCombobox.vue': 'Unused component - intended for future admin features', 44 44 'SkeletonBlock.vue': 'Already covered indirectly via other component tests', 45 45 'SkeletonInline.vue': 'Already covered indirectly via other component tests', 46 + 'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here", 46 47 } 47 48 48 49 /**
-15
uno.config.ts
··· 126 126 // Focus states - subtle but accessible 127 127 ['focus-ring', 'outline-none focus-visible:(ring-2 ring-fg/50 ring-offset-2)'], 128 128 129 - // Buttons 130 - [ 131 - 'btn', 132 - 'inline-flex items-center justify-center px-4 py-2 font-mono text-sm border border-border rounded-md bg-transparent text-fg transition-all duration-200 hover:(bg-fg hover:text-bg border-fg) focus-ring active:scale-98 disabled:(opacity-40 cursor-not-allowed hover:bg-transparent hover:text-fg)', 133 - ], 134 - [ 135 - 'btn-ghost', 136 - 'inline-flex items-center justify-center px-3 py-1.5 font-mono text-sm text-fg-muted bg-transparent transition-all duration-200 hover:text-fg focus-ring', 137 - ], 138 - 139 - // Links 140 - [ 141 - 'link', 142 - 'text-fg underline-offset-4 decoration-border hover:(decoration-fg underline) transition-colors duration-200 focus-ring', 143 - ], 144 129 ['link-subtle', 'text-fg-muted hover:text-fg transition-colors duration-200 focus-ring'], 145 130 146 131 // badges