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
69 changes: 69 additions & 0 deletions app/components/Checkbox/Base.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
const model = defineModel<boolean>()

const props = withDefaults(
defineProps<{
disabled?: boolean
size?: 'small' | 'medium'
hideRadio?: boolean
value: string

indeterminate?: boolean

variant?: 'default' | 'tag'

hideCheckbox?: boolean

/**
* type should never be used, because this will always be a radio button.
*
* If you want a link use `TagLink` instead.
* */
type?: never
}>(),
{
size: 'medium',
variant: 'default',
},
)

const el = useTemplateRef('el')

defineExpose({
focus: () => el.value?.focus(),
getBoundingClientRect: () => el.value?.getBoundingClientRect(),
})

const uid = useId()
const internalId = `checkbox-${uid}`
</script>

<template>
<label
:htmlFor="internalId"
class="text-fg-muted hover:(text-fg) inline-flex items-center font-mono rounded transition-colors duration-200 has-checked:(hover:(text-fg)) has-disabled:(opacity-50 pointer-events-none)"
:class="{
'bg-bg-muted hover:(bg-fg/10)': variant === 'tag',
'has-checked:(bg-fg/20 text-fg)': variant === 'tag' && !hideCheckbox,
'has-checked:(bg-fg text-bg)': variant === 'tag' && hideCheckbox,
'has-checked:(text-fg)': variant !== 'tag',
'text-sm': size === 'medium',
'text-xs': size === 'small',
'px-4 py-2': size === 'medium' && variant === 'tag',
'px-2 py-0.5': size === 'small' && variant === 'tag',
}"
>
<input
type="checkbox"
:value="props.value"
:checked="model"
:id="internalId"
:disabled="props.disabled ? true : undefined"
@change="$emit('update:modelValue', !model)"
:indeterminate="props.indeterminate"
class="size-[1em] bg-bg-muted border-border rounded disabled:opacity-50 me-1"
:class="{ 'sr-only': hideCheckbox }"
/>
<slot />
</label>
</template>
22 changes: 10 additions & 12 deletions app/components/ColumnPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,18 @@ function handleReset() {
<label
v-for="column in toggleableColumns"
:key="column.id"
class="flex gap-2 items-center px-3 py-2 transition-colors duration-200"
:class="column.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bg-muted'"
class="flex gap-2 items-center px-3 py-2"
>
<input
type="checkbox"
:checked="column.visible"
<CheckboxBase
:model-value="column.visible"
:disabled="column.disabled"
:value="column.id"
:aria-describedby="column.disabled ? `${column.id}-disabled-reason` : undefined"
class="w-4 h-4 accent-fg bg-bg-muted border-border rounded disabled:opacity-50"
@change="!column.disabled && emit('toggle', column.id)"
/>
<span class="text-sm text-fg-muted font-mono flex-1">
class="w-full"
@update:modelValue="emit('toggle', column.id)"
>
{{ getColumnLabel(column.id) }}
</span>
</CheckboxBase>
<TooltipApp
v-if="column.disabled"
:id="`${column.id}-disabled-reason`"
Expand All @@ -129,8 +127,8 @@ function handleReset() {
</label>
</div>

<div class="border-t border-border py-1">
<ButtonBase @click="handleReset">
<div class="border-t border-border p-1">
<ButtonBase @click="handleReset" class="w-full">
{{ $t('filters.columns.reset') }}
</ButtonBase>
</div>
Expand Down
71 changes: 20 additions & 51 deletions app/components/Compare/FacetSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,72 +25,41 @@ function isCategoryNoneSelected(category: string): boolean {
</script>

<template>
<div class="space-y-3" role="group" :aria-label="$t('compare.facets.group_label')">
<div class="space-y-6" role="group" :aria-label="$t('compare.facets.group_label')">
<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">
{{ getCategoryLabel(category) }}
</span>
<!-- TODO: These should be radios, since they are mutually exclusive, and currently this behavior is faked with buttons -->
<ButtonBase
:aria-label="
$t('compare.facets.select_category', { category: getCategoryLabel(category) })
"
:aria-pressed="isCategoryAllSelected(category)"
:disabled="isCategoryAllSelected(category)"
@click="selectCategory(category)"
size="small"
>
{{ $t('compare.facets.all') }}
</ButtonBase>
<span class="text-2xs text-fg-muted/40">/</span>
<ButtonBase
:aria-label="
$t('compare.facets.deselect_category', { category: getCategoryLabel(category) })
"
:aria-pressed="isCategoryNoneSelected(category)"
:disabled="isCategoryNoneSelected(category)"
@click="deselectCategory(category)"
size="small"
>
{{ $t('compare.facets.none') }}
</ButtonBase>
</div>
<CheckboxBase
:aria-label="$t('compare.facets.select_category', { category: getCategoryLabel(category) })"
:model-value="isCategoryAllSelected(category)"
:value="category"
@update:model-value="
isCategoryAllSelected(category) ? deselectCategory(category) : selectCategory(category)
"
:indeterminate="!isCategoryAllSelected(category) && !isCategoryNoneSelected(category)"
class="uppercase tracking-widest mb-2"
>
{{ getCategoryLabel(category) }}
</CheckboxBase>

<!-- Facet buttons -->
<div class="flex items-center gap-1.5 flex-wrap" role="group">
<!-- TODO: These should be checkboxes -->
<ButtonBase
<CheckboxBase
v-for="facet in facetsByCategory[category]"
:key="facet.id"
size="small"
variant="tag"
hide-checkbox
: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'
"
:model-value="isFacetSelected(facet.id)"
:value="facet.id"
@update:model-value="!facet.comingSoon && toggleFacet(facet.id)"
>
{{ facet.label }}
<span v-if="facet.comingSoon" class="text-4xs"
>({{ $t('compare.facets.coming_soon') }})</span
>
</ButtonBase>
</CheckboxBase>
</div>
</div>
</div>
Expand Down
16 changes: 5 additions & 11 deletions app/components/Filter/Chips.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,19 @@ const emit = defineEmits<{
<span class="max-w-32 truncate">{{
Array.isArray(chip.value) ? chip.value.join(', ') : chip.value
}}</span>
<button
type="button"
class="flex items-center p-1 -m-1 hover:text-fg rounded-full transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
<ButtonBase
:aria-label="$t('filters.remove_filter', { label: chip.label })"
size="small"
@click="emit('remove', chip)"
>
<span class="i-lucide:x w-3 h-3" aria-hidden="true" />
</button>
</ButtonBase>
</TagStatic>
</TransitionGroup>

<button
v-if="chips.length > 1"
type="button"
class="text-sm p-0.5 text-fg-muted hover:text-fg underline transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-2"
@click="emit('clearAll')"
>
<ButtonBase v-if="chips.length > 1" type="button" size="small" @click="emit('clearAll')">
{{ $t('filters.clear_all') }}
</button>
</ButtonBase>
</div>
</template>

Expand Down
63 changes: 27 additions & 36 deletions app/components/Filter/Panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -216,27 +216,17 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.search') }}
</label>
<!-- Search scope toggle -->
<div
class="inline-flex rounded-md border border-border p-0.5 bg-bg"
role="group"
:aria-label="$t('filters.search_scope')"
>
<button
<div class="inline-flex gap-x-4" role="group" :aria-label="$t('filters.search_scope')">
<RadioBase
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 === scope
? 'bg-bg-muted text-fg'
: 'text-fg-muted hover:text-fg'
"
:aria-pressed="filters.searchScope === scope"
:value="scope"
:model-value="filters.searchScope"
:title="getScopeDescriptionKey(scope)"
@click="emit('update:searchScope', scope)"
@update:model-value="emit('update:searchScope', scope)"
>
{{ getScopeLabelKey(scope) }}
</button>
</RadioBase>
</div>
</div>
<InputBase
Expand All @@ -257,20 +247,21 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.weekly_downloads') }}
</legend>
<div
class="flex flex-wrap gap-2"
class="flex flex-wrap gap-8"
role="radiogroup"
:aria-label="$t('filters.weekly_downloads')"
>
<TagRadioButton
<RadioBase
v-for="range in DOWNLOAD_RANGES"
:key="range.value"
:model-value="filters.downloadRange"
:value="range.value"
@update:modelValue="emit('update:downloadRange', $event as DownloadRange)"
name="range"
size="small"
>
{{ getDownloadRangeLabelKey(range.value) }}
</TagRadioButton>
</RadioBase>
</div>
</fieldset>

Expand All @@ -280,20 +271,21 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.updated_within') }}
</legend>
<div
class="flex flex-wrap gap-2"
class="flex flex-wrap gap-8"
role="radiogroup"
:aria-label="$t('filters.updated_within')"
>
<TagRadioButton
<RadioBase
v-for="option in UPDATED_WITHIN_OPTIONS"
:key="option.value"
:model-value="filters.updatedWithin"
:value="option.value"
name="updatedWithin"
@update:modelValue="emit('update:updatedWithin', $event as UpdatedWithin)"
size="small"
>
{{ getUpdatedWithinLabelKey(option.value) }}
</TagRadioButton>
</RadioBase>
</div>
</fieldset>

Expand All @@ -305,17 +297,18 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.columns.coming_soon') }}
</span>
</legend>
<div class="flex flex-wrap gap-2" role="radiogroup" :aria-label="$t('filters.security')">
<TagRadioButton
<div class="flex flex-wrap gap-8" role="radiogroup" :aria-label="$t('filters.security')">
<RadioBase
v-for="security in SECURITY_FILTER_VALUES"
:key="security"
disabled
:model-value="filters.security"
:value="security"
name="security"
size="small"
>
{{ getSecurityLabelKey(security) }}
</TagRadioButton>
</RadioBase>
</div>
</fieldset>

Expand All @@ -325,23 +318,21 @@ const hasActiveFilters = computed(() => !!filterSummary.value)
{{ $t('filters.keywords') }}
</legend>
<div class="flex flex-wrap gap-1.5" role="group" :aria-label="$t('filters.keywords')">
<ButtonBase
<CheckboxBase
v-for="keyword in displayedKeywords"
:key="keyword"
size="small"
:aria-pressed="filters.keywords.includes(keyword)"
@click="emit('toggleKeyword', keyword)"
variant="tag"
hide-checkbox
:v-model="filters.keywords.includes(keyword)"
@update:modelValue="emit('toggleKeyword', keyword)"
:value="keyword"
>
{{ keyword }}
</ButtonBase>
<button
v-if="hasMoreKeywords"
type="button"
class="text-xs text-fg-subtle self-center font-mono hover:text-fg transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-fg focus-visible:ring-offset-1"
@click="showAllKeywords = true"
>
</CheckboxBase>
<ButtonBase v-if="hasMoreKeywords" size="small" @click="showAllKeywords = true">
{{ $t('filters.more_keywords', { count: (availableKeywords?.length ?? 0) - 20 }) }}
</button>
</ButtonBase>
</div>
</fieldset>
</div>
Expand Down
13 changes: 8 additions & 5 deletions app/components/Package/Card.vue
Original file line number Diff line number Diff line change
Expand Up @@ -167,16 +167,19 @@ const numberFormatter = useNumberFormatter()
class="relative z-10 flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-border list-none m-0 p-0 pointer-events-none items-center"
>
<li v-for="keyword in result.package.keywords.slice(0, 5)" :key="keyword">
<ButtonBase
<CheckboxBase
class="pointer-events-auto"
:key="keyword"
variant="tag"
hide-checkbox
size="small"
:aria-pressed="props.filters?.keywords.includes(keyword)"
:model-value="props.filters?.keywords.includes(keyword)"
:title="`Filter by ${keyword}`"
:data-result-index="index"
@click.stop="emit('clickKeyword', keyword)"
@update:modelValue="emit('clickKeyword', keyword)"
:value="keyword"
>
{{ keyword }}
</ButtonBase>
</CheckboxBase>
</li>
<li>
<span
Expand Down
Loading
Loading