Skip to content

Commit d206a7a

Browse files
WilcoSpautofix-ci[bot]danielroe
authored
feat: copy package name button (#473)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent e2b4432 commit d206a7a

8 files changed

Lines changed: 102 additions & 35 deletions

File tree

app/components/AnnounceTooltip.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
/** Tooltip text */
4+
text: string
5+
/** Position: 'top' | 'bottom' | 'left' | 'right' */
6+
position?: 'top' | 'bottom' | 'left' | 'right'
7+
/** is tooltip visible */
8+
isVisible: boolean
9+
}>()
10+
</script>
11+
12+
<template>
13+
<BaseTooltip :text :isVisible :position :tooltip-attr="{ 'aria-live': 'polite' }"
14+
><slot
15+
/></BaseTooltip>
16+
</template>

app/components/AppTooltip.vue

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,6 @@ const props = defineProps<{
99
const isVisible = shallowRef(false)
1010
const tooltipId = useId()
1111
12-
const positionClasses: Record<string, string> = {
13-
top: 'bottom-full inset-is-1/2 -translate-x-1/2 mb-1',
14-
bottom: 'top-full inset-is-0 mt-1',
15-
left: 'inset-ie-full top-1/2 -translate-y-1/2 me-2',
16-
right: 'inset-is-full top-1/2 -translate-y-1/2 ms-2',
17-
}
18-
19-
const tooltipPosition = computed(() => positionClasses[props.position || 'bottom'])
20-
2112
function show() {
2213
isVisible.value = true
2314
}
@@ -28,31 +19,16 @@ function hide() {
2819
</script>
2920

3021
<template>
31-
<div
32-
class="relative inline-flex"
33-
:aria-describedby="isVisible ? tooltipId : undefined"
22+
<BaseTooltip
23+
:text
24+
:isVisible
25+
:position
26+
:tooltip-attr="{ role: 'tooltip', id: tooltipId }"
3427
@mouseenter="show"
3528
@mouseleave="hide"
3629
@focusin="show"
3730
@focusout="hide"
38-
>
39-
<slot />
40-
41-
<Transition
42-
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
43-
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
44-
enter-from-class="opacity-0"
45-
leave-to-class="opacity-0"
46-
>
47-
<div
48-
v-if="isVisible"
49-
:id="tooltipId"
50-
role="tooltip"
51-
class="absolute px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-[100] pointer-events-none"
52-
:class="tooltipPosition"
53-
>
54-
{{ text }}
55-
</div>
56-
</Transition>
57-
</div>
31+
:aria-describedby="isVisible ? tooltipId : undefined"
32+
><slot
33+
/></BaseTooltip>
5834
</template>

app/components/BaseTooltip.vue

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
4+
const props = defineProps<{
5+
/** Tooltip text */
6+
text: string
7+
/** Position: 'top' | 'bottom' | 'left' | 'right' */
8+
position?: 'top' | 'bottom' | 'left' | 'right'
9+
/** is tooltip visible */
10+
isVisible: boolean
11+
/** attributes for tooltip element */
12+
tooltipAttr?: HTMLAttributes
13+
}>()
14+
15+
const positionClasses: Record<string, string> = {
16+
top: 'bottom-full inset-is-1/2 -translate-x-1/2 mb-1',
17+
bottom: 'top-full inset-is-0 mt-1',
18+
left: 'inset-ie-full top-1/2 -translate-y-1/2 me-2',
19+
right: 'inset-is-full top-1/2 -translate-y-1/2 ms-2',
20+
}
21+
22+
const tooltipPosition = computed(() => positionClasses[props.position || 'bottom'])
23+
</script>
24+
25+
<template>
26+
<div class="relative inline-flex">
27+
<slot />
28+
29+
<Transition
30+
enter-active-class="transition-opacity duration-150 motion-reduce:transition-none"
31+
leave-active-class="transition-opacity duration-100 motion-reduce:transition-none"
32+
enter-from-class="opacity-0"
33+
leave-to-class="opacity-0"
34+
>
35+
<div
36+
v-if="props.isVisible"
37+
class="absolute px-2 py-1 font-mono text-xs text-fg bg-bg-elevated border border-border rounded shadow-lg whitespace-nowrap z-[100] pointer-events-none"
38+
:class="tooltipPosition"
39+
v-bind="tooltipAttr"
40+
>
41+
{{ text }}
42+
</div>
43+
</Transition>
44+
</div>
45+
</template>

app/pages/[...package].vue

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ const displayVersion = computed(() => {
8686
return pkg.value.versions[latestTag] ?? null
8787
})
8888
89+
//copy package name
90+
const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
91+
source: packageName,
92+
copiedDuring: 2000,
93+
})
94+
8995
// Fetch dependency analysis (lazy, client-side)
9096
// This is the same composable used by PackageVulnerabilityTree and PackageDeprecatedTree
9197
const {
@@ -388,9 +394,19 @@ function handleClick(event: MouseEvent) {
388394
:to="{ name: 'org', params: { org: orgName } }"
389395
class="text-fg-muted hover:text-fg transition-colors duration-200"
390396
>@{{ orgName }}</NuxtLink
391-
><span v-if="orgName">/</span
392-
>{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
397+
><span v-if="orgName">/</span>
398+
<AnnounceTooltip :text="$t('common.copied')" :isVisible="copiedPkgName">
399+
<button
400+
@click="copyPkgName()"
401+
aria-describedby="copy-pkg-name"
402+
class="cursor-copy ms-1 mt-1 active:scale-95 transition-transform"
403+
>
404+
{{ orgName ? pkg.name.replace(`@${orgName}/`, '') : pkg.name }}
405+
</button>
406+
</AnnounceTooltip>
393407
</h1>
408+
409+
<span id="copy-pkg-name" class="sr-only">{{ $t('package.copy_name') }}</span>
394410
<span
395411
v-if="displayVersion"
396412
class="inline-flex items-baseline gap-1.5 font-mono text-base sm:text-lg text-fg-muted shrink-0"

app/pages/code/[...path].vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ defineOgImageComponent('Default', {
418418
<button
419419
v-if="selectedLines"
420420
type="button"
421-
class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors"
421+
class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors active:scale-95"
422422
@click="copyPermalinkUrl"
423423
>
424424
{{ permalinkCopied ? $t('common.copied') : $t('code.copy_link') }}

i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"verified_provenance": "Verified provenance",
112112
"view_permalink": "View permalink for this version",
113113
"navigation": "Package",
114+
"copy_name": "Copy package name",
114115
"deprecation": {
115116
"package": "This package has been deprecated.",
116117
"version": "This version has been deprecated.",

lunaria/files/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"verified_provenance": "Verified provenance",
112112
"view_permalink": "View permalink for this version",
113113
"navigation": "Package",
114+
"copy_name": "Copy package name",
114115
"deprecation": {
115116
"package": "This package has been deprecated.",
116117
"version": "This version has been deprecated.",

test/nuxt/components.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import DateTime from '~/components/DateTime.vue'
5656
import AppHeader from '~/components/AppHeader.vue'
5757
import AppFooter from '~/components/AppFooter.vue'
5858
import AppTooltip from '~/components/AppTooltip.vue'
59+
import AnnounceTooltip from '~/components/AnnounceTooltip.vue'
5960
import LoadingSpinner from '~/components/LoadingSpinner.vue'
6061
import JsrBadge from '~/components/JsrBadge.vue'
6162
import ProvenanceBadge from '~/components/ProvenanceBadge.vue'
@@ -194,6 +195,17 @@ describe('component accessibility audits', () => {
194195
})
195196
})
196197

198+
describe('AnnounceTooltip', () => {
199+
it('should have no accessibility violations', async () => {
200+
const component = await mountSuspended(AnnounceTooltip, {
201+
props: { text: 'Tooltip content', isVisible: true },
202+
slots: { default: '<button>Trigger</button>' },
203+
})
204+
const results = await runAxe(component)
205+
expect(results.violations).toEqual([])
206+
})
207+
})
208+
197209
describe('LoadingSpinner', () => {
198210
it('should have no accessibility violations', async () => {
199211
const component = await mountSuspended(LoadingSpinner)

0 commit comments

Comments
 (0)