Skip to content
Draft
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
8 changes: 8 additions & 0 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ onKeyStroke(',', e => {
:class="{ 'hidden sm:flex': showFullSearch }"
class="flex-shrink-0 flex items-center gap-4 sm:gap-6 ms-auto sm:ms-0"
>
<NuxtLink
to="/compare"
class="hidden sm:inline-flex link-subtle font-mono text-sm items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
>
<span class="i-carbon-compare w-4 h-4" aria-hidden="true" />
{{ $t('nav.compare') }}
</NuxtLink>

<NuxtLink
to="/about"
class="sm:hidden link-subtle font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 rounded"
Expand Down
80 changes: 80 additions & 0 deletions app/components/compare/ComparisonGrid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<script setup lang="ts">
defineProps<{
/** Number of columns (2-4) */
columns: number
/** Column headers (package names or version numbers) */
headers: string[]
}>()
</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 }"
>
<!-- Header row -->
<div class="comparison-header">
<div class="comparison-label" />
<div
v-for="(header, index) in headers"
:key="index"
class="comparison-cell comparison-cell-header"
>
<span class="font-mono text-sm font-medium text-fg truncate" :title="header">
{{ header }}
</span>
</div>
</div>

<!-- Metric rows -->
<slot />
</div>
</div>
</template>

<style scoped>
.comparison-grid {
display: grid;
gap: 0;
}

.comparison-grid.columns-2 {
grid-template-columns: minmax(120px, 180px) repeat(2, 1fr);
}

.comparison-grid.columns-3 {
grid-template-columns: minmax(120px, 160px) repeat(3, 1fr);
}

.comparison-grid.columns-4 {
grid-template-columns: minmax(100px, 140px) repeat(4, 1fr);
}

.comparison-header {
display: contents;
}

.comparison-header > .comparison-label {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
}

.comparison-header > .comparison-cell-header {
padding: 0.75rem 1rem;
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
text-align: center;
}

/* First header cell rounded top-left */
.comparison-header > .comparison-cell-header:first-of-type {
border-top-left-radius: 0.5rem;
}

/* Last header cell rounded top-right */
.comparison-header > .comparison-cell-header:last-of-type {
border-top-right-radius: 0.5rem;
}
</style>
128 changes: 128 additions & 0 deletions app/components/compare/FacetSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { FACET_INFO, FACETS_BY_CATEGORY, CATEGORY_ORDER } from '#shared/types/comparison'

const { t } = useI18n()

const {
isFacetSelected,
toggleFacet,
selectCategory,
deselectCategory,
selectAll,
deselectAll,
isAllSelected,
isNoneSelected,
} = useFacetSelection()

// Enrich facets with their info for rendering
const facetsByCategory = computed(() => {
const result: Record<
string,
{ facet: string; info: (typeof FACET_INFO)[keyof typeof FACET_INFO] }[]
> = {}
for (const category of CATEGORY_ORDER) {
result[category] = FACETS_BY_CATEGORY[category].map(facet => ({
facet,
info: FACET_INFO[facet],
}))
}
return result
})

// Check if all non-comingSoon facets in a category are selected
function isCategoryAllSelected(category: string): boolean {
const facets = facetsByCategory.value[category]
const selectableFacets = facets.filter(f => !f.info.comingSoon)

Check failure on line 35 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

'facets' is possibly 'undefined'.
return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet))

Check failure on line 36 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
}

// Check if no facets in a category are selected
function isCategoryNoneSelected(category: string): boolean {
const facets = facetsByCategory.value[category]
const selectableFacets = facets.filter(f => !f.info.comingSoon)

Check failure on line 42 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

'facets' is possibly 'undefined'.
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet))

Check failure on line 43 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
}
</script>

<template>
<div class="space-y-3" role="group" :aria-label="t('compare.facets.group_label')">
<div v-for="category in CATEGORY_ORDER" :key="category">
<!-- Category header with all/none buttons -->
<div class="flex items-center gap-2 mb-2">
<span class="text-[10px] text-fg-subtle uppercase tracking-wider">
{{ t(`compare.facets.categories.${category}`) }}
</span>
<button
type="button"
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
:class="
isCategoryAllSelected(category)
? 'text-fg-muted'
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
t('compare.facets.select_category', {
category: t(`compare.facets.categories.${category}`),
})
"
:disabled="isCategoryAllSelected(category)"
@click="selectCategory(category)"
>
{{ t('compare.facets.all') }}
</button>
<span class="text-[10px] text-fg-muted/40">/</span>
<button
type="button"
class="text-[10px] transition-colors focus-visible:outline-none focus-visible:underline"
:class="
isCategoryNoneSelected(category)
? 'text-fg-muted'
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
t('compare.facets.deselect_category', {
category: t(`compare.facets.categories.${category}`),
})
"
:disabled="isCategoryNoneSelected(category)"
@click="deselectCategory(category)"
>
{{ t('compare.facets.none') }}
</button>
</div>

<!-- Facet buttons -->
<div class="flex items-center gap-1.5 flex-wrap" role="group">
<button
v-for="{ facet, info } in facetsByCategory[category]"
:key="facet"
type="button"
:title="info.comingSoon ? t('compare.facets.coming_soon') : info.description"
:disabled="info.comingSoon"
:aria-pressed="isFacetSelected(facet)"

Check failure on line 102 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
:aria-label="info.label"
class="inline-flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
:class="
info.comingSoon
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
: isFacetSelected(facet)

