Skip to content
Merged
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
107 changes: 92 additions & 15 deletions app/components/Compare/ComparisonGrid.vue
Original file line number Diff line number Diff line change
@@ -1,34 +1,101 @@
<script setup lang="ts">
defineProps<{
/** Number of columns (2-4) */
columns: number
/** Column headers (package names or version numbers) */
headers: string[]
import type { ModuleReplacement } from 'module-replacements'

export interface ComparisonGridColumn {
/** Display text (e.g. "lodash@4.17.21") */
header: string
/** Module replacement data for this package (if available) */
replacement?: ModuleReplacement | null
}

const props = defineProps<{
/** Column definitions for each package being compared */
columns: ComparisonGridColumn[]
/** Whether to show the "no dependency" baseline as the last column */
showNoDependency?: boolean
}>()

/** Total column count including the optional no-dep column */
const totalColumns = computed(() => props.columns.length + (props.showNoDependency ? 1 : 0))

/** Compute plain-text tooltip for a replacement column */
function getReplacementTooltip(col: ComparisonGridColumn): string {
if (!col.replacement) return ''

return [$t('package.replacement.title'), $t('package.replacement.learn_more_above')].join(' ')
}
</script>

<template>
<div class="overflow-x-auto">
<div
class="comparison-grid"
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
:style="{ '--columns': columns }"
:class="[totalColumns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${totalColumns}`]"
:style="{ '--columns': totalColumns }"
>
<!-- Header row -->
<div class="comparison-header">
<div class="comparison-label" />

<!-- Package columns -->
<div
v-for="(header, index) in headers"
:key="index"
v-for="col in columns"
:key="col.header"
class="comparison-cell comparison-cell-header"
>
<NuxtLink
:to="`/package/${header}`"
class="link-subtle font-mono text-sm font-medium text-fg truncate"
:title="header"
<span class="inline-flex items-center gap-1.5 truncate">
<NuxtLink
:to="`/package/${col.header}`"
class="link-subtle font-mono text-sm font-medium text-fg truncate"
:title="col.header"
>
{{ col.header }}
</NuxtLink>
<TooltipApp v-if="col.replacement" :text="getReplacementTooltip(col)" position="bottom">
<span
class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help"
role="img"
:aria-label="$t('package.replacement.title')"
/>
</TooltipApp>
</span>
</div>

<!-- "No dep" column (always last) -->
<div
v-if="showNoDependency"
class="comparison-cell comparison-cell-header comparison-cell-nodep"
>
<span
class="inline-flex items-center gap-1.5 text-sm font-medium text-accent italic truncate"
>
{{ header }}
</NuxtLink>
{{ $t('compare.no_dependency.label') }}
<TooltipApp interactive position="bottom">
<span
class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help"
role="img"
:aria-label="$t('compare.no_dependency.tooltip_title')"
/>
<template #content>
<p class="text-sm font-medium text-fg mb-1">
{{ $t('compare.no_dependency.tooltip_title') }}
</p>
<p class="text-xs text-fg-muted">
<i18n-t keypath="compare.no_dependency.tooltip_description" tag="span">
<template #link>
<a
href="https://e18e.dev/docs/replacements/"
target="_blank"
rel="noopener noreferrer"
class="text-accent hover:underline"
>{{ $t('compare.no_dependency.e18e_community') }}</a
>
</template>
</i18n-t>
</p>
</template>
</TooltipApp>
</span>
</div>
</div>

Expand Down Expand Up @@ -72,6 +139,16 @@ defineProps<{
text-align: center;
}

/* "No dep" column styling */
.comparison-header > .comparison-cell-header.comparison-cell-nodep {
background: linear-gradient(
135deg,
var(--color-bg-subtle) 0%,
color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-subtle)) 100%
);
border-bottom-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
}

/* First header cell rounded top-start */
.comparison-header > .comparison-cell-header:first-of-type {
border-start-start-radius: 0.5rem;
Expand Down
82 changes: 76 additions & 6 deletions app/components/Compare/PackageSelector.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison'

const packages = defineModel<string[]>({ required: true })

const props = defineProps<{
Expand All @@ -17,6 +19,29 @@ const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 })

