-
Notifications
You must be signed in to change notification settings - Fork 101
feat: add package comparison feature #383
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| 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) | ||
| return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.facet)) | ||
| } | ||
|
|
||
| // 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) | ||
| return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.facet)) | ||
| } | ||
| </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)" | ||
| :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) | ||
| ? '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)" | ||
| > | ||
| <span | ||
| v-if="!info.comingSoon" | ||
| class="w-3 h-3" | ||
| :class="isFacetSelected(facet) ? 'i-carbon-checkmark' : '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> | ||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't use |
||
| :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> | ||
There was a problem hiding this comment.
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