[READ-ONLY] a fast, modern browser for the npm registry
at main 196 lines 6.8 kB view raw
1<script setup lang="ts"> 2import type { PlaygroundLink } from '#shared/types' 3import { decodeHtmlEntities } from '~/utils/formatters' 4 5const props = defineProps<{ 6 links: PlaygroundLink[] 7}>() 8 9// Map provider id to icon class 10const providerIcons: Record<string, string> = { 11 'stackblitz': 'i-simple-icons:stackblitz', 12 'codesandbox': 'i-simple-icons:codesandbox', 13 'codepen': 'i-simple-icons:codepen', 14 'replit': 'i-simple-icons:replit', 15 'gitpod': 'i-simple-icons:gitpod', 16 'vue-playground': 'i-simple-icons:vuedotjs', 17 'nuxt-new': 'i-simple-icons:nuxtdotjs', 18 'vite-new': 'i-simple-icons:vite', 19 'jsfiddle': 'i-lucide:code', 20} 21 22// Map provider id to color class 23const providerColors: Record<string, string> = { 24 'stackblitz': 'text-provider-stackblitz', 25 'codesandbox': 'text-provider-codesandbox', 26 'codepen': 'text-provider-codepen', 27 'replit': 'text-provider-replit', 28 'gitpod': 'text-provider-gitpod', 29 'vue-playground': 'text-provider-vue', 30 'nuxt-new': 'text-provider-nuxt', 31 'vite-new': 'text-provider-vite', 32 'jsfiddle': 'text-provider-jsfiddle', 33} 34 35function getIcon(provider: string): string { 36 return providerIcons[provider] || 'i-lucide:play' 37} 38 39function getColor(provider: string): string { 40 return providerColors[provider] || 'text-fg-muted' 41} 42 43// Dropdown state 44const isOpen = shallowRef(false) 45const dropdownRef = useTemplateRef('dropdownRef') 46const menuRef = useTemplateRef('menuRef') 47const focusedIndex = shallowRef(-1) 48 49onClickOutside(dropdownRef, () => { 50 isOpen.value = false 51}) 52 53// Single vs multiple 54const hasSingleLink = computed(() => props.links.length === 1) 55const hasMultipleLinks = computed(() => props.links.length > 1) 56const firstLink = computed(() => props.links[0]) 57 58function closeDropdown() { 59 isOpen.value = false 60 focusedIndex.value = -1 61} 62 63function handleKeydown(event: KeyboardEvent) { 64 if (!isOpen.value) { 65 if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { 66 event.preventDefault() 67 isOpen.value = true 68 focusedIndex.value = 0 69 nextTick(() => focusMenuItem(0)) 70 } 71 return 72 } 73 74 switch (event.key) { 75 case 'Escape': 76 event.preventDefault() 77 closeDropdown() 78 break 79 case 'ArrowDown': 80 event.preventDefault() 81 focusedIndex.value = (focusedIndex.value + 1) % props.links.length 82 focusMenuItem(focusedIndex.value) 83 break 84 case 'ArrowUp': 85 event.preventDefault() 86 focusedIndex.value = focusedIndex.value <= 0 ? props.links.length - 1 : focusedIndex.value - 1 87 focusMenuItem(focusedIndex.value) 88 break 89 case 'Home': 90 event.preventDefault() 91 focusedIndex.value = 0 92 focusMenuItem(0) 93 break 94 case 'End': 95 event.preventDefault() 96 focusedIndex.value = props.links.length - 1 97 focusMenuItem(props.links.length - 1) 98 break 99 case 'Tab': 100 closeDropdown() 101 break 102 } 103} 104 105function focusMenuItem(index: number) { 106 const items = menuRef.value?.querySelectorAll<HTMLElement>('[role="menuitem"]') 107 items?.[index]?.focus() 108} 109</script> 110 111<template> 112 <section v-if="links.length > 0" class="px-1"> 113 <h2 114 id="playgrounds-heading" 115 class="text-xs font-mono text-fg-subtle uppercase tracking-wider text-white mb-3" 116 > 117 {{ $t('package.playgrounds.title') }} 118 </h2> 119 120 <div ref="dropdownRef" class="relative"> 121 <!-- Single link: direct button --> 122 <TooltipApp v-if="hasSingleLink && firstLink" :text="firstLink.providerName" class="w-full"> 123 <a 124 :href="firstLink.url" 125 target="_blank" 126 rel="noopener noreferrer" 127 class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-accent/70 transition-colors duration-200" 128 > 129 <span 130 :class="[getIcon(firstLink.provider), getColor(firstLink.provider), 'w-4 h-4 shrink-0']" 131 aria-hidden="true" 132 /> 133 <span class="truncate text-fg-muted">{{ decodeHtmlEntities(firstLink.label) }}</span> 134 </a> 135 </TooltipApp> 136 137 <!-- Multiple links: dropdown button --> 138 <button 139 v-if="hasMultipleLinks" 140 type="button" 141 aria-haspopup="true" 142 :aria-expanded="isOpen" 143 class="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-accent/70 transition-colors duration-200" 144 @click="isOpen = !isOpen" 145 @keydown="handleKeydown" 146 > 147 <span class="flex items-center gap-2"> 148 <span class="i-lucide:play w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" /> 149 <span class="text-fg-muted" 150 >{{ $t('package.playgrounds.choose') }} ({{ links.length }})</span 151 > 152 </span> 153 <span 154 class="i-lucide:chevron-down w-3 h-3 text-fg-subtle transition-transform duration-200 motion-reduce:transition-none" 155 :class="{ 'rotate-180': isOpen }" 156 aria-hidden="true" 157 /> 158 </button> 159 160 <!-- Dropdown menu --> 161 <Transition 162 enter-active-class="transition duration-150 ease-out motion-reduce:transition-none" 163 enter-from-class="opacity-0 scale-95 motion-reduce:scale-100" 164 enter-to-class="opacity-100 scale-100" 165 leave-active-class="transition duration-100 ease-in motion-reduce:transition-none" 166 leave-from-class="opacity-100 scale-100" 167 leave-to-class="opacity-0 scale-95 motion-reduce:scale-100" 168 > 169 <div 170 v-if="isOpen && hasMultipleLinks" 171 ref="menuRef" 172 role="menu" 173 class="absolute top-full inset-is-0 inset-ie-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 overflow-visible" 174 @keydown="handleKeydown" 175 > 176 <TooltipApp v-for="link in links" :key="link.url" :text="link.providerName" class="block"> 177 <a 178 :href="link.url" 179 target="_blank" 180 rel="noopener noreferrer" 181 role="menuitem" 182 class="flex items-center gap-2 px-3 py-2 text-sm font-mono text-fg-muted hover:text-fg hover:bg-bg-muted focus-visible:outline-accent/70 focus-visible:text-fg focus-visible:bg-bg-muted transition-colors duration-150" 183 @click="closeDropdown" 184 > 185 <span 186 :class="[getIcon(link.provider), getColor(link.provider), 'w-4 h-4 shrink-0']" 187 aria-hidden="true" 188 /> 189 <span class="truncate">{{ decodeHtmlEntities(link.label) }}</span> 190 </a> 191 </TooltipApp> 192 </div> 193 </Transition> 194 </div> 195 </section> 196</template>