diff --git a/app/app.vue b/app/app.vue index cbcf3a4808..ee2d52574a 100644 --- a/app/app.vue +++ b/app/app.vue @@ -68,9 +68,20 @@ function handleGlobalKeyup() { showKbdHints.value = false } +/* A hack to get light dismiss to work in safari because it does not support closedby="any" yet */ +// https://codepen.io/paramagicdev/pen/gbYompq +// see: https://github.com/npmx-dev/npmx.dev/pull/522#discussion_r2749978022 +function handleModalLightDismiss(e: MouseEvent) { + const target = e.target as HTMLElement + if (target.tagName === 'DIALOG' && target.hasAttribute('open')) { + ;(target as HTMLDialogElement).close() + } +} + if (import.meta.client) { useEventListener(document, 'keydown', handleGlobalKeydown) useEventListener(document, 'keyup', handleGlobalKeyup) + useEventListener(document, 'click', handleModalLightDismiss) } diff --git a/app/assets/main.css b/app/assets/main.css index 81265c902b..31a9a71880 100644 --- a/app/assets/main.css +++ b/app/assets/main.css @@ -233,3 +233,9 @@ input[type='search']::-webkit-search-results-decoration { animation-duration: 0.3s; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } + +/* Locking the scroll whenever any of the modals are open */ +html:has(dialog:modal) { + overflow: hidden; + scrollbar-gutter: stable; +} diff --git a/app/components/AuthModal.client.vue b/app/components/AuthModal.client.vue new file mode 100644 index 0000000000..dcc79883c7 --- /dev/null +++ b/app/components/AuthModal.client.vue @@ -0,0 +1,144 @@ + + + diff --git a/app/components/AuthModal.vue b/app/components/AuthModal.vue deleted file mode 100644 index ade24b2eaf..0000000000 --- a/app/components/AuthModal.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - diff --git a/app/components/ChartModal.vue b/app/components/ChartModal.vue index 465ff4b1cd..bc7e971480 100644 --- a/app/components/ChartModal.vue +++ b/app/components/ChartModal.vue @@ -1,66 +1,14 @@ - + diff --git a/app/components/ClaimPackageModal.vue b/app/components/ClaimPackageModal.vue index 4207fe2cca..fb57b3277b 100644 --- a/app/components/ClaimPackageModal.vue +++ b/app/components/ClaimPackageModal.vue @@ -6,8 +6,6 @@ const props = defineProps<{ packageName: string }>() -const open = defineModel('open', { default: false }) - const { isConnected, state, @@ -38,6 +36,8 @@ async function checkAvailability() { } } +const connectorModal = useModal('connector-modal') + async function handleClaim() { if (!checkResult.value?.available || !isConnected.value) return @@ -71,15 +71,15 @@ async function handleClaim() { } else if (completedOp?.status === 'failed') { if (completedOp.result?.requiresOtp) { // OTP is needed - open connector panel to handle it - open.value = false - connectorModalOpen.value = true + close() + connectorModal.open() } else { publishError.value = completedOp.result?.stderr || 'Failed to publish package' } } else { // Still pending/approved/running - open connector panel to show progress - open.value = false - connectorModalOpen.value = true + close() + connectorModal.open() } } catch (err) { publishError.value = err instanceof Error ? err.message : $t('claim.modal.failed_to_claim') @@ -88,15 +88,22 @@ async function handleClaim() { } } -// Check availability when modal opens -watch(open, isOpen => { - if (isOpen) { - checkResult.value = null - publishError.value = null - publishSuccess.value = false - checkAvailability() - } -}) +const dialogRef = ref() + +function open() { + // Reset state and check availability each time modal is opened + checkResult.value = null + publishError.value = null + publishSuccess.value = false + checkAvailability() + dialogRef.value?.showModal() +} + +function close() { + dialogRef.value?.close() +} + +defineExpose({ open, close }) // Computed for similar packages with warnings const hasDangerousSimilarPackages = computed(() => { @@ -124,303 +131,258 @@ const previewPackageJson = computed(() => { ...(access && { publishConfig: { access } }), } }) - -const connectorModalOpen = shallowRef(false) diff --git a/app/components/ConnectorModal.vue b/app/components/ConnectorModal.vue index ec5973ac90..16a6d1d703 100644 --- a/app/components/ConnectorModal.vue +++ b/app/components/ConnectorModal.vue @@ -1,6 +1,4 @@ diff --git a/app/components/HeaderAccountMenu.client.vue b/app/components/HeaderAccountMenu.client.vue index 4a2ae3bc13..85bd64ad25 100644 --- a/app/components/HeaderAccountMenu.client.vue +++ b/app/components/HeaderAccountMenu.client.vue @@ -1,4 +1,6 @@ @@ -118,7 +126,7 @@ function openAuthModal() { leave-to-class="opacity-0 translate-y-1" > - - - - diff --git a/app/components/Modal.client.vue b/app/components/Modal.client.vue new file mode 100644 index 0000000000..0791d3aa2b --- /dev/null +++ b/app/components/Modal.client.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/app/components/PackageDownloadAnalytics.vue b/app/components/PackageDownloadAnalytics.vue index d4a23e872d..2ae1dc2566 100644 --- a/app/components/PackageDownloadAnalytics.vue +++ b/app/components/PackageDownloadAnalytics.vue @@ -5,12 +5,7 @@ import { useDebounceFn, useElementSize } from '@vueuse/core' import { useCssVariables } from '../composables/useColors' import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '../utils/colors' -const { - weeklyDownloads, - inModal = false, - packageName, - createdIso, -} = defineProps<{ +const props = defineProps<{ weeklyDownloads: WeeklyDownloadPoint[] inModal?: boolean packageName: string @@ -131,7 +126,7 @@ function formatXyDataset( return { dataset: [ { - name: packageName, + name: props.packageName, type: 'line', series: dataset.map(d => d.downloads), color: accent.value, @@ -149,7 +144,7 @@ function formatXyDataset( return { dataset: [ { - name: packageName, + name: props.packageName, type: 'line', series: dataset.map(d => d.downloads), color: accent.value, @@ -162,7 +157,7 @@ function formatXyDataset( return { dataset: [ { - name: packageName, + name: props.packageName, type: 'line', series: dataset.map(d => d.downloads), color: accent.value, @@ -175,7 +170,7 @@ function formatXyDataset( return { dataset: [ { - name: packageName, + name: props.packageName, type: 'line', series: dataset.map(d => d.downloads), color: accent.value, @@ -235,10 +230,10 @@ const hasUserEditedDates = shallowRef(false) function initDateRangeFromWeekly() { if (hasUserEditedDates.value) return - if (!weeklyDownloads?.length) return + if (!props.weeklyDownloads?.length) return - const first = weeklyDownloads[0] - const last = weeklyDownloads[weeklyDownloads.length - 1] + const first = props.weeklyDownloads[0] + const last = props.weeklyDownloads[props.weeklyDownloads.length - 1] const start = first?.weekStart ? toIsoDateOnly(first.weekStart) : '' const end = last?.weekEnd ? toIsoDateOnly(last.weekEnd) : '' if (isValidIsoDateOnly(start)) startDate.value = start @@ -265,7 +260,7 @@ function initDateRangeFallbackClient() { } watch( - () => weeklyDownloads?.length, + () => props.weeklyDownloads?.length, () => { initDateRangeFromWeekly() initDateRangeFallbackClient() @@ -342,7 +337,7 @@ watch( const { fetchPackageDownloadEvolution } = useCharts() -const evolution = shallowRef(weeklyDownloads) +const evolution = shallowRef(props.weeklyDownloads) const pending = shallowRef(false) let lastRequestKey = '' @@ -354,7 +349,7 @@ const debouncedLoad = useDebounceFn(() => { async function load() { if (!import.meta.client) return - if (!inModal) return + if (!props.inModal) return const o = options.value const extraBase = @@ -366,14 +361,14 @@ async function load() { const startKey = (o as any).startDate ?? '' const endKey = (o as any).endDate ?? '' - const requestKey = `${packageName}|${createdIso ?? ''}|${o.granularity}|${extraBase}|${startKey}|${endKey}` + const requestKey = `${props.packageName}|${props.createdIso ?? ''}|${o.granularity}|${extraBase}|${startKey}|${endKey}` if (requestKey === lastRequestKey) return lastRequestKey = requestKey const hasExplicitRange = Boolean((o as any).startDate || (o as any).endDate) - if (o.granularity === 'week' && weeklyDownloads?.length && !hasExplicitRange) { - evolution.value = weeklyDownloads + if (o.granularity === 'week' && props.weeklyDownloads?.length && !hasExplicitRange) { + evolution.value = props.weeklyDownloads pending.value = false displayedGranularity.value = 'weekly' return @@ -384,8 +379,8 @@ async function load() { try { const result = await fetchPackageDownloadEvolution( - () => packageName, - () => createdIso, + () => props.packageName, + () => props.createdIso, () => o as any, // FIXME: any ) @@ -404,7 +399,7 @@ async function load() { } watch( - () => inModal, + () => props.inModal, () => { // modal open/close should be immediate load() @@ -414,8 +409,8 @@ watch( watch( () => [ - packageName, - createdIso, + props.packageName, + props.createdIso, options.value.granularity, (options.value as any).weeks, (options.value as any).months, @@ -437,9 +432,9 @@ watch( ) const effectiveData = computed(() => { - if (displayedGranularity.value === 'weekly' && weeklyDownloads?.length) { + if (displayedGranularity.value === 'weekly' && props.weeklyDownloads?.length) { if (isWeeklyDataset(evolution.value) && evolution.value.length) return evolution.value - return weeklyDownloads + return props.weeklyDownloads } return evolution.value }) @@ -481,7 +476,7 @@ const config = computed(() => { img: ({ imageUri }: { imageUri: string }) => { loadFile( imageUri, - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, ) }, csv: (csvStr: string) => { @@ -502,7 +497,7 @@ const config = computed(() => { const url = URL.createObjectURL(blob) loadFile( url, - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, ) URL.revokeObjectURL(url) }, @@ -510,7 +505,7 @@ const config = computed(() => { const url = URL.createObjectURL(blob) loadFile( url, - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, + `${props.packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, ) URL.revokeObjectURL(url) }, @@ -525,7 +520,7 @@ const config = computed(() => { yLabel: $t('package.downloads.y_axis_label', { granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), }), - xLabel: packageName, + xLabel: props.packageName, yLabelOffsetX: 12, fontSize: isMobile.value ? 32 : 24, }, diff --git a/app/components/PackageWeeklyDownloadStats.vue b/app/components/PackageWeeklyDownloadStats.vue index e7dd4eed4b..46ee35022f 100644 --- a/app/components/PackageWeeklyDownloadStats.vue +++ b/app/components/PackageWeeklyDownloadStats.vue @@ -3,13 +3,20 @@ import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' import { useCssVariables } from '../composables/useColors' import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '../utils/colors' -const { packageName } = defineProps<{ +const props = defineProps<{ packageName: string }>() -const showModal = shallowRef(false) +const chartModal = useModal('chart-modal') -const { data: packument } = usePackage(() => packageName) +const isChartModalOpen = shallowRef(false) +function openChartModal() { + isChartModalOpen.value = true + // ensure the component renders before opening the dialog + nextTick(() => chartModal.open()) +} + +const { data: packument } = usePackage(() => props.packageName) const createdIso = computed(() => packument.value?.time?.created ?? null) const { fetchPackageDownloadEvolution } = useCharts() @@ -84,7 +91,7 @@ async function loadWeeklyDownloads() { try { const result = await fetchPackageDownloadEvolution( - () => packageName, + () => props.packageName, () => createdIso.value, () => ({ granularity: 'week' as const, weeks: 52 }), ) @@ -99,7 +106,7 @@ onMounted(() => { }) watch( - () => packageName, + () => props.packageName, () => loadWeeklyDownloads(), ) @@ -194,7 +201,7 @@ const config = computed(() => { diff --git a/app/composables/useModal.ts b/app/composables/useModal.ts new file mode 100644 index 0000000000..551471ef07 --- /dev/null +++ b/app/composables/useModal.ts @@ -0,0 +1,24 @@ +export function useModal(modalId: string) { + const getModal = () => document.querySelector(`#${modalId}`) + + function open() { + const modal = getModal() + if (modal) { + setTimeout(() => { + modal.showModal() + }) + } + } + + function close() { + const modal = getModal() + if (modal) { + modal.close() + } + } + + return { + open, + close, + } +} diff --git a/app/pages/search.vue b/app/pages/search.vue index 0733c43a6b..c249381fcf 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -287,8 +287,7 @@ const showClaimPrompt = computed(() => { ) }) -// Modal state for claiming a package -const claimModalOpen = shallowRef(false) +const claimPackageModalRef = useTemplateRef('claimPackageModalRef') /** * Check if a string is a valid npm username/org name @@ -626,7 +625,7 @@ defineOgImageComponent('Default', { @@ -717,7 +716,7 @@ defineOgImageComponent('Default', { @@ -763,6 +762,6 @@ defineOgImageComponent('Default', { - + diff --git a/app/pages/settings.vue b/app/pages/settings.vue index fe151bdbf9..fa5de26776 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -10,7 +10,10 @@ onKeyStroke( 'Escape', e => { const target = e.target as HTMLElement - if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName)) { + if ( + !['INPUT', 'SELECT', 'TEXTAREA'].includes(target?.tagName) && + !document.documentElement.matches('html:has(:modal)') + ) { e.preventDefault() router.back() }