Skip to content

Commit fb0e360

Browse files
committed
fix: remove custom tooltip and simplify
- replace custom tooltip with reuse of TooltipApp - add ability to TooltipApp to accept an interactive slot, so that we can render a link inside the tooltip - refactor various bits for simplicity and clarity - make the James stuff a little bit less Jamesy (sorry, James) - Simplify special "No dep" column implementation. Trying to pretend it was a real package made things more complicated than necessary - Properly encode package names in fetch requests to handle scoped packages - Make the typahead Easter egg more difficult to trigger by accident
1 parent 04baba9 commit fb0e360

15 files changed

Lines changed: 274 additions & 337 deletions

File tree

app/components/Compare/ComparisonGrid.vue

Lines changed: 75 additions & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -1,265 +1,107 @@
11
<script setup lang="ts">
22
import type { ModuleReplacement } from 'module-replacements'
33
4-
const props = defineProps<{
5-
/** Number of columns (2-4) */
6-
columns: number
7-
/** Column headers (package names or version numbers) */
8-
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)[]
13-
}>()
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-
}
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
699
}
7010
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-
}
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
16+
}>()
8417
85-
function hideTooltip() {
86-
hideTimeout.value = setTimeout(() => {
87-
activeTooltip.value = null
88-
}, 150)
89-
}
18+
/** Total column count including the optional no-dep column */
19+
const totalColumns = computed(() => props.columns.length + (props.showNoDependency ? 1 : 0))
9020
91-
function onTooltipEnter() {
92-
cancelHide()
93-
}
21+
/** Compute plain-text tooltip for a replacement column */
22+
function getReplacementTooltip(col: ComparisonGridColumn): string {
23+
if (!col.replacement) return ''
9424
95-
function onTooltipLeave() {
96-
activeTooltip.value = null
25+
return [$t('package.replacement.title'), $t('package.replacement.learn_more_above')].join(' ')
9726
}
9827
</script>
9928

10029
<template>
10130
<div class="overflow-x-auto">
10231
<div
10332
class="comparison-grid"
104-
:class="[columns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${columns}`]"
105-
:style="{ '--columns': columns }"
33+
:class="[totalColumns === 4 ? 'min-w-[800px]' : 'min-w-[600px]', `columns-${totalColumns}`]"
34+
:style="{ '--columns': totalColumns }"
10635
>
10736
<!-- Header row -->
10837
<div class="comparison-header">
10938
<div class="comparison-label" />
39+
40+
<!-- Package columns -->
11041
<div
111-
v-for="(header, index) in headers"
112-
:key="index"
42+
v-for="col in columns"
43+
:key="col.header"
11344
class="comparison-cell comparison-cell-header"
114-
:class="{ 'comparison-cell-special': specialColumns?.[index] }"
11545
>
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>
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>
14963

150-
<!-- Regular package header (no replacement) -->
151-
<NuxtLink
152-
v-else
153-
:to="`/package/${header}`"
154-
class="link-subtle font-mono text-sm font-medium text-fg truncate"
155-
:title="header"
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"
15671
>
157-
{{ header }}
158-
</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('package.replacement.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>
15999
</div>
160100
</div>
161101

162102
<!-- Facet rows -->
163103
<slot />
164104
</div>
165-
166-
<!-- Tooltips (teleported to body to avoid overflow clipping) -->
167-
<Teleport to="body">
168-
<Transition
169-
enter-active-class="transition-opacity duration-150"
170-
enter-from-class="opacity-0"
171-
leave-active-class="transition-opacity duration-100"
172-
leave-to-class="opacity-0"
173-
>
174-
<!-- "No dep" tooltip -->
175-
<div
176-
v-if="activeTooltip?.type === 'nodep'"
177-
role="tooltip"
178-
class="fixed z-[100] w-72 p-3 bg-bg-elevated border border-border rounded-lg shadow-lg text-start -translate-x-1/2"
179-
:style="{
180-
top: `${tooltipPosition.top}px`,
181-
left: `${tooltipPosition.left}px`,
182-
}"
183-
@mouseenter="onTooltipEnter"
184-
@mouseleave="onTooltipLeave"
185-
>
186-
<div class="flex gap-3">
187-
<img
188-
src="/43081j.png"
189-
alt="James Garbutt"
190-
class="w-12 h-12 rounded-lg object-cover flex-shrink-0"
191-
/>
192-
<div class="min-w-0">
193-
<p class="text-xs text-accent italic mb-0.5">
194-
{{ $t('compare.no_dependency.james_says') }}
195-
</p>
196-
<p class="text-sm font-medium text-fg mb-1">
197-
{{ $t('package.replacement.title') }}
198-
</p>
199-
<p class="text-xs text-fg-muted">
200-
<i18n-t keypath="compare.no_dependency.tooltip_description" tag="span">
201-
<template #link>
202-
<a
203-
href="https://e18e.dev/docs/replacements/"
204-
target="_blank"
205-
rel="noopener noreferrer"
206-
class="text-accent hover:underline"
207-
>{{ $t('compare.no_dependency.e18e_community') }}</a
208-
>
209-
</template>
210-
</i18n-t>
211-
</p>
212-
</div>
213-
</div>
214-
<!-- Tooltip arrow -->
215-
<div
216-
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"
217-
/>
218-
</div>
219-
220-
<!-- Replacement tooltip -->
221-
<div
222-
v-else-if="activeReplacementMessage"
223-
role="tooltip"
224-
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"
225-
:style="{
226-
top: `${tooltipPosition.top}px`,
227-
left: `${tooltipPosition.left}px`,
228-
}"
229-
@mouseenter="onTooltipEnter"
230-
@mouseleave="onTooltipLeave"
231-
>
232-
<div class="flex items-start gap-2">
233-
<span
234-
class="i-carbon:idea w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5"
235-
aria-hidden="true"
236-
/>
237-
<div class="min-w-0">
238-
<p class="text-sm font-medium text-fg mb-1">
239-
{{ $t('package.replacement.title') }}
240-
</p>
241-
<p class="text-xs text-fg-muted">
242-
<i18n-t :keypath="activeReplacementMessage[0]" tag="span">
243-
<template #replacement>
244-
{{ activeReplacementMessage[1].replacement ?? '' }}
245-
</template>
246-
<template #nodeVersion>
247-
{{ activeReplacementMessage[1].nodeVersion ?? '' }}
248-
</template>
249-
<template #community>
250-
{{ $t('package.replacement.community') }}
251-
</template>
252-
</i18n-t>
253-
</p>
254-
</div>
255-
</div>
256-
<!-- Tooltip arrow -->
257-
<div
258-
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"
259-
/>
260-
</div>
261-
</Transition>
262-
</Teleport>
263105
</div>
264106
</template>
265107

