Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/components/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ const footerSections = computed<Array<{ label: string; links: FooterLink[] }>>((
<kbd class="kbd">-</kbd>
<span>{{ $t('shortcuts.changelog') }}</span>
</li>
<li class="flex gap-2 items-center">
<kbd class="kbd">v</kbd>
<span>{{ $t('package.share_aria_label') }}</span>
</li>
</ul>
<p class="text-fg-muted leading-relaxed">
<i18n-t keypath="shortcuts.disable_shortcuts" tag="span" scope="global">
Expand Down
1 change: 1 addition & 0 deletions app/components/Button/Base.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const keyboardShortcutsEnabled = useKeyboardShortcuts()

defineExpose({
focus: () => el.value?.focus(),
click: () => el.value?.click(),
getBoundingClientRect: () => el.value?.getBoundingClientRect(),
})
</script>
Expand Down
20 changes: 20 additions & 0 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { scrollToTop } = useScrollToTop()
const packageHeaderHeight = usePackageHeaderHeight()

const header = useTemplateRef('header')
const shareRef = useTemplateRef('shareRef')
const isHeaderPinned = shallowRef(false)
const { height: headerHeight } = useElementBounding(header)

Expand Down Expand Up @@ -80,6 +81,8 @@ const { copied: copiedPkgVersion, copy: copyPkgVersion } = useClipboard({
copiedDuring: 2000,
})

const canShare = import.meta.client ? 'share' in navigator : false

function hasProvenance(version: PackumentVersion | null): boolean {
if (!version?.dist) return false
return !!(version.dist as { attestations?: unknown }).attestations
Expand Down Expand Up @@ -116,6 +119,17 @@ useCommandPaletteContextCommands(
},
]

if (canShare) {
commands.push({
id: 'package-share',
group: 'package',
label: $t('package.links.share'),
keywords: [packageName.value, 'share'],
iconClass: 'i-lucide:share-2',
action: () => shareRef.value?.sharePackage(),
})
}

if (fundingUrl.value) {
commands.push({
id: 'package-link-funding',
Expand Down Expand Up @@ -260,6 +274,12 @@ useShortcuts({
</LinkBase>
<PackageLikes :packageName />

<PackageShareButton
ref="shareRef"
:package-name="packageName"
:description="displayVersion?.description"
/>

<LinkBase
variant="button-secondary"
v-if="fundingUrl"
Expand Down
88 changes: 88 additions & 0 deletions app/components/Package/ShareButton.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
description?: string | null
}>()

const canShare = 'share' in navigator
const buttonRef = useTemplateRef<{ click: () => void }>('buttonRef')
const keyboardShortcutsEnabled = useKeyboardShortcuts()

if (canShare) {
onKeyStroke(
e =>
keyboardShortcutsEnabled.value &&
isKeyWithoutModifiers(e, 'v') &&
!isEditableElement(e.target),
e => {
e.preventDefault()
// Click the button element so the browser anchors the share sheet at
// the button's position rather than the current mouse cursor position.
buttonRef.value?.click()
},
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function getOgImageFile(): Promise<File | null> {
// Files sharing is not universally supported; bail out early if not available.
if (!navigator.canShare?.({ files: [new File([], 'x.png', { type: 'image/png' })] })) {
return null
}
try {
const ogMeta = document.querySelector<HTMLMetaElement>('meta[property="og:image"]')
if (!ogMeta?.content) return null
const blob = await fetch(ogMeta.content).then(r => r.blob())
if (!blob.type.startsWith('image/')) return null
return new File([blob], `${props.packageName}.png`, { type: blob.type })
} catch {
return null
}
}

const sharing = shallowRef(false)

async function share() {
if (sharing.value) {
return
}
sharing.value = true
const shareData: ShareData = {
title: props.packageName,
text: props.description ?? props.packageName,
url: window.location.href,
}

const imageFile = await getOgImageFile()
if (imageFile) {
const shareDataWithFile: ShareData = { ...shareData, files: [imageFile] }
// Some implementations support sharing files or url/text, but not the
// combination of both. Only attach the file once the full payload
// (title + text + url + files) is confirmed shareable, so we keep the
// url/text fallback otherwise.
if (navigator.canShare?.(shareDataWithFile)) {
shareData.files = shareDataWithFile.files
}
}

await navigator
.share(shareData)
.catch(() => {})
.finally(() => (sharing.value = false))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

defineExpose({ sharePackage: share })
</script>

<template>
<ButtonBase
v-if="canShare"
ref="buttonRef"
variant="secondary"
:classicon="sharing ? 'i-svg-spinners:ring-resize' : 'i-lucide:share-2'"
:aria-label="$t('package.share_aria_label', { package: packageName })"
:ariaKeyshortcuts="keyboardShortcutsEnabled ? 'v' : undefined"

@userquin userquin Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I press Ctrl + click in my IDE (WebStorm) on :aria-keyshortcuts, it redirects to the DOM library, when it should redirect to the ariaKeyshortcuts prop of the Button component.

@click="share"
>
<span class="max-sm:sr-only">{{ $t('package.links.share') }}</span>
</ButtonBase>
</template>
4 changes: 3 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,10 @@
"timeline": "timeline",
"stats": "stats",
"compare_this_package": "compare this package",
"changelog": "changelog"
"changelog": "changelog",
"share": "share"
},
"share_aria_label": "Share {package}",
"likes": {
"like": "Like this package",
"unlike": "Unlike this package",
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1506,10 +1506,16 @@
},
"changelog": {
"type": "string"
},
"share": {
"type": "string"
}
},
"additionalProperties": false
},
"share_aria_label": {
"type": "string"
},
"likes": {
"type": "object",
"properties": {
Expand Down
2 changes: 2 additions & 0 deletions test/unit/a11y-component-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
'Translation/StatusByFile.unused.vue': 'Unused component, might be needed in the future',
'ColorScheme/Img.vue': 'Image component, basic ui',
'VideoPlayer.vue': 'Atproto video component, basic ui',
'Package/ShareButton.client.vue':
'Renders nothing when Web Share API is unavailable (test env); button itself has no a11y violations',
}

function normalizeComponentPath(filePath: string): string {
Expand Down
Loading