Skip to content
Closed
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
79 changes: 51 additions & 28 deletions app/components/Compare/FacetSelector.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script setup lang="ts">
import type { FacetInfoWithLabels } from '~/composables/useFacetSelection'

const {
selectedFacets,
isFacetSelected,
toggleFacet,
selectCategory,
Expand All @@ -9,6 +12,31 @@ const {
getCategoryLabel,
} = useFacetSelection()

/** Native checkbox disabled when coming soon or this is the only selected facet (must keep ≥1). */
function isFacetCheckboxDisabled(facet: FacetInfoWithLabels): boolean {
if (facet.comingSoon) return true
return selectedFacets.value.length === 1 && isFacetSelected(facet.id)
}

function onFacetChange(facet: FacetInfoWithLabels) {
if (isFacetCheckboxDisabled(facet)) return
toggleFacet(facet.id)
}

/** Visual variant for each facet chip (border/background/cursor). */
function facetChipVariantClass(facet: FacetInfoWithLabels): string {
if (facet.comingSoon) {
return 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
}
if (isFacetCheckboxDisabled(facet)) {
return 'text-fg-muted bg-bg-muted border-border opacity-90 cursor-not-allowed'
}
if (isFacetSelected(facet.id)) {
return 'text-fg-muted bg-bg-muted border-border cursor-pointer'
}
return 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border cursor-pointer'
}

// Check if all non-comingSoon facets in a category are selected
function isCategoryAllSelected(category: string): boolean {
const facets = facetsByCategory.value[category] ?? []
Expand All @@ -29,7 +57,10 @@ function isCategoryNoneSelected(category: string): boolean {
<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-3xs text-fg-subtle uppercase tracking-wider">
<span
:id="`facet-category-${category}-heading`"
class="text-3xs text-fg-subtle uppercase tracking-wider"
>
{{ getCategoryLabel(category) }}
</span>
<!-- TODO: These should be radios, since they are mutually exclusive, and currently this behavior is faked with buttons -->
Expand Down Expand Up @@ -58,39 +89,31 @@ function isCategoryNoneSelected(category: string): boolean {
</ButtonBase>
</div>

<!-- Facet buttons -->
<div class="flex items-center gap-1.5 flex-wrap" role="group">
<!-- TODO: These should be checkboxes -->
<ButtonBase
<div
class="flex items-center gap-1.5 flex-wrap"
role="group"
:aria-labelledby="`facet-category-${category}-heading`"
>
<label
v-for="facet in facetsByCategory[category]"
:key="facet.id"
size="sm"
:title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description"
:disabled="facet.comingSoon"
:aria-pressed="isFacetSelected(facet.id)"
:aria-label="facet.label"
class="gap-1 px-1.5 rounded transition-colors focus-visible:outline-accent/70"
:class="
facet.comingSoon
? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed'
: isFacetSelected(facet.id)
? 'text-fg-muted bg-bg-muted'
: 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border'
"
@click="!facet.comingSoon && toggleFacet(facet.id)"
:classicon="
facet.comingSoon
? undefined
: isFacetSelected(facet.id)
? 'i-lucide:check'
: 'i-lucide:plus'
"
class="flex items-center gap-1.5 px-1.5 py-0.5 rounded border text-xs transition-colors focus-within:outline focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-accent/70"
:class="facetChipVariantClass(facet)"
>
{{ facet.label }}
<input
type="checkbox"
:data-facet-id="facet.id"
class="size-3.5 shrink-0 accent-accent rounded border-border disabled:opacity-60"
:checked="isFacetSelected(facet.id)"
:disabled="isFacetCheckboxDisabled(facet)"
:title="facet.comingSoon ? $t('compare.facets.coming_soon') : facet.description"
@change="onFacetChange(facet)"
/>
<span>{{ facet.label }}</span>
<span v-if="facet.comingSoon" class="text-4xs"
>({{ $t('compare.facets.coming_soon') }})</span
>
</ButtonBase>
</label>
</div>
</div>
</div>
Expand Down
95 changes: 44 additions & 51 deletions test/nuxt/components/compare/FacetSelector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ const categoryLabels: Record<string, string> = {
security: 'Security & Compliance',
}

const comingSoonFacetId = comingSoonFacets[0]
const comingSoonFacetLabel = hasComingSoonFacets
? (facetLabels[comingSoonFacetId!]?.label ?? comingSoonFacetId)
: ''
const comingSoonFacetId: ComparisonFacet | undefined = comingSoonFacets.at(0)

// Helper to build facet info with labels
function buildFacetInfo(facet: ComparisonFacet) {
Expand Down Expand Up @@ -136,7 +133,7 @@ describe('FacetSelector', () => {
})
})

describe('facet buttons', () => {
describe('facet checkboxes', () => {
it('renders all facets from FACET_INFO', async () => {
const component = await mountSuspended(FacetSelector)

Expand All @@ -146,86 +143,82 @@ describe('FacetSelector', () => {
}
})

it('shows checkmark icon for selected facets', async () => {
mockSelectedFacets.value = ['downloads']
mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads')

it('renders a checkbox for each facet', async () => {
const component = await mountSuspended(FacetSelector)

expect(component.find('.i-lucide\\:check').exists()).toBe(true)
const checkboxes = component.findAll('input[type="checkbox"]')
expect(checkboxes.length).toBe(Object.keys(FACET_INFO).length)
})

it('shows add icon for unselected facets', async () => {
it('checks selected facets', async () => {
mockSelectedFacets.value = ['downloads']
mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads')

const component = await mountSuspended(FacetSelector)

expect(component.find('.i-lucide\\:plus').exists()).toBe(true)
const downloads = component.find('input[type="checkbox"][data-facet-id="downloads"]')
expect((downloads.element as HTMLInputElement).checked).toBe(true)
})

it('applies aria-pressed for selected state', async () => {
it('unchecks unselected facets', async () => {
mockSelectedFacets.value = ['downloads']
mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads')

const component = await mountSuspended(FacetSelector)

const buttons = component.findAll('button[aria-pressed]')
const selectedButton = buttons.find(b => b.attributes('aria-pressed') === 'true')
expect(selectedButton).toBeDefined()
const types = component.find('input[type="checkbox"][data-facet-id="types"]')
expect((types.element as HTMLInputElement).checked).toBe(false)
})

it('calls toggleFacet when facet button is clicked', async () => {
const component = await mountSuspended(FacetSelector)
it('disables the checkbox when it is the only selected facet', async () => {
mockSelectedFacets.value = ['downloads']
mockIsFacetSelected.mockImplementation((f: string) => f === 'downloads')

// Find a facet button (not all/none)
const facetButton = component.findAll('button').find(b => b.text().includes('Downloads'))
await facetButton?.trigger('click')
const component = await mountSuspended(FacetSelector)

expect(mockToggleFacet).toHaveBeenCalled()
const downloads = component.find('input[type="checkbox"][data-facet-id="downloads"]')
expect(downloads.attributes('disabled')).toBeDefined()
})
})

describe.runIf(hasComingSoonFacets)('comingSoon facets', () => {
it('disables comingSoon facets', async () => {
it('calls toggleFacet when a facet checkbox is changed', async () => {
const component = await mountSuspended(FacetSelector)

// totalDependencies is marked as comingSoon
const buttons = component.findAll('button')
const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel))
const typesCheckbox = component.find('input[type="checkbox"][data-facet-id="types"]')
await typesCheckbox.trigger('change')

expect(comingSoonButton?.attributes('disabled')).toBeDefined()
expect(mockToggleFacet).toHaveBeenCalledWith('types')
})
})

it('shows coming soon text for comingSoon facets', async () => {
const component = await mountSuspended(FacetSelector)
describe.runIf(hasComingSoonFacets && comingSoonFacetId !== undefined)(
'comingSoon facets',
() => {
// runIf guarantees comingSoonFacetId is defined here
const facetId: ComparisonFacet = comingSoonFacetId!

expect(component.text().toLowerCase()).toContain('coming soon')
})
it('disables comingSoon facets', async () => {
const component = await mountSuspended(FacetSelector)

it('does not show checkmark/add icon for comingSoon facets', async () => {
const component = await mountSuspended(FacetSelector)
const comingSoonInput = component.find(`input[type="checkbox"][data-facet-id="${facetId}"]`)
expect(comingSoonInput.attributes('disabled')).toBeDefined()
})

// Find the comingSoon button
const buttons = component.findAll('button')
const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel))
it('shows coming soon text for comingSoon facets', async () => {
const component = await mountSuspended(FacetSelector)

// Should not have checkmark or add icon
expect(comingSoonButton?.find('.i-lucide\\:check').exists()).toBe(false)
expect(comingSoonButton?.find('.i-lucide\\:plus').exists()).toBe(false)
})
expect(component.text().toLowerCase()).toContain('coming soon')
})

it('does not call toggleFacet when comingSoon facet is clicked', async () => {
const component = await mountSuspended(FacetSelector)
it('does not call toggleFacet when comingSoon checkbox change is triggered', async () => {
const component = await mountSuspended(FacetSelector)

const buttons = component.findAll('button')
const comingSoonButton = buttons.find(b => b.text().includes(comingSoonFacetLabel))
await comingSoonButton?.trigger('click')
const comingSoonInput = component.find(`input[type="checkbox"][data-facet-id="${facetId}"]`)
await comingSoonInput.trigger('change')

// toggleFacet should not have been called with totalDependencies
expect(mockToggleFacet).not.toHaveBeenCalledWith(comingSoonFacetId)
})
})
expect(mockToggleFacet).not.toHaveBeenCalledWith(facetId)
})
},
)

describe('category all/none buttons', () => {
it('calls selectCategory when all button is clicked', async () => {
Expand Down
Loading