Skip to content

Commit 4cec308

Browse files
authored
feat(ui): add e18e module replacement recommendations to /compare page (#801)
1 parent a8bd2f2 commit 4cec308

File tree

18 files changed

+1110
-100
lines changed

18 files changed

+1110
-100
lines changed

app/components/Compare/ComparisonGrid.vue

Lines changed: 92 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,101 @@
11
<script setup lang="ts">
2-
defineProps<{
3-
/** Number of columns (2-4) */
4-
columns: number
5-
/** Column headers (package names or version numbers) */
6-
headers: string[]
2+
import type { ModuleReplacement } from 'module-replacements'
3+
4+
export interface ComparisonGridColumn {
5+
/** Display text (e.g. "lodash@4.17.21") */
6+
header: string
7+
/** Module replacement data for this package (if available) */
8+
replacement?: ModuleReplacement | null
9+
}
10+
11+
const props = defineProps<{
12+
/** Column definitions for each package being compared */
13+
columns: ComparisonGridColumn[]
14+
/** Whether to show the "no dependency" baseline as the last column */
15+
showNoDependency?: boolean
716
}>()
17+
18+
/** Total column count including the optional no-dep column */
19+
const totalColumns = computed(() => props.columns.length + (props.showNoDependency ? 1 : 0))
20+
21+
/** Compute plain-text tooltip for a replacement column */
22+
function getReplacementTooltip(col: ComparisonGridColumn): string {
23+
if (!col.replacement) return ''
24+
25+
return [$t('package.replacement.title'), $t('package.replacement.learn_more_above')].join(' ')
26+
}
827
</script>
928

1029
<template>
1130
<div class="overflow-x-auto">
1231
<div
1332
class="comparison-grid"
14-
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
15-
:style="{ '--columns': columns }"
33+
:class="[totalColumns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${totalColumns}`]"
34+
:style="{ '--columns': totalColumns }"
1635
>
1736
<!-- Header row -->
1837
<div class="comparison-header">
1938
<div class="comparison-label" />
39+
40+
<!-- Package columns -->
2041
<div
21-
v-for="(header, index) in headers"
22-
:key="index"
42+
v-for="col in columns"
43+
:key="col.header"
2344
class="comparison-cell comparison-cell-header"
2445
>
25-
<NuxtLink
26-
:to="`/package/${header}`"
27-
class="link-subtle font-mono text-sm font-medium text-fg truncate"
28-
:title="header"
46+
<span class="inline-flex items-center gap-1.5 truncate">
47+
<NuxtLink
48+
:to="`/package/${col.header}`"
49+
class="link-subtle font-mono text-sm font-medium text-fg truncate"
50+
:title="col.header"
51+
>
52+
{{ col.header }}
53+
</NuxtLink>
54+
<TooltipApp v-if="col.replacement" :text="getReplacementTooltip(col)" position="bottom">
55+
<span
56+
class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help"
57+
role="img"
58+
:aria-label="$t('package.replacement.title')"
59+
/>
60+
</TooltipApp>
61+
</span>
62+
</div>
63+
64+
<!-- "No dep" column (always last) -->
65+
<div
66+
v-if="showNoDependency"
67+
class="comparison-cell comparison-cell-header comparison-cell-nodep"
68+
>
69+
<span
70+
class="inline-flex items-center gap-1.5 text-sm font-medium text-accent italic truncate"
2971
>
30-
{{ header }}
31-
</NuxtLink>
72+
{{ $t('compare.no_dependency.label') }}
73+
<TooltipApp interactive position="bottom">
74+
<span
75+
class="i-carbon:idea w-3.5 h-3.5 text-amber-500 shrink-0 cursor-help"
76+
role="img"
77+
:aria-label="$t('compare.no_dependency.tooltip_title')"
78+
/>
79+
<template #content>
80+
<p class="text-sm font-medium text-fg mb-1">
81+
{{ $t('compare.no_dependency.tooltip_title') }}
82+
</p>
83+
<p class="text-xs text-fg-muted">
84+
<i18n-t keypath="compare.no_dependency.tooltip_description" tag="span">
85+
<template #link>
86+
<a
87+
href="https://e18e.dev/docs/replacements/"
88+
target="_blank"
89+
rel="noopener noreferrer"
90+
class="text-accent hover:underline"
91+
>{{ $t('compare.no_dependency.e18e_community') }}</a
92+
>
93+
</template>
94+
</i18n-t>
95+
</p>
96+
</template>
97+
</TooltipApp>
98+
</span>
3299
</div>
33100
</div>
34101

@@ -72,6 +139,16 @@ defineProps<{
72139
text-align: center;
73140
}
74141
142+
/* "No dep" column styling */
143+
.comparison-header > .comparison-cell-header.comparison-cell-nodep {
144+
background: linear-gradient(
145+
135deg,
146+
var(--color-bg-subtle) 0%,
147+
color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-subtle)) 100%
148+
);
149+
border-bottom-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
150+
}
151+
75152
/* First header cell rounded top-start */
76153
.comparison-header > .comparison-cell-header:first-of-type {
77154
border-start-start-radius: 0.5rem;

app/components/Compare/PackageSelector.vue

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { NO_DEPENDENCY_ID } from '~/composables/usePackageComparison'
3+
24
const packages = defineModel<string[]>({ required: true })
35
46
const props = defineProps<{
@@ -17,6 +19,29 @@ const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 })
1719
1820
const isSearching = computed(() => status.value === 'pending')
1921
22+
// Trigger strings for "What Would James Do?" typeahead Easter egg
23+
// Intentionally not localized
24+
const EASTER_EGG_TRIGGERS = new Set([
25+
'no dep',
26+
'none',
27+
'vanilla',
28+
'diy',
29+
'zero',
30+
'nothing',
31+
'0',
32+
"don't",
33+
'native',
34+
'use the platform',
35+
])
36+
37+
// Check if "no dependency" option should show in typeahead
38+
const showNoDependencyOption = computed(() => {
39+
if (packages.value.includes(NO_DEPENDENCY_ID)) return false
40+
const input = inputValue.value.toLowerCase().trim()
41+
if (!input) return false
42+
return EASTER_EGG_TRIGGERS.has(input)
43+
})
44+
2045
// Filter out already selected packages
2146
const filteredResults = computed(() => {
2247
if (!searchData.value?.objects) return []
@@ -32,7 +57,16 @@ function addPackage(name: string) {
3257
if (packages.value.length >= maxPackages.value) return
3358
if (packages.value.includes(name)) return
3459
35-
packages.value = [...packages.value, name]
60+
// Keep NO_DEPENDENCY_ID always last
61+
if (name === NO_DEPENDENCY_ID) {
62+
packages.value = [...packages.value, name]
63+
} else if (packages.value.includes(NO_DEPENDENCY_ID)) {
64+
// Insert before the no-dep entry
65+
const withoutNoDep = packages.value.filter(p => p !== NO_DEPENDENCY_ID)
66+
packages.value = [...withoutNoDep, name, NO_DEPENDENCY_ID]
67+
} else {
68+
packages.value = [...packages.value, name]
69+
}
3670
inputValue.value = ''
3771
}
3872
@@ -63,16 +97,28 @@ function handleBlur() {
6397
:key="pkg"
6498
class="inline-flex items-center gap-2 px-3 py-1.5 bg-bg-subtle border border-border rounded-md"
6599
>
100+
<!-- No dependency display -->
101+
<template v-if="pkg === NO_DEPENDENCY_ID">
102+
<span class="text-sm text-accent italic flex items-center gap-1.5">
103+
<span class="i-carbon:clean w-3.5 h-3.5" aria-hidden="true" />
104+
{{ $t('compare.no_dependency.label') }}
105+
</span>
106+
</template>
66107
<NuxtLink
108+
v-else
67109
:to="`/package/${pkg}`"
68110
class="font-mono text-sm text-fg hover:text-accent transition-colors"
69111
>
70112
{{ pkg }}
71113
</NuxtLink>
72114
<button
73115
type="button"
74-
class="text-fg-subtle hover:text-fg transition-colors focus-visible:outline-accent/70 rounded"
75-
:aria-label="$t('compare.selector.remove_package', { package: pkg })"
116+
class="text-fg-subtle hover:text-fg transition-colors rounded"
117+
:aria-label="
118+
$t('compare.selector.remove_package', {
119+
package: pkg === NO_DEPENDENCY_ID ? $t('compare.no_dependency.label') : pkg,
120+
})
121+
"
76122
@click="removePackage(pkg)"
77123
>
78124
<span class="i-carbon:close flex items-center w-3.5 h-3.5" aria-hidden="true" />
@@ -118,17 +164,36 @@ function handleBlur() {
118164
leave-to-class="opacity-0"
119165
>
120166
<div
121-
v-if="isInputFocused && (filteredResults.length > 0 || isSearching)"
167+
v-if="
168+
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
169+
"
122170
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"
123171
>
172+
<!-- No dependency option (easter egg with James) -->
173+
<button
174+
v-if="showNoDependencyOption"
175+
type="button"
176+
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"
177+
:aria-label="$t('compare.no_dependency.add_column')"
178+
@click="addPackage(NO_DEPENDENCY_ID)"
179+
>
180+
<div class="text-sm text-accent italic flex items-center gap-2">
181+
<span class="i-carbon:clean w-4 h-4" aria-hidden="true" />
182+
{{ $t('compare.no_dependency.typeahead_title') }}
183+
</div>
184+
<div class="text-xs text-fg-muted truncate mt-0.5">
185+
{{ $t('compare.no_dependency.typeahead_description') }}
186+
</div>
187+
</button>
188+
124189
<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
125190
{{ $t('compare.selector.searching') }}
126191
</div>
127192
<button
128193
v-for="result in filteredResults"
129194
:key="result.name"
130195
type="button"
131-
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"
196+
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"
132197
@click="addPackage(result.name)"
133198
>
134199
<div class="font-mono text-sm text-fg">{{ result.name }}</div>
@@ -142,7 +207,12 @@ function handleBlur() {
142207

143208
<!-- Hint -->
144209
<p class="text-xs text-fg-subtle">
145-
{{ $t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }}
210+
{{
211+
$t('compare.selector.packages_selected', {
212+
count: packages.length,
213+
max: maxPackages,
214+
})
215+
}}
146216
<span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span>
147217
</p>
148218
</div>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script setup lang="ts">
2+
import type { ModuleReplacement } from 'module-replacements'
3+
4+
const props = defineProps<{
5+
packageName: string
6+
replacement: ModuleReplacement
7+
/** Whether this suggestion should show the "no dep" action (native/simple) or just info (documented) */
8+
variant: 'nodep' | 'info'
9+
/** Whether to show the action button (defaults to true) */
10+
showAction?: boolean
11+
}>()
12+
13+
const emit = defineEmits<{
14+
addNoDep: []
15+
}>()
16+
17+
const docUrl = computed(() => {
18+
if (props.replacement.type !== 'documented' || !props.replacement.docPath) return null
19+
// TODO(serhalp): Once the e18e docs site is complete, link there instead
20+
return `https://github.com/es-tooling/module-replacements/blob/main/docs/modules/${props.replacement.docPath}.md`
21+
})
22+
</script>
23+
24+
<template>
25+
<div
26+
class="flex items-start gap-2 px-3 py-2 rounded-lg text-sm"
27+
:class="
28+
variant === 'nodep'
29+
? 'bg-amber-500/10 border border-amber-600/30 text-amber-700 dark:text-amber-400'
30+
: 'bg-blue-500/10 border border-blue-600/30 text-blue-700 dark:text-blue-400'
31+
"
32+
>
33+
<span
34+
class="w-4 h-4 flex-shrink-0 mt-0.5"
35+
:class="variant === 'nodep' ? 'i-carbon:idea' : 'i-carbon:information'"
36+
/>
37+
<div class="min-w-0 flex-1">
38+
<p class="font-medium">{{ packageName }}: {{ $t('package.replacement.title') }}</p>
39+
<p class="text-xs mt-0.5 opacity-80">
40+
<template v-if="replacement.type === 'native'">
41+
{{
42+
$t('package.replacement.native', {
43+
replacement: replacement.replacement,
44+
nodeVersion: replacement.nodeVersion,
45+
})
46+
}}
47+
</template>
48+
<template v-else-if="replacement.type === 'simple'">
49+
{{
50+
$t('package.replacement.simple', {
51+
replacement: replacement.replacement,
52+
community: $t('package.replacement.community'),
53+
})
54+
}}
55+
</template>
56+
<template v-else-if="replacement.type === 'documented'">
57+
{{
58+
$t('package.replacement.documented', {
59+
community: $t('package.replacement.community'),
60+
})
61+
}}
62+
</template>
63+
</p>
64+
</div>
65+
66+
<!-- No dependency action button -->
67+
<button
68+
v-if="variant === 'nodep' && showAction !== false"
69+
type="button"
70+
class="flex-shrink-0 px-2 py-1 text-xs font-medium bg-amber-500/20 hover:bg-amber-500/30 rounded transition-colors"
71+
:aria-label="$t('compare.no_dependency.add_column')"
72+
@click="emit('addNoDep')"
73+
>
74+
{{ $t('package.replacement.consider_no_dep') }}
75+
</button>
76+
77+
<!-- Info link -->
78+
<a
79+
v-else-if="docUrl"
80+
:href="docUrl"
81+
target="_blank"
82+
rel="noopener noreferrer"
83+
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"
84+
>
85+
{{ $t('package.replacement.learn_more') }}
86+
<span class="i-carbon:launch w-3 h-3" />
87+
</a>
88+
</div>
89+
</template>

app/components/Package/Replacement.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ const props = defineProps<{
55
replacement: ModuleReplacement
66
}>()
77
8-
const message = computed<[string, { replacement?: string; nodeVersion?: string }]>(() => {
8+
const message = computed<
9+
[string, { replacement?: string; nodeVersion?: string; community?: string }]
10+
>(() => {
911
switch (props.replacement.type) {
1012
case 'native':
1113
return [
@@ -20,10 +22,16 @@ const message = computed<[string, { replacement?: string; nodeVersion?: string }
2022
'package.replacement.simple',
2123
{
2224
replacement: props.replacement.replacement,
25+
community: $t('package.replacement.community'),
2326
},
2427
]
2528
case 'documented':
26-
return ['package.replacement.documented', {}]
29+
return [
30+
'package.replacement.documented',
31+
{
32+
community: $t('package.replacement.community'),
33+
},
34+
]
2735
case 'none':
2836
return ['package.replacement.none', {}]
2937
}

0 commit comments

Comments
 (0)