@@ -47,7 +47,11 @@ const gridColumns = computed(() =>
4747 .map ((pkg , i ) => ({ pkg , originalIndex: i }))
4848 .filter (({ pkg }) => pkg !== NO_DEPENDENCY_ID )
4949 .map (({ pkg , originalIndex }) => {
50- const data = packagesData .value ?.[originalIndex ]
50+ // packagesData can be false (not ready) or array with nulls (loading)
51+ // Ensure we handle the boolean case safely for TS
52+ const list = packagesData .value
53+ const data = Array .isArray (list ) ? list [originalIndex ] : null
54+
5155 return {
5256 name: data ?.package .name || pkg ,
5357 version: data ?.package .version ,
@@ -80,10 +84,12 @@ const gridHeaders = computed(() =>
8084)
8185
8286useSeoMeta ({
83- title : () =>
84- packages .value .length > 0
85- ? $t (' compare.packages.meta_title' , { packages: packages .value .join (' vs ' ) })
86- : $t (' compare.packages.meta_title_empty' ),
87+ title : () => {
88+ if (packages .value .length === 0 ) return $t (' compare.packages.meta_title_empty' )
89+ const title = $t (' compare.packages.meta_title' , { packages: packages .value .join (' vs ' ) })
90+ // Avoid long titles (SEO/HTML validation)
91+ return title .length > 60 ? $t (' compare.packages.meta_title_empty' ) : title
92+ },
8793 ogTitle : () =>
8894 packages .value .length > 0
8995 ? $t (' compare.packages.meta_title' , { packages: packages .value .join (' vs ' ) })
@@ -135,31 +141,38 @@ useSeoMeta({
135141 <h2 id =" packages-heading" class =" text-xs text-fg-subtle uppercase tracking-wider mb-3" >
136142 {{ $t('compare.packages.section_packages') }}
137143 </h2 >
138- <ComparePackageSelector v-model =" packages" :max =" 4" />
144+ <ClientOnly >
145+ <ComparePackageSelector v-model =" packages" :max =" 4" />
146+ <template #fallback >
147+ <SkeletonBlock class =" min-h-[42px]" />
148+ </template >
149+ </ClientOnly >
139150
140- <!-- "No dep" replacement suggestions (native, simple) -->
141- <div v-if =" noDepSuggestions.length > 0" class =" mt-3 space-y-2" >
142- <CompareReplacementSuggestion
143- v-for =" suggestion in noDepSuggestions"
144- :key =" suggestion.forPackage"
145- :package-name =" suggestion.forPackage"
146- :replacement =" suggestion.replacement"
147- variant =" nodep"
148- :show-action =" canAddNoDep"
149- @add-no-dep =" addNoDep"
150- />
151- </div >
151+ <ClientOnly >
152+ <!-- "No dep" replacement suggestions (native, simple) -->
153+ <div v-if =" noDepSuggestions.length > 0" class =" mt-3 space-y-2" >
154+ <CompareReplacementSuggestion
155+ v-for =" suggestion in noDepSuggestions"
156+ :key =" suggestion.forPackage"
157+ :package-name =" suggestion.forPackage"
158+ :replacement =" suggestion.replacement"
159+ variant =" nodep"
160+ :show-action =" canAddNoDep"
161+ @add-no-dep =" addNoDep"
162+ />
163+ </div >
152164
153- <!-- Informational replacement suggestions (documented) -->
154- <div v-if =" infoSuggestions.length > 0" class =" mt-3 space-y-2" >
155- <CompareReplacementSuggestion
156- v-for =" suggestion in infoSuggestions"
157- :key =" suggestion.forPackage"
158- :package-name =" suggestion.forPackage"
159- :replacement =" suggestion.replacement"
160- variant =" info"
161- />
162- </div >
165+ <!-- Informational replacement suggestions (documented) -->
166+ <div v-if =" infoSuggestions.length > 0" class =" mt-3 space-y-2" >
167+ <CompareReplacementSuggestion
168+ v-for =" suggestion in infoSuggestions"
169+ :key =" suggestion.forPackage"
170+ :package-name =" suggestion.forPackage"
171+ :replacement =" suggestion.replacement"
172+ variant =" info"
173+ />
174+ </div >
175+ </ClientOnly >
163176 </section >
164177
165178 <!-- Facet selector -->
@@ -168,47 +181,86 @@ useSeoMeta({
168181 <h2 id =" facets-heading" class =" text-xs text-fg-subtle uppercase tracking-wider" >
169182 {{ $t('compare.packages.section_facets') }}
170183 </h2 >
171- <ButtonBase
172- size =" small"
173- :aria-pressed =" isAllSelected"
174- :disabled =" isAllSelected"
175- :aria-label =" $t('compare.facets.select_all')"
176- @click =" selectAll"
177- >
178- {{ $t('compare.facets.all') }}
179- </ButtonBase >
180- <span class =" text-3xs text-fg-muted/40" aria-hidden =" true" >/</span >
181- <ButtonBase
182- size =" small"
183- :aria-pressed =" isNoneSelected"
184- :disabled =" isNoneSelected"
185- :aria-label =" $t('compare.facets.deselect_all')"
186- @click =" deselectAll"
187- >
188- {{ $t('compare.facets.none') }}
189- </ButtonBase >
184+ <ClientOnly >
185+ <div class =" contents" >
186+ <ButtonBase
187+ size =" small"
188+ :aria-pressed =" isAllSelected"
189+ :disabled =" isAllSelected"
190+ :aria-label =" $t('compare.facets.select_all')"
191+ @click =" selectAll"
192+ >
193+ {{ $t('compare.facets.all') }}
194+ </ButtonBase >
195+ <span class =" text-3xs text-fg-muted/40" aria-hidden =" true" >/</span >
196+ <ButtonBase
197+ size =" small"
198+ :aria-pressed =" isNoneSelected"
199+ :disabled =" isNoneSelected"
200+ :aria-label =" $t('compare.facets.deselect_all')"
201+ @click =" deselectAll"
202+ >
203+ {{ $t('compare.facets.none') }}
204+ </ButtonBase >
205+ </div >
206+ <template #fallback >
207+ <div class =" flex gap-2 items-center" >
208+ <SkeletonBlock class =" w-8 h-6" />
209+ <span class =" text-3xs text-fg-muted/40" aria-hidden =" true" >/</span >
210+ <SkeletonBlock class =" w-10 h-6" />
211+ </div >
212+ </template >
213+ </ClientOnly >
190214 </div >
191- <CompareFacetSelector />
215+ <ClientOnly >
216+ <CompareFacetSelector />
217+ <template #fallback >
218+ <div class =" space-y-3" >
219+ <div v-for =" i in 4" :key =" i" >
220+ <div class =" flex items-center gap-2 mb-2" >
221+ <SkeletonBlock class =" w-20 h-3" />
222+ <SkeletonBlock class =" w-8 h-6" />
223+ <span class =" text-2xs text-fg-muted/40" >/</span >
224+ <SkeletonBlock class =" w-10 h-6" />
225+ </div >
226+ <div class =" flex items-center gap-1.5 flex-wrap" >
227+ <SkeletonBlock v-for =" j in 3" :key =" j" class =" w-24 h-6" />
228+ </div >
229+ </div >
230+ </div >
231+ </template >
232+ </ClientOnly >
192233 </section >
193234
194- <!-- Comparison grid -->
195- <section v-if =" canCompare" class =" mt-10" aria-labelledby =" comparison-heading" >
196- <h2 id =" comparison-heading" class =" text-xs text-fg-subtle uppercase tracking-wider mb-4" >
197- {{ $t('compare.packages.section_comparison') }}
198- </h2 >
235+ <ClientOnly >
236+ <!-- Comparison grid -->
237+ <section v-if =" canCompare" class =" mt-10" aria-labelledby =" comparison-heading" >
238+ <h2 id =" comparison-heading" class =" text-xs text-fg-subtle uppercase tracking-wider mb-4" >
239+ {{ $t('compare.packages.section_comparison') }}
240+ </h2 >
199241
200- <div
201- v-if =" status === 'pending' && (!packagesData || packagesData.every(p => p === null))"
202- class =" flex items-center justify-center py-12"
203- >
204- <LoadingSpinner :text =" $t('compare.packages.loading')" />
205- </div >
242+ <!-- Show grid if we have packages, even if loading (skeletons) -->
243+ <div v-if =" packages.length > 0" >
244+ <!-- Desktop: Grid layout -->
245+ <div class =" hidden md:block overflow-x-auto" >
246+ <CompareComparisonGrid :columns =" gridColumns" :show-no-dependency =" showNoDependency" >
247+ <CompareFacetRow
248+ v-for =" facet in selectedFacets"
249+ :key =" facet.id"
250+ :label =" facet.label"
251+ :description =" facet.description"
252+ :values =" getFacetValues(facet.id)"
253+ :facet-loading =" isFacetLoading(facet.id)"
254+ :column-loading =" columnLoading"
255+ :bar =" facet.id !== 'lastUpdated'"
256+ :headers =" gridHeaders"
257+ />
258+ </CompareComparisonGrid >
259+ </div >
206260
207- <div v-else-if =" packagesData && packagesData.some(p => p !== null)" >
208- <!-- Desktop: Grid layout -->
209- <div class =" hidden md:block overflow-x-auto" >
210- <CompareComparisonGrid :columns =" gridColumns" :show-no-dependency =" showNoDependency" >
211- <CompareFacetRow
261+ <!-- Mobile: Card-based layout -->
262+ <div class =" md:hidden space-y-3" >
263+ <CompareFacetCard
212264 v-for =" facet in selectedFacets"
213265 :key =" facet.id"
214266 :label =" facet.label"
@@ -219,52 +271,55 @@ useSeoMeta({
219271 :bar =" facet.id !== 'lastUpdated'"
220272 :headers =" gridHeaders"
221273 />
222- </CompareComparisonGrid >
274+ </div >
275+
276+ <h2
277+ id =" comparison-heading"
278+ class =" text-xs text-fg-subtle uppercase tracking-wider mb-4 mt-10"
279+ >
280+ {{ $t('compare.facets.trends.title') }}
281+ </h2 >
282+
283+ <CompareLineChart :packages =" packages.filter(p => p !== NO_DEPENDENCY_ID)" />
223284 </div >
224285
225- <!-- Mobile: Card-based layout -->
226- <div class =" md:hidden space-y-3" >
227- <CompareFacetCard
228- v-for =" facet in selectedFacets"
229- :key =" facet.id"
230- :label =" facet.label"
231- :description =" facet.description"
232- :values =" getFacetValues(facet.id)"
233- :facet-loading =" isFacetLoading(facet.id)"
234- :column-loading =" columnLoading"
235- :bar =" facet.id !== 'lastUpdated'"
236- :headers =" gridHeaders"
237- />
286+ <div v-else-if =" status === 'error'" class =" text-center py-12" role =" alert" >
287+ <p class =" text-fg-muted" >{{ $t('compare.packages.error') }}</p >
238288 </div >
289+ </section >
239290
240- <h2
241- id =" comparison-heading"
242- class =" text-xs text-fg-subtle uppercase tracking-wider mb-4 mt-10"
243- >
244- {{ $t('compare.facets.trends.title') }}
291+ <!-- Empty state -->
292+ <section
293+ v-else
294+ class =" text-center px-1.5 py-16 border border-dashed border-border-hover rounded-lg"
295+ >
296+ <div class =" i-carbon:compare w-12 h-12 text-fg-subtle mx-auto mb-4" aria-hidden =" true" />
297+ <h2 class =" font-mono text-lg text-fg-muted mb-2" >
298+ {{ $t('compare.packages.empty_title') }}
245299 </h2 >
300+ <p class =" text-sm text-fg-subtle max-w-md mx-auto" >
301+ {{ $t('compare.packages.empty_description') }}
302+ </p >
303+ </section >
246304
247- <CompareLineChart :packages =" packages.filter(p => p !== NO_DEPENDENCY_ID)" />
248- </div >
249-
250- <div v-else class =" text-center py-12" role =" alert" >
251- <p class =" text-fg-muted" >{{ $t('compare.packages.error') }}</p >
252- </div >
253- </section >
254-
255- <!-- Empty state -->
256- <section
257- v-else
258- class =" text-center px-1.5 py-16 border border-dashed border-border-hover rounded-lg"
259- >
260- <div class =" i-carbon:compare w-12 h-12 text-fg-subtle mx-auto mb-4" aria-hidden =" true" />
261- <h2 class =" font-mono text-lg text-fg-muted mb-2" >
262- {{ $t('compare.packages.empty_title') }}
263- </h2 >
264- <p class =" text-sm text-fg-subtle max-w-md mx-auto" >
265- {{ $t('compare.packages.empty_description') }}
266- </p >
267- </section >
305+ <template #fallback >
306+ <!-- Generic loading state for the whole grid section if needed, or rely on inner skeletons -->
307+ <div class =" mt-10 space-y-4" >
308+ <SkeletonBlock class =" w-32 h-4 mb-4" />
309+ <div class =" border border-border rounded-lg p-4 space-y-4" >
310+ <div class =" flex gap-4" >
311+ <SkeletonBlock class =" w-1/4 h-8" />
312+ <SkeletonBlock class =" w-1/4 h-8" />
313+ <SkeletonBlock class =" w-1/4 h-8" />
314+ <SkeletonBlock class =" w-1/4 h-8" />
315+ </div >
316+ <div v-for =" i in 5" :key =" i" class =" flex gap-4" >
317+ <SkeletonBlock class =" w-full h-12" />
318+ </div >
319+ </div >
320+ </div >
321+ </template >
322+ </ClientOnly >
268323 </div >
269324 </main >
270325</template >
0 commit comments