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