Skip to content
Merged
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
28 changes: 14 additions & 14 deletions app/components/Filter/Panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import type {
} from '#shared/types/preferences'
import {
DOWNLOAD_RANGES,
SEARCH_SCOPE_OPTIONS,
SECURITY_FILTER_OPTIONS,
SEARCH_SCOPE_VALUES,
SECURITY_FILTER_VALUES,
UPDATED_WITHIN_OPTIONS,
} from '#shared/types/preferences'

Expand Down Expand Up @@ -205,20 +205,20 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
:aria-label="$t('filters.search_scope')"
>
<button
v-for="option in SEARCH_SCOPE_OPTIONS"
:key="option.value"
v-for="scope in SEARCH_SCOPE_VALUES"
:key="scope"
type="button"
class="px-2 py-0.5 text-xs font-mono rounded-sm transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.searchScope === option.value
filters.searchScope === scope
? 'bg-bg-muted text-fg'
: 'text-fg-muted hover:text-fg'
"
:aria-pressed="filters.searchScope === option.value"
:title="$t(getScopeDescriptionKey(option.value))"
@click="emit('update:searchScope', option.value)"
:aria-pressed="filters.searchScope === scope"
:title="$t(getScopeDescriptionKey(scope))"
@click="emit('update:searchScope', scope)"
>
{{ $t(getScopeLabelKey(option.value)) }}
{{ $t(getScopeLabelKey(scope)) }}
</button>
</div>
</div>
Expand Down Expand Up @@ -301,18 +301,18 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
</legend>
<div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')">
<button
v-for="option in SECURITY_FILTER_OPTIONS"
:key="option.value"
v-for="security in SECURITY_FILTER_VALUES"
:key="security"
type="button"
role="radio"
disabled
:aria-checked="filters.security === option.value"
:aria-checked="filters.security === security"
class="tag transition-colors duration-200 opacity-50 cursor-not-allowed focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
:class="
filters.security === option.value ? 'bg-fg text-bg border-fg hover:text-bg/70' : ''
filters.security === security ? 'bg-fg text-bg border-fg hover:text-bg/70' : ''
"
>
{{ $t(getSecurityLabelKey(option.value)) }}
{{ $t(getSecurityLabelKey(security)) }}
</button>
</div>
</fieldset>
Expand Down
74 changes: 30 additions & 44 deletions app/components/compare/FacetSelector.vue
Original file line number Diff line number Diff line change
@@ -1,46 +1,36 @@
<script setup lang="ts">
import type { ComparisonFacet } from '#shared/types'
import { FACET_INFO, FACETS_BY_CATEGORY, CATEGORY_ORDER } from '#shared/types/comparison'

const { isFacetSelected, toggleFacet, selectCategory, deselectCategory } = useFacetSelection()

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

// 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))
const selectableFacets = facets.filter(f => !f.comingSoon)
return selectableFacets.length > 0 && selectableFacets.every(f => isFacetSelected(f.id))
}