@@ -298,7 +140,7 @@ function onTooltipLeave() {
298140
}
299141
300142
/* "No dep" column styling */
301-
.comparison-header > .comparison-cell-header.comparison-cell-special {
143+
.comparison-header > .comparison-cell-header.comparison-cell-nodep {
302144
background: linear-gradient(
303145
135deg,
304146
var(--color-bg-subtle) 0%,

app/components/Compare/PackageSelector.vue

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,27 @@ const { data: searchData, status } = useNpmSearch(inputValue, { size: 15 })
1919
2020
const isSearching = computed(() => status.value === 'pending')
2121
22-
// Trigger phrases for "What Would James Do?" option
23-
const noDependencyTriggers = ['no dep', 'none', 'vanilla', 'diy', 'zero', 'nothing', '0']
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+
])
2436
2537
// Check if "no dependency" option should show
2638
const showNoDependencyOption = computed(() => {
2739
if (packages.value.includes(NO_DEPENDENCY_ID)) return false
2840
const input = inputValue.value.toLowerCase().trim()
2941
if (!input) return false
30-
return noDependencyTriggers.some(
31-
trigger => trigger.startsWith(input) || input.startsWith(trigger),
32-
)
42+
return EASTER_EGG_TRIGGERS.has(input)
3343
})
3444
3545
// Filter out already selected packages
@@ -188,7 +198,12 @@ function handleBlur() {
188198

189199
<!-- Hint -->
190200
<p class="text-xs text-fg-subtle">
191-
{{ $t('compare.selector.packages_selected', { count: packages.length, max: maxPackages }) }}
201+
{{
202+
$t('compare.selector.packages_selected', {
203+
count: packages.length,
204+
max: maxPackages,
205+
})
206+
}}
192207
<span v-if="packages.length < 2">{{ $t('compare.selector.add_hint') }}</span>
193208
</p>
194209
</div>

0 commit comments

Comments
 (0)