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(() => {}) +} + + + 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 {