update toggle filter design (#6888)

change toggle filters to just use exclude-only filters, same design as the others.
This commit is contained in:
Prospector
2026-07-27 11:49:52 -07:00
committed by GitHub
parent 20adb0f16a
commit c938ca3ceb
5 changed files with 118 additions and 131 deletions
@@ -19,7 +19,7 @@
:option="option"
:included="included(option)"
:excluded="excluded(option)"
:supports-negative-filter="supportsNegativeFilter"
:supports="supports"
@toggle="(o) => emit('toggle', o)"
@toggle-exclude="(o) => emit('toggleExclude', o)"
>
@@ -44,13 +44,13 @@
import { DropdownIcon } from '@modrinth/assets'
import { ref } from 'vue'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
import SearchFilterOption from './SearchFilterOption.vue'
defineProps<{
groupName: string
options: FilterOption[]
supportsNegativeFilter: boolean
supports: FilterMode[]
included: (option: FilterOption) => boolean
excluded: (option: FilterOption) => boolean
}>()
@@ -1,13 +1,13 @@
<template>
<div class="search-filter-option group flex gap-1 items-center">
<button
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)"
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
@click="() => emit(primaryAction === 'exclude' ? 'toggleExclude' : 'toggle', option)"
>
<slot> </slot>
<BanIcon
v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
v-if="excluded || primaryAction === 'exclude'"
:class="`filter-action-icon ban-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
@@ -17,12 +17,12 @@
/>
</button>
<div
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents"
:class="{ 'opacity-0': included }"
></div>
<button
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
v-tooltip="formatMessage(messages.excludeTooltip)"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)"
@@ -34,22 +34,30 @@
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
withDefaults(
const props = withDefaults(
defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
supports?: FilterMode[]
}>(),
{
supportsNegativeFilter: false,
supports: () => ['include'],
},
)
const supportsInclude = computed(() => props.supports.includes('include'))
const supportsExclude = computed(() => props.supports.includes('exclude'))
const primaryAction = computed<FilterMode>(() => (supportsInclude.value ? 'include' : 'exclude'))
const showExcludeButton = computed(
() => supportsInclude.value && supportsExclude.value && !props.excluded,
)
const { formatMessage } = useVIntl()
const emit = defineEmits<{
@@ -71,105 +71,82 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<label
v-for="option in filterType.options"
:key="`${filterType.id}-toggle-${option.id}`"
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
>
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
<Toggle
:model-value="isExcluded(option)"
small
class="shrink-0"
@update:model-value="toggleNegativeFilter(option)"
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports="filterType.supports"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -213,7 +190,6 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
+16 -13
View File
@@ -29,13 +29,15 @@ export type FilterOption = BaseOption &
| { method: 'environment'; environment: 'client' | 'server' }
)
export type FilterMode = 'include' | 'exclude'
export type FilterType = {
id: string
formatted_name: string
options: FilterOption[]
supported_project_types: ProjectType[]
query_param: string
supports_negative_filter: boolean
supports: FilterMode[]
toggle_groups?: {
id: string
formatted_name: string
@@ -46,7 +48,7 @@ export type FilterType = {
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none' | 'toggle'
display: 'all' | 'scrollable' | 'none'
}
| {
display: 'expandable'
@@ -191,7 +193,7 @@ export function useSearch(
: ([category.project_type] as ProjectType[]),
display: 'all',
query_param: category.header === 'resolutions' ? 'g' : 'f',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: [],
}
@@ -227,7 +229,7 @@ export function useSearch(
supported_project_types: ['mod', 'modpack'],
display: 'all',
query_param: 'e',
supports_negative_filter: false,
supports: ['include'],
searchable: false,
options: [
{
@@ -267,7 +269,7 @@ export function useSearch(
supported_project_types: ALL_PROJECT_TYPES,
display: 'scrollable',
query_param: 'v',
supports_negative_filter: false,
supports: ['include'],
toggle_groups: [
{
id: 'all_versions',
@@ -305,7 +307,7 @@ export function useSearch(
supported_project_types: ['mod'],
display: 'expandable',
query_param: 'g',
supports_negative_filter: true,
supports: ['include', 'exclude'],
default_values: DEFAULT_MOD_LOADERS,
searchable: false,
options: tags.value.loaders
@@ -337,7 +339,7 @@ export function useSearch(
supported_project_types: ['modpack'],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: tags.value.loaders
.filter((loader) => loader.supported_project_types.includes('modpack'))
@@ -363,7 +365,7 @@ export function useSearch(
display: 'expandable',
default_values: DEFAULT_PLUGIN_LOADERS,
query_param: 'g',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: tags.value.loaders
.filter(
@@ -392,7 +394,7 @@ export function useSearch(
supported_project_types: ['plugin'],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: tags.value.loaders
.filter((loader) => PLUGIN_PLATFORMS.includes(loader.name))
@@ -416,7 +418,7 @@ export function useSearch(
),
supported_project_types: ['shader'],
query_param: 'g',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
display: 'expandable',
default_values: DEFAULT_SHADER_LOADERS,
@@ -439,7 +441,7 @@ export function useSearch(
),
supported_project_types: ['mod', 'modpack', 'resourcepack', 'shader', 'plugin', 'datapack'],
query_param: 'l',
supports_negative_filter: true,
supports: ['include', 'exclude'],
display: 'all',
searchable: false,
options: [
@@ -466,7 +468,7 @@ export function useSearch(
),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'pid',
supports_negative_filter: true,
supports: ['include', 'exclude'],
display: 'none',
searchable: false,
options: [],
@@ -481,8 +483,9 @@ export function useSearch(
}),
),
supported_project_types: ['mod', 'plugin', 'datapack'],
display: 'toggle',
display: 'all',
query_param: 'a',
supports: ['exclude'],
searchable: false,
ordering: -1000,
options: excludeableProjectTypes.map((target) => ({
+6 -6
View File
@@ -157,7 +157,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'all',
query_param: 'sc',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: [],
}
@@ -204,7 +204,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'all',
query_param: 'sct',
supports_negative_filter: false,
supports: ['include'],
searchable: false,
options: [
{
@@ -250,7 +250,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'scrollable',
query_param: 'sgv',
supports_negative_filter: false,
supports: ['include'],
searchable: true,
options: (tags.value?.gameVersions ?? []).map((gv) => ({
id: gv.version,
@@ -271,7 +271,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'all',
query_param: 'sr',
supports_negative_filter: true,
supports: ['include', 'exclude'],
searchable: false,
options: sortedRegions.map(([code, name]) => ({
id: code,
@@ -291,7 +291,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'scrollable',
query_param: 'sl',
supports_negative_filter: false,
supports: ['include'],
searchable: true,
options: sortedLanguages.map(([code, name]) => ({
id: code,
@@ -312,7 +312,7 @@ export function useServerSearch(opts: {
supported_project_types: ['server'],
display: 'all',
query_param: 'sst',
supports_negative_filter: false,
supports: ['include'],
searchable: false,
options: [
{