diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue
index 1a321e888d..182dc3db28 100644
--- a/app/components/Button/Base.vue
+++ b/app/components/Button/Base.vue
@@ -41,6 +41,7 @@ const keyboardShortcutsEnabled = useKeyboardShortcuts()
defineExpose({
focus: () => el.value?.focus(),
+ click: () => el.value?.click(),
getBoundingClientRect: () => el.value?.getBoundingClientRect(),
})
diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue
index d0306d3706..a087268529 100644
--- a/app/components/Package/Header.vue
+++ b/app/components/Package/Header.vue
@@ -75,6 +75,18 @@ const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({
copiedDuring: 2000,
})
+const canShare = import.meta.client && 'share' in navigator
+
+function sharePackage() {
+ navigator
+ .share({
+ title: packageName.value,
+ text: props.displayVersion?.description ?? packageName.value,
+ url: window.location.href,
+ })
+ .catch(() => {})
+}
+
function hasProvenance(version: PackumentVersion | null): boolean {
if (!version?.dist) return false
return !!(version.dist as { attestations?: unknown }).attestations
@@ -100,6 +112,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: sharePackage,
+ })
+ }
+
if (fundingUrl.value) {
commands.push({
id: 'package-link-funding',
@@ -238,6 +261,11 @@ useShortcuts({
+
+
+const props = defineProps<{
+ packageName: string
+ description?: string | null
+}>()
+
+const canShare = 'share' in navigator
+const buttonRef = useTemplateRef<{ click: () => void }>('buttonRef')
+
+if (canShare) {
+ onKeyStroke(
+ e => 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()
+ },
+ )
+}
+
+async function getOgImageFile(): Promise {
+ // 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('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
+ }
+}
+
+async function share() {
+ 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(() => {})
+}
+
+
+
+
+ {{ $t('package.links.share') }}
+
+
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 49119bafc9..a67c1117ef 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -490,8 +490,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",
diff --git a/i18n/schema.json b/i18n/schema.json
index 1acae2e07b..d53eb49590 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -1476,10 +1476,16 @@
},
"changelog": {
"type": "string"
+ },
+ "share": {
+ "type": "string"
}
},
"additionalProperties": false
},
+ "share_aria_label": {
+ "type": "string"
+ },
"likes": {
"type": "object",
"properties": {
diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts
index 86f8371613..438c8d4288 100644
--- a/test/unit/a11y-component-coverage.spec.ts
+++ b/test/unit/a11y-component-coverage.spec.ts
@@ -61,6 +61,8 @@ const SKIPPED_COMPONENTS: Record = {
'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 {