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
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
28 changes: 28 additions & 0 deletions app/components/Package/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
Expand Down Expand Up @@ -238,6 +261,11 @@ useShortcuts({
</LinkBase>
<PackageLikes :packageName />

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

<LinkBase
variant="button-secondary"
v-if="fundingUrl"
Expand Down
73 changes: 73 additions & 0 deletions app/components/Package/ShareButton.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
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()
},
)
}
Comment on lines +10 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
git ls-files | rg '^(app/components/Package/ShareButton\.client\.vue|app/components/Package/Header\.vue|app/components/.*/ButtonBase\.vue|.*useShortcuts.*|.*useKeyboardShortcuts.*|.*keyboard_shortcuts_enabled.*|.*shortcuts.*)$' || true

# Find the shortcut composables and the settings flag.
rg -n "useShortcuts|useKeyboardShortcuts|keyboard_shortcuts_enabled|onKeyStroke|useMagicKeys|useEventListener" app . --glob '!node_modules' --glob '!dist' --glob '!build'

# Show concise outlines for the most relevant files if present.
for f in \
  app/components/Package/ShareButton.client.vue \
  app/components/Package/Header.vue \
  app/components/Package/ButtonBase.vue \
  ; do
  if [ -f "$f" ]; then
    echo "### OUTLINE: $f"
    ast-grep outline "$f" --view expanded || true
  fi
done

# Read the relevant sections with line numbers.
for f in \
  app/components/Package/ShareButton.client.vue \
  app/components/Package/Header.vue \
  app/components/Package/ButtonBase.vue \
  ; do
  if [ -f "$f" ]; then
    echo "### FILE: $f"
    wc -l "$f"
  fi
done

# Print the most relevant line ranges if the files exist.
if [ -f app/components/Package/ShareButton.client.vue ]; then
  sed -n '1,120p' app/components/Package/ShareButton.client.vue | cat -n
fi

if [ -f app/components/Package/Header.vue ]; then
  sed -n '1,180p' app/components/Package/Header.vue | cat -n
fi

if [ -f app/components/Package/ButtonBase.vue ]; then
  sed -n '1,220p' app/components/Package/ButtonBase.vue | cat -n
fi

Repository: npmx-dev/npmx.dev

Length of output: 25678


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the shortcut composable and settings hook.
sed -n '1,220p' app/composables/useShortcuts.ts | cat -n
printf '\n### useSettings excerpt\n'
sed -n '1,240p' app/composables/useSettings.ts | cat -n

# Inspect a couple of other components that use keyboard shortcuts directly or via the composable.
printf '\n### ActionBar\n'
sed -n '1,120p' app/components/Package/ActionBar.vue | cat -n
printf '\n### ButtonBase\n'
sed -n '1,140p' app/components/Button/Base.vue | cat -n
printf '\n### Header shortcut usage\n'
sed -n '200,260p' app/components/Package/Header.vue | cat -n

Repository: npmx-dev/npmx.dev

Length of output: 20092


Register v through the shared shortcut registry

The raw onKeyStroke here bypasses the global keyboard-shortcut toggle, so v still works when shortcuts are disabled. Wire this through useShortcuts() like the other package actions in Header.vue so it honours the setting.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Package/ShareButton.client.vue` around lines 10 - 20, The
ShareButton.client.vue keyboard handler currently uses a raw onKeyStroke, so the
v shortcut ignores the global shortcuts toggle. Update the shortcut registration
in the ShareButton component to go through useShortcuts(), matching the pattern
used by Header.vue and other package actions, and keep the existing
isKeyWithoutModifiers / isEditableElement checks and buttonRef click behavior
intact.


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
}
}

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(() => {})
}
Comment on lines +38 to +58

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Share payload diverges from the command-palette share action.

share() here conditionally attaches an og:image file when fully shareable, but Header.vue's sharePackage() (used by the "package-share" command) only ever shares title/text/url. Users triggering share via the command palette get a different, less-rich payload than users clicking the button or using the v shortcut.

Consider extracting the shared logic (title/text/url + optional image attachment) into a composable (e.g. usePackageShare()) consumed by both Header.vue and this component, so all three entry points behave identically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/Package/ShareButton.client.vue` around lines 38 - 58, The
package share payload is implemented inconsistently between
ShareButton.client.vue and Header.vue, so the command-palette share action uses
a less rich ShareData than the button and shortcut. Extract the shared share
flow into a common helper/composable such as usePackageShare() that builds the
title/text/url payload, optionally attaches the og:image file when
navigator.canShare allows it, and is reused by both share() and Header.vue's
sharePackage() so all entry points behave the same.

</script>

<template>
<ButtonBase
v-if="canShare"
ref="buttonRef"
variant="secondary"
classicon="i-lucide:share-2"
:aria-label="$t('package.share_aria_label', { package: packageName })"
:ariaKeyshortcuts="'v'"
@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 @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,10 +1476,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 @@ -61,6 +61,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