Skip to content

Commit 492cc30

Browse files
committed
feat: suggest package comparisons based on module-replacements
and add somewhat of an Easter egg for manual "no dependency" comparisons when typing certain trigger queries like "none" or "no dep" or "diy".
1 parent ed6bf6f commit 492cc30

11 files changed

Lines changed: 1009 additions & 23 deletions

File tree

app/components/compare/ComparisonGrid.vue

Lines changed: 236 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,100 @@
11
<script setup lang="ts">
2-
defineProps<{
2+
import type { ModuleReplacement } from 'module-replacements'
3+
4+
const props = defineProps<{
35
/** Number of columns (2-4) */
46
columns: number
57
/** Column headers (package names or version numbers) */
68
headers: string[]
9+
/** Which columns are the "no dependency" column */
10+
specialColumns?: boolean[]
11+
/** Replacement data for each column (if available) */
12+
replacements?: (ModuleReplacement | null)[]
713
}>()
14+
15+
// Tooltip state
16+
const activeTooltip = shallowRef<{
17+
type: 'nodep' | 'replacement'
18+
index: number
19+
} | null>(null)
20+
21+
// Computed message for active replacement tooltip
22+
const activeReplacementMessage = computed<
23+
[string, { replacement?: string; nodeVersion?: string }] | null
24+
>(() => {
25+
if (activeTooltip.value?.type !== 'replacement') return null
26+
const replacement = props.replacements?.[activeTooltip.value.index]
27+
if (!replacement) return null
28+
29+
switch (replacement.type) {
30+
case 'native':
31+
return [
32+
'package.replacement.native',
33+
{
34+
replacement: replacement.replacement,
35+
nodeVersion: replacement.nodeVersion,
36+
},
37+
]
38+
case 'simple':
39+
return [
40+
'package.replacement.simple',
41+
{
42+
replacement: replacement.replacement,
43+
},
44+
]
45+
case 'documented':
46+
return ['package.replacement.documented', {}]
47+
default:
48+
return null
49+
}
50+
})
51+
const tooltipTrigger = shallowRef<HTMLElement | null>(null)
52+
const tooltipPosition = shallowRef({ top: 0, left: 0 })
53+
const hideTimeout = shallowRef<ReturnType<typeof setTimeout> | null>(null)
54+
55+
function updateTooltipPosition() {
56+
if (!tooltipTrigger.value) return
57+
const rect = tooltipTrigger.value.getBoundingClientRect()
58+
tooltipPosition.value = {
59+
top: rect.bottom + 8,
60+
left: rect.left + rect.width / 2,
61+
}
62+
}
63+
64+
function cancelHide() {
65+
if (hideTimeout.value) {
66+
clearTimeout(hideTimeout.value)
67+
hideTimeout.value = null
68+
}
69+
}
70+
71+
function showNoDep(event: MouseEvent | FocusEvent) {
72+
cancelHide()
73+
tooltipTrigger.value = event.currentTarget as HTMLElement
74+
updateTooltipPosition()
75+
activeTooltip.value = { type: 'nodep', index: -1 }
76+
}
77+
78+
function showReplacement(event: MouseEvent | FocusEvent, index: number) {
79+
cancelHide()
80+
tooltipTrigger.value = event.currentTarget as HTMLElement
81+
updateTooltipPosition()
82+
activeTooltip.value = { type: 'replacement', index }
83+
}
84+
85+
function hideTooltip() {
86+
hideTimeout.value = setTimeout(() => {
87+
activeTooltip.value = null
88+
}, 150)
89+
}
90+
91+
function onTooltipEnter() {
92+
cancelHide()
93+
}
94+
95+
function onTooltipLeave() {
96+
activeTooltip.value = null
97+
}
898
</script>
999

10100
<template>
@@ -21,8 +111,44 @@ defineProps<{
21111
v-for="(header, index) in headers"
22112
:key="index"
23113
class="comparison-cell comparison-cell-header"
114+
:class="{ 'comparison-cell-special': specialColumns?.[index] }"
24115
>
25-
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
116+
<!-- "No dep" header with tooltip (James easter egg) -->
117+
<button
118+
v-if="specialColumns?.[index]"
119+
type="button"
120+
class="inline-flex items-center gap-1.5 cursor-help bg-transparent border-0 p-0"
121+
:aria-label="$t('compare.no_dependency.label')"
122+
@mouseenter="showNoDep"
123+
@mouseleave="hideTooltip"
124+
@focus="showNoDep"
125+
@blur="hideTooltip"
126+
>
127+
<span class="text-sm font-medium text-accent italic truncate">
128+
{{ header }}
129+
</span>
130+
<span class="i-carbon:help w-3 h-3 text-accent/60" aria-hidden="true" />
131+
</button>
132+
133+
<!-- Package header with replacement info -->
134+
<button
135+
v-else-if="replacements?.[index]"
136+
type="button"
137+
class="inline-flex items-center gap-1.5 cursor-help bg-transparent border-0 p-0"
138+
:aria-label="$t('package.replacement.title')"
139+
@mouseenter="showReplacement($event, index)"
140+
@mouseleave="hideTooltip"
141+
@focus="showReplacement($event, index)"
142+
@blur="hideTooltip"
143+
>
144+
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
145+
{{ header }}
146+
</span>
147+
<span class="i-carbon:idea w-3.5 h-3.5 text-amber-500" aria-hidden="true" />
148+
</button>
149+
150+
<!-- Regular package header (no replacement) -->
151+
<span v-else class="font-mono text-sm font-medium text-fg truncate" :title="header">
26152
{{ header }}
27153
</span>
28154
</div>
@@ -31,6 +157,104 @@ defineProps<{
31157
<!-- Facet rows -->
32158
<slot />
33159
</div>
160+
161+
<!-- Tooltips (teleported to body to avoid overflow clipping) -->
162+
<Teleport to="body">
163+
<Transition
164+
enter-active-class="transition-opacity duration-150"
165+
enter-from-class="opacity-0"
166+
leave-active-class="transition-opacity duration-100"
167+
leave-to-class="opacity-0"
168+
>
169+
<!-- "No dep" tooltip -->
170+
<div
171+
v-if="activeTooltip?.type === 'nodep'"
172+
role="tooltip"
173+
class="fixed z-[100] w-72 p-3 bg-bg-elevated border border-border rounded-lg shadow-lg text-start -translate-x-1/2"
174+
:style="{
175+
top: `${tooltipPosition.top}px`,
176+
left: `${tooltipPosition.left}px`,
177+
}"
178+
@mouseenter="onTooltipEnter"
179+
@mouseleave="onTooltipLeave"
180+
>
181+
<div class="flex gap-3">
182+
<img
183+
src="/43081j.png"
184+
alt="James Garbutt"
185+
class="w-12 h-12 rounded-lg object-cover flex-shrink-0"
186+
/>
187+
<div class="min-w-0">
188+
<p class="text-xs text-accent italic mb-0.5">
189+
{{ $t('compare.no_dependency.james_says') }}
190+
</p>
191+
<p class="text-sm font-medium text-fg mb-1">
192+
{{ $t('package.replacement.title') }}
193+
</p>
194+
<p class="text-xs text-fg-muted">
195+
<i18n-t keypath="compare.no_dependency.tooltip_description" tag="span">
196+
<template #link>
197+
<a
198+
href="https://e18e.dev/docs/replacements/"
199+
target="_blank"
200+
rel="noopener noreferrer"
201+
class="text-accent hover:underline"
202+
>{{ $t('compare.no_dependency.e18e_community') }}</a
203+
>
204+
</template>
205+
</i18n-t>
206+
</p>
207+
</div>
208+
</div>
209+
<!-- Tooltip arrow -->
210+
<div
211+
class="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-bg-elevated border-t border-l border-border rotate-45"
212+
/>
213+
</div>
214+
215+
<!-- Replacement tooltip -->
216+
<div
217+
v-else-if="activeReplacementMessage"
218+
role="tooltip"
219+
class="fixed z-[100] w-80 p-3 bg-bg-elevated border border-amber-600/30 rounded-lg shadow-lg text-start -translate-x-1/2"
220+
:style="{
221+
top: `${tooltipPosition.top}px`,
222+
left: `${tooltipPosition.left}px`,
223+
}"
224+
@mouseenter="onTooltipEnter"
225+
@mouseleave="onTooltipLeave"
226+
>
227+
<div class="flex items-start gap-2">
228+
<span
229+
class="i-carbon:idea w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5"
230+
aria-hidden="true"
231+
/>
232+
<div class="min-w-0">
233+
<p class="text-sm font-medium text-fg mb-1">
234+
{{ $t('package.replacement.title') }}
235+
</p>
236+
<p class="text-xs text-fg-muted">
237+
<i18n-t :keypath="activeReplacementMessage[0]" tag="span">
238+
<template #replacement>
239+
{{ activeReplacementMessage[1].replacement ?? '' }}
240+
</template>
241+
<template #nodeVersion>
242+
{{ activeReplacementMessage[1].nodeVersion ?? '' }}
243+
</template>
244+
<template #community>
245+
{{ $t('package.replacement.community') }}
246+
</template>
247+
</i18n-t>
248+
</p>
249+
</div>
250+
</div>
251+
<!-- Tooltip arrow -->
252+
<div
253+
class="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-bg-elevated border-t border-l border-amber-600/30 rotate-45"
254+
/>
255+
</div>
256+
</Transition>
257+
</Teleport>
34258
</div>
35259
</template>
36260

@@ -68,6 +292,16 @@ defineProps<{
68292
text-align: center;
69293
}
70294
295+
/* "No dep" column styling */
296+
.comparison-header > .comparison-cell-header.comparison-cell-special {
297+
background: linear-gradient(
298+
135deg,
299+
var(--color-bg-subtle) 0%,
300+
color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-subtle)) 100%
301+
);
302+
border-bottom-color: color-mix(in srgb, var(--color-accent) 30%, var(--color-border));
303+
}
304+
71305
/* First header cell rounded top-start */
72306
.comparison-header > .comparison-cell-header:first-of-type {
73307
border-start-start-radius: 0.5rem;

app/components/compare/PackageSelector.vue

Lines changed: 49 additions & 3 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,19 @@ const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 })
1719
1820
const isSearching = computed(() => status.value === 'pending')
1921
22+
// Trigger phrases for "What Would James Do?" option
23+
const noDependencyTriggers = ['no dep', 'none', 'vanilla', 'diy', 'zero', 'nothing', '0']
24+
25+
// Check if "no dependency" option should show
26+
const showNoDependencyOption = computed(() => {
27+
if (packages.value.includes(NO_DEPENDENCY_ID)) return false
28+
const input = inputValue.value.toLowerCase().trim()
29+
if (!input) return false
30+
return noDependencyTriggers.some(
31+
trigger => trigger.startsWith(input) || input.startsWith(trigger),
32+
)
33+
})
34+
2035
// Filter out already selected packages
2136
const filteredResults = computed(() => {
2237
if (!searchData.value?.objects) return []
@@ -63,7 +78,15 @@ function handleBlur() {
6378
:key="pkg"
6479
class="inline-flex items-center gap-2 px-3 py-1.5 bg-bg-subtle border border-border rounded-md"
6580
>
81+
<!-- No dependency display -->
82+
<template v-if="pkg === NO_DEPENDENCY_ID">
83+
<span class="text-sm text-accent italic flex items-center gap-1.5">
84+
<span class="i-carbon:clean w-3.5 h-3.5" aria-hidden="true" />
85+
{{ $t('compare.no_dependency.label') }}
86+
</span>
87+
</template>
6688
<NuxtLink
89+
v-else
6790
:to="`/package/${pkg}`"
6891
class="font-mono text-sm text-fg hover:text-accent transition-colors"
6992
>
@@ -72,7 +95,11 @@ function handleBlur() {
7295
<button
7396
type="button"
7497
class="text-fg-subtle hover:text-fg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
75-
:aria-label="$t('compare.selector.remove_package', { package: pkg })"
98+
:aria-label="
99+
$t('compare.selector.remove_package', {
100+
package: pkg === NO_DEPENDENCY_ID ? 'No dependency' : pkg,
101+
})
102+
"
76103
@click="removePackage(pkg)"
77104
>
78105
<span class="i-carbon:close w-3.5 h-3.5" aria-hidden="true" />
@@ -118,17 +145,36 @@ function handleBlur() {
118145
leave-to-class="opacity-0"
119146
>
120147
<div
121-
v-if="isInputFocused && (filteredResults.length > 0 || isSearching)"
148+
v-if="
149+
isInputFocused && (filteredResults.length > 0 || isSearching || showNoDependencyOption)
150+
"
122151
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"
123152
>
153+
<!-- No dependency option (easter egg with James) -->
154+
<button
155+
v-if="showNoDependencyOption"
156+
type="button"
157+
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"
158+
:aria-label="$t('compare.no_dependency.add_column')"
159+
@click="addPackage(NO_DEPENDENCY_ID)"
160+
>
161+
<div class="text-sm text-accent italic flex items-center gap-2">
162+
<span class="i-carbon:clean w-4 h-4" aria-hidden="true" />
163+
{{ $t('compare.no_dependency.typeahead_title') }}
164+
</div>
165+
<div class="text-xs text-fg-muted truncate mt-0.5">
166+
{{ $t('compare.no_dependency.typeahead_description') }}
167+
</div>
168+
</button>
169+
124170
<div v-if="isSearching" class="px-4 py-3 text-sm text-fg-muted">
125171
{{ $t('compare.selector.searching') }}
126172
</div>
127173
<button
128174
v-for="result in filteredResults"
129175
:key="result.name"
130176
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"
177+
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"
132178
@click="addPackage(result.name)"
133179
>
134180
<div class="font-mono text-sm text-fg">{{ result.name }}</div>

0 commit comments

Comments
 (0)