Check failure on line 108 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
? 'text-fg-muted bg-bg-muted border-border'
: 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border'
"
@click="!info.comingSoon && toggleFacet(facet)"

Check failure on line 112 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
>
<span
v-if="!info.comingSoon"
class="w-3 h-3"
:class="isFacetSelected(facet) ? 'i-carbon-checkmark' : 'i-carbon-add'"

Check failure on line 117 in app/components/compare/FacetSelector.vue

View workflow job for this annotation

GitHub Actions / test

Argument of type 'string' is not assignable to parameter of type 'ComparisonFacet'.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you use : as separator in the icons (small perf at UnoCSS) => i-carbon:add

aria-hidden="true"
/>
{{ info.label }}
<span v-if="info.comingSoon" class="text-[9px]"
>({{ t('compare.facets.coming_soon') }})</span
>
</button>
</div>
</div>
</div>
</template>
145 changes: 145 additions & 0 deletions app/components/compare/MetricRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { MetricValue, DiffResult } from '#shared/types'

const props = defineProps<{
/** Metric label */
label: string
/** Description/tooltip for the metric */
description?: string
/** Values for each column */
values: (MetricValue | null | undefined)[]
/** Diff results between adjacent columns (for release comparison) */
diffs?: (DiffResult | null | undefined)[]
/** Whether this row is loading */
loading?: boolean
/** Whether to show the proportional bar (defaults to true for numeric values) */
bar?: boolean
}>()

// Check if all values are numeric (for bar visualization)
const isNumeric = computed(() => {
return props.values.every(v => v === null || v === undefined || typeof v.raw === 'number')
})

// Show bar if explicitly enabled, or if not specified and values are numeric
const showBar = computed(() => {
return props.bar ?? isNumeric.value
})

// Get max value for bar width calculation
const maxValue = computed(() => {
if (!isNumeric.value) return 0
return Math.max(...props.values.map(v => (typeof v?.raw === 'number' ? v.raw : 0)))
})

// Calculate bar width percentage for a value
function getBarWidth(value: MetricValue | null | undefined): number {
if (!isNumeric.value || !maxValue.value || !value || typeof value.raw !== 'number') return 0
return (value.raw / maxValue.value) * 100
}

function getStatusClass(status?: MetricValue['status']): string {
switch (status) {
case 'good':
return 'text-emerald-400'
case 'info':
return 'text-blue-400'
case 'warning':
return 'text-amber-400'
case 'bad':
return 'text-red-400'
default:
return 'text-fg'
}
}

function getDiffClass(diff?: DiffResult | null): string {
if (!diff) return ''
if (diff.favorable === true) return 'text-emerald-400'
if (diff.favorable === false) return 'text-red-400'
return 'text-fg-muted'
}

function getDiffIcon(diff?: DiffResult | null): string {
if (!diff) return ''
switch (diff.direction) {
case 'increase':
return 'i-carbon-arrow-up'
case 'decrease':
return 'i-carbon-arrow-down'
case 'changed':
return 'i-carbon-arrows-horizontal'
default:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 icons here

return ''
}
}
</script>

<template>
<div class="contents">
<!-- Label cell -->
<div
class="comparison-label flex items-center gap-1.5 px-4 py-3 border-b border-border"
:title="description"
>
<span class="text-xs text-fg-muted uppercase tracking-wider">{{ label }}</span>
<span
v-if="description"
class="i-carbon-information w-3 h-3 text-fg-subtle"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

icon

aria-hidden="true"
/>
</div>

<!-- Value cells -->
<div
v-for="(value, index) in values"
:key="index"
class="comparison-cell relative flex flex-col items-end justify-center gap-1 px-4 py-3 border-b border-border"
>
<!-- Background bar for numeric values -->
<div
v-if="showBar && value && getBarWidth(value) > 0"
class="absolute inset-y-1 left-1 bg-fg/5 rounded-sm transition-all duration-300"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use left/right use inset-is and inset-ie respetivelly: check the contributing guide, RTL support => https://github.com/npmx-dev/npmx.dev/blob/main/CONTRIBUTING.md#rtl-support

:style="{ width: `calc(${getBarWidth(value)}% - 8px)` }"
aria-hidden="true"
/>

<!-- Loading state -->
<template v-if="loading">
<span
class="i-carbon-circle-dash w-4 h-4 text-fg-subtle motion-safe:animate-spin"
aria-hidden="true"
/>
</template>

<!-- No data -->
<template v-else-if="!value">
<span class="text-fg-subtle text-sm">-</span>
</template>

<!-- Value display -->
<template v-else>
<span class="relative font-mono text-sm tabular-nums" :class="getStatusClass(value.status)">
<!-- Date values use DateTime component for i18n and user settings -->
<DateTime v-if="value.type === 'date'" :datetime="value.display" date-style="medium" />
<template v-else>{{ value.display }}</template>
</span>

<!-- Diff indicator (if provided) -->
<div
v-if="diffs && diffs[index] && diffs[index]?.direction !== 'same'"
class="relative flex items-center gap-1 text-xs tabular-nums"
:class="getDiffClass(diffs[index])"
>
<span
v-if="getDiffIcon(diffs[index])"
class="w-3 h-3"
:class="getDiffIcon(diffs[index])"
aria-hidden="true"
/>
<span>{{ diffs[index]?.display }}</span>
</div>
</template>
</div>
</div>
</template>
Loading
Loading