const isSearching = computed(() => status.value === 'pending')

// Trigger strings for "What Would James Do?" typeahead Easter egg
// Intentionally not localized
const EASTER_EGG_TRIGGERS = new Set([
'no dep',
'none',
'vanilla',
'diy',
'zero',
'nothing',
'0',
"don't",
'native',
'use the platform',
])

// Check if "no dependency" option should show in typeahead
const showNoDependencyOption = computed(() => {
if (packages.value.includes(NO_DEPENDENCY_ID)) return false
const input = inputValue.value.toLowerCase().trim()
if (!input) return false
return EASTER_EGG_TRIGGERS.has(input)
})

// Filter out already selected packages
const filteredResults = computed(() => {
if (!searchData.value?.objects) return []
Expand All @@ -32,7 +57,16 @@ function addPackage(name: string) {
if (packages.value.length >= maxPackages.value) return
if (packages.value.includes(name)) return

packages.value = [...packages.value, name]
// Keep NO_DEPENDENCY_ID always last
if (name === NO_DEPENDENCY_ID) {
packages.value = [...packages.value, name]
} else if (packages.value.includes(NO_DEPENDENCY_ID)) {
// Insert before the no-dep entry
const withoutNoDep = packages.value.filter(p => p !== NO_DEPENDENCY_ID)
packages.value = [...withoutNoDep, name, NO_DEPENDENCY_ID]
} else {
packages.value = [...packages.value, name]
}
inputValue.value = ''
}

Expand Down Expand Up @@ -63,16 +97,28 @@ function handleBlur() {
:key="pkg"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-bg-subtle border border-border rounded-md"
>
<!-- No dependency display -->
<template v-if="pkg === NO_DEPENDENCY_ID">
<span class="text-sm text-accent italic flex items-center gap-1.5">
<span class="i-carbon:clean w-3.5 h-3.5" aria-hidden="true" />
{{ $t('compare.no_dependency.label') }}
</span>
</template>
<NuxtLink
v-else
:to="`/package/${pkg}`"
class="font-mono text-sm text-fg hover:text-accent transition-colors"
>
{{ pkg }}
</NuxtLink>
<button
type="button"
class="text-fg-subtle hover:text-fg transition-colors focus-visible:outline-accent/70 rounded"
:aria-label="$t('compare.selector.remove_package', { package: pkg })"
class="text-fg-subtle hover:text-fg transition-colors rounded"
:aria-label="
$t('compare.selector.remove_package', {
package: pkg === NO_DEPENDENCY_ID ? $t('compare.no_dependency.label') : pkg,
})
"
@click="removePackage(pkg)"
>
<span class="i-carbon:close flex items-center w-3.5 h-3.5" aria-hidden="true" />
Expand Down Expand Up @@ -118,17 +164,36 @@ function handleBlur() {
leave-to-class="opacity-0"
>
<div
v-if="isInputFocused && (filteredResults.length > 0 || isSearching)"
v-if="
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
"
class="absolute top-full inset-x-0 mt-1 bg-bg-elevated border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto"
>
<!-- No dependency option (easter egg with James) -->
<button
v-if="showNoDependencyOption"
type="button"
class="w-full text-start px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted border-b border-border/50"
:aria-label="$t('compare.no_dependency.add_column')"
@click="addPackage(NO_DEPENDENCY_ID)"
>
<div class="text-sm text-accent italic flex items-center gap-2">
<span class="i-carbon:clean w-4 h-4" aria-hidden="true" />
{{ $t('compare.no_dependency.typeahead_title') }}
</div>
<div class="text-xs text-fg-muted truncate mt-0.5">
{{ $t('compare.no_dependency.typeahead_description') }}
</div>
</button>

<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
{{ $t('compare.selector.searching') }}
</div>
<button
v-for="result in filteredResults"
:key="result.name"
type="button"
class="w-full text-left px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted"
class="w-full text-start px-4 py-2.5 hover:bg-bg-muted transition-colors focus-visible:outline-none focus-visible:bg-bg-muted"
@click="addPackage(result.name)"
>
<div class="font-mono text-sm text-fg">{{ result.name }}</div>
Expand All @@ -142,7 +207,12 @@ function handleBlur() {

<!-- Hint -->
<p class="text-xs text-fg-subtle">
{{ $t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }}
{{
$t('compare.selector.packages_selected', {
count: packages.length,
max: maxPackages,
})
}}
<span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span>
</p>
</div>
Expand Down
89 changes: 89 additions & 0 deletions app/components/Compare/ReplacementSuggestion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { ModuleReplacement } from 'module-replacements'

const props = defineProps<{
packageName: string
replacement: ModuleReplacement
/** Whether this suggestion should show the "no dep" action (native/simple) or just info (documented) */
variant: 'nodep' | 'info'
/** Whether to show the action button (defaults to true) */
showAction?: boolean
}>()

const emit = defineEmits<{
addNoDep: []
}>()

const docUrl = computed(() => {
if (props.replacement.type !== 'documented' || !props.replacement.docPath) return null
// TODO(serhalp): Once the e18e docs site is complete, link there instead
return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${props.replacement.docPath}.md`
})
</script>

<template>
<div
class="flex items-start gap-2 px-3 py-2 rounded-lg text-sm"
:class="
variant === 'nodep'
? 'bg-amber-500/10 border border-amber-600/30 text-amber-700 dark:text-amber-400'
: 'bg-blue-500/10 border border-blue-600/30 text-blue-700 dark:text-blue-400'
"
>
<span
class="w-4 h-4 flex-shrink-0 mt-0.5"
:class="variant === 'nodep' ? 'i-carbon:idea' : 'i-carbon:information'"
/>
<div class="min-w-0 flex-1">
<p class="font-medium">{{ packageName }}: {{ $t('package.replacement.title') }}</p>
<p class="text-xs mt-0.5 opacity-80">
<template v-if="replacement.type === 'native'">
{{
$t('package.replacement.native', {
replacement: replacement.replacement,
nodeVersion: replacement.nodeVersion,
})
}}
</template>
<template v-else-if="replacement.type === 'simple'">
{{
$t('package.replacement.simple', {
replacement: replacement.replacement,
community: $t('package.replacement.community'),
})
}}
</template>
<template v-else-if="replacement.type === 'documented'">
{{
$t('package.replacement.documented', {
community: $t('package.replacement.community'),
})
}}
</template>
</p>
</div>

<!-- No dependency action button -->
<button
v-if="variant === 'nodep' && showAction !== false"
type="button"
class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-amber-500/20 hover:bg-amber-500/30 rounded transition-colors"
:aria-label="$t('compare.no_dependency.add_column')"
@click="emit('addNoDep')"
>
{{ $t('package.replacement.consider_no_dep') }}
</button>

<!-- Info link -->
<a
v-else-if="docUrl"
:href="docUrl"
target="_blank"
rel="noopener noreferrer"
class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-blue-500/20 hover:bg-blue-500/30 rounded transition-colors inline-flex items-center gap-1"
>
{{ $t('package.replacement.learn_more') }}
<span class="i-carbon:launch w-3 h-3" />
</a>
</div>
</template>
12 changes: 10 additions & 2 deletions app/components/Package/Replacement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ const props = defineProps<{
replacement: ModuleReplacement
}>()

const message = computed<[string, { replacement?: string; nodeVersion?: string }]>(() => {
const message = computed<
[string, { replacement?: string; nodeVersion?: string; community?: string }]
>(() => {
switch (props.replacement.type) {
case 'native':
return [
Expand All @@ -20,10 +22,16 @@ const message = computed<[string, { replacement?: string; nodeVersion?: string }
'package.replacement.simple',
{
replacement: props.replacement.replacement,
community: $t('package.replacement.community'),
},
]
case 'documented':
return ['package.replacement.documented', {}]
return [
'package.replacement.documented',
{
community: $t('package.replacement.community'),
},
]
case 'none':
return ['package.replacement.none', {}]
}
Expand Down
Loading
Loading