diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 4580671cd3..96ebda187b 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -276,6 +276,10 @@ const footerSections = computed>(( - {{ $t('shortcuts.changelog') }} +
  • + v + {{ $t('package.share_aria_label') }} +
  • diff --git a/app/components/Button/Base.vue b/app/components/Button/Base.vue index be7926289c..fbd535bfa9 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 e01e0dc03b..0da5d6ac1a 100644 --- a/app/components/Package/Header.vue +++ b/app/components/Package/Header.vue @@ -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) @@ -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 @@ -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', @@ -260,6 +274,12 @@ useShortcuts({ + + +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() + }, + ) +} + +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 + } +} + +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)) +} + +defineExpose({ sharePackage: share }) + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index b0e28f2c8a..3d8c7fcc9e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", diff --git a/i18n/schema.json b/i18n/schema.json index 254dda9aed..a4dfc289fe 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1506,10 +1506,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 d6f82a4d62..b066dbd010 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -63,6 +63,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 {