// 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))
const selectableFacets = facets.filter(f => !f.comingSoon)
return selectableFacets.length > 0 && selectableFacets.every(f => !isFacetSelected(f.id))
}
</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">
<div v-for="category in categoryOrder" :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}`) }}
{{ getCategoryLabel(category) }}
</span>
<button
type="button"
Expand All @@ -51,9 +41,7 @@ function isCategoryNoneSelected(category: string): boolean {
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
$t('compare.facets.select_category', {
category: $t(`compare.facets.categories.${category}`),
})
$t('compare.facets.select_category', { category: getCategoryLabel(category) })
"
:disabled="isCategoryAllSelected(category)"
@click="selectCategory(category)"
Expand All @@ -70,9 +58,7 @@ function isCategoryNoneSelected(category: string): boolean {
: 'text-fg-muted/60 hover:text-fg-muted'
"
:aria-label="
$t('compare.facets.deselect_category', {
category: $t(`compare.facets.categories.${category}`),
})
$t('compare.facets.deselect_category', { category: getCategoryLabel(category) })
"
:disabled="isCategoryNoneSelected(category)"
@click="deselectCategory(category)"
Expand All @@ -84,31 +70,31 @@ function isCategoryNoneSelected(category: string): boolean {
<!-- Facet buttons -->
<div class="flex items-center gap-1.5 flex-wrap" role="group">
<button
v-for="{ facet, info } in facetsByCategory[category]"
:key="facet"
v-for="facet in facetsByCategory[category]"
:key="facet.id"
type="button"
:title="info.comingSoon ? $t('compare.facets.coming_soon') : info.description"
:disabled="info.comingSoon"
:aria-pressed="isFacetSelected(facet)"
:aria-label="info.label"
:title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description"
:disabled="facet.comingSoon"
:aria-pressed="isFacetSelected(facet.id)"
:aria-label="facet.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
facet.comingSoon
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
: isFacetSelected(facet)
: isFacetSelected(facet.id)
? '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)"
@click="!facet.comingSoon && toggleFacet(facet.id)"
>
<span
v-if="!info.comingSoon"
v-if="!facet.comingSoon"
class="w-3 h-3"
:class="isFacetSelected(facet) ? 'i-carbon:checkmark' : 'i-carbon:add'"
:class="isFacetSelected(facet.id) ? 'i-carbon:checkmark' : 'i-carbon:add'"
aria-hidden="true"
/>
{{ info.label }}
<span v-if="info.comingSoon" class="text-[9px]"
{{ facet.label }}
<span v-if="facet.comingSoon" class="text-[9px]"
>({{ $t('compare.facets.coming_soon') }})</span
>
</button>
Expand Down
80 changes: 64 additions & 16 deletions app/composables/useFacetSelection.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import type { ComparisonFacet } from '#shared/types'
import { ALL_FACETS, DEFAULT_FACETS, FACET_INFO } from '#shared/types/comparison'
import type { ComparisonFacet, FacetInfo } from '#shared/types'
import {
ALL_FACETS,
CATEGORY_ORDER,
DEFAULT_FACETS,
FACET_INFO,
FACETS_BY_CATEGORY,
} from '#shared/types/comparison'
import { useRouteQuery } from '@vueuse/router'

/** Facet info enriched with i18n labels */
export interface FacetInfoWithLabels extends Omit<FacetInfo, 'id'> {
id: ComparisonFacet
label: string
description: string
}

/**
* Composable for managing comparison facet selection with URL sync.
*
* @param queryParam - The URL query parameter name to use (default: 'facets')
*/
export function useFacetSelection(queryParam = 'facets') {
const { t } = useI18n()

// Helper to build facet info with i18n labels
function buildFacetInfo(facet: ComparisonFacet): FacetInfoWithLabels {
return {
id: facet,
...FACET_INFO[facet],
label: t(`compare.facets.items.${facet}.label`),
description: t(`compare.facets.items.${facet}.description`),
}
}

// Sync with URL query param (stable ref - doesn't change on other query changes)
const facetsParam = useRouteQuery<string>(queryParam, '', { mode: 'replace' })

// Parse facets from URL or use defaults
const selectedFacets = computed<ComparisonFacet[]>({
// Parse facet IDs from URL or use defaults
const selectedFacetIds = computed<ComparisonFacet[]>({
get() {
if (!facetsParam.value) {
return DEFAULT_FACETS
Expand Down Expand Up @@ -40,21 +65,26 @@ export function useFacetSelection(queryParam = 'facets') {
},
})

// Selected facets with full info and i18n labels
const selectedFacets = computed<FacetInfoWithLabels[]>(() =>
selectedFacetIds.value.map(buildFacetInfo),
)

// Check if a facet is selected
function isFacetSelected(facet: ComparisonFacet): boolean {
return selectedFacets.value.includes(facet)
return selectedFacetIds.value.includes(facet)
}

// Toggle a single facet
function toggleFacet(facet: ComparisonFacet): void {
const current = selectedFacets.value
const current = selectedFacetIds.value
if (current.includes(facet)) {
// Don't allow deselecting all facets
if (current.length > 1) {
selectedFacets.value = current.filter(f => f !== facet)
selectedFacetIds.value = current.filter(f => f !== facet)
}
} else {
selectedFacets.value = [...current, facet]
selectedFacetIds.value = [...current, facet]
}
}

Expand All @@ -69,36 +99,50 @@ export function useFacetSelection(queryParam = 'facets') {
// Select all facets in a category
function selectCategory(category: string): void {
const categoryFacets = getFacetsInCategory(category)
const current = selectedFacets.value
const current = selectedFacetIds.value
const newFacets = [...new Set([...current, ...categoryFacets])]
selectedFacets.value = newFacets
selectedFacetIds.value = newFacets
}

// Deselect all facets in a category
function deselectCategory(category: string): void {
const categoryFacets = getFacetsInCategory(category)
const remaining = selectedFacets.value.filter(f => !categoryFacets.includes(f))
const remaining = selectedFacetIds.value.filter(f => !categoryFacets.includes(f))
// Don't allow deselecting all facets
if (remaining.length > 0) {
selectedFacets.value = remaining
selectedFacetIds.value = remaining
}
}

// Select all facets globally
function selectAll(): void {
selectedFacets.value = DEFAULT_FACETS
selectedFacetIds.value = DEFAULT_FACETS
}

// Deselect all facets globally (keeps first facet to ensure at least one)
function deselectAll(): void {
selectedFacets.value = [DEFAULT_FACETS[0] as ComparisonFacet]
selectedFacetIds.value = [DEFAULT_FACETS[0] as ComparisonFacet]
}

// Check if all facets are selected
const isAllSelected = computed(() => selectedFacets.value.length === DEFAULT_FACETS.length)
const isAllSelected = computed(() => selectedFacetIds.value.length === DEFAULT_FACETS.length)

// Check if only one facet is selected (minimum)
const isNoneSelected = computed(() => selectedFacets.value.length === 1)
const isNoneSelected = computed(() => selectedFacetIds.value.length === 1)

// Get translated category name
function getCategoryLabel(category: FacetInfo['category']): string {
return t(`compare.facets.categories.${category}`)
}

// All facets with their info and i18n labels, grouped by category
const facetsByCategory = computed(() => {
const result: Record<string, FacetInfoWithLabels[]> = {}
for (const category of CATEGORY_ORDER) {
result[category] = FACETS_BY_CATEGORY[category].map(buildFacetInfo)
}
return result
})

return {
selectedFacets,
Expand All @@ -111,6 +155,10 @@ export function useFacetSelection(queryParam = 'facets') {
isAllSelected,
isNoneSelected,
allFacets: ALL_FACETS,
// Facet info with i18n
getCategoryLabel,
facetsByCategory,
categoryOrder: CATEGORY_ORDER,
}
}

Expand Down
Loading