refactor: no more vue multiselect (#5523)

* start multiselect component

* update styles

* small fix

* fix padding and styles

* add border bottom on sticky items

* add border bottom to search as well

* fix select all showing line

* use multi-select component for languages field

* add no options story for empty state

* refactor: remove vue-multiselect, replace with either our own combobox and multiselect

* pnpm prepr

* pnpm prepr

* fix combobox in transfer organization
This commit is contained in:
Truman Gao
2026-03-16 05:46:48 -07:00
committed by GitHub
parent d50a8efb26
commit 01c9dee612
15 changed files with 172 additions and 240 deletions

View File

@@ -40,7 +40,6 @@
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-multiselect": "3.0.0",
"vue-router": "^4.6.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
},

View File

@@ -1599,4 +1599,3 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@@ -17,31 +17,17 @@
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<multiselect
<Combobox
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
v-model="selectedVersionId"
:options="versionOptions"
:searchable="true"
placeholder="Select version"
open-direction="top"
:show-labels="false"
:custom-label="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
force-direction="up"
:max-height="150"
/>
<span v-else>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
<span>{{ selectedVersionLabel }}</span>
</span>
</td>
</tr>
@@ -59,9 +45,8 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import Multiselect from 'vue-multiselect'
import { Button, Combobox, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
@@ -79,11 +64,35 @@ const installing = ref(false)
const onInstall = ref(() => {})
const selectedVersionLabel = computed(() => {
if (!selectedVersion.value) return ''
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
})
const versionOptions = computed(() =>
(versions.value ?? []).map((version) => ({
value: version.id,
label: `${version.name} (${version.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version.game_versions.join(', ')})`,
})),
)
const selectedVersionId = computed({
get: () => selectedVersion.value?.id ?? null,
set: (value) => {
if (!value) return
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
},
})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal
versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0]
versions.value = projectVersions ?? []
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
project.value = projectVal
@@ -162,9 +171,5 @@ td:first-child {
display: flex;
flex-direction: column;
gap: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
}
</style>

View File

@@ -75,7 +75,6 @@
"semver": "^7.5.4",
"three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2",

View File

@@ -1593,4 +1593,3 @@ const { cycle: changeTheme } = useTheme()
}
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@@ -296,19 +296,14 @@
This project is not managed by an organization. If you are the member of any organizations,
you can transfer management to one of them.
</p>
<div v-if="!organization" class="input-group">
<Multiselect
id="organization-picker"
v-model="selectedOrganization"
class="large-multiselect"
track-by="id"
label="name"
open-direction="top"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:options="organizations || []"
:disabled="!currentMember?.is_owner || organizations?.length === 0"
<div v-if="!organization" class="flex gap-2">
<Combobox
v-model="selectedOrganizationId"
:options="organizationOptions"
:searchable="true"
search-placeholder="Select organization..."
force-direction="up"
:disabled="!currentMember?.is_owner || organizationOptions.length === 0"
/>
<button
class="btn btn-primary"
@@ -316,7 +311,7 @@
@click="openTransferToOrgModal($event)"
>
<CheckIcon />
Transfer management
<span class="w-max"> Transfer management </span>
</button>
</div>
<button v-if="organization" class="btn" @click="$refs.modal_remove.show()">
@@ -561,13 +556,13 @@ import {
Badge,
Card,
Checkbox,
Combobox,
ConfirmModal,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import ConfirmTransferProjectModal from '~/components/ui/ConfirmTransferProjectModal.vue'
import { removeSelfFromTeam } from '~/helpers/teams.js'
@@ -620,7 +615,7 @@ initMembers()
const currentUsername = ref('')
const openTeamMembers = ref([])
const selectedOrganization = ref(null)
const selectedOrganizationId = ref('')
const transferData = ref(null)
const transferModal = ref(null)
@@ -630,6 +625,17 @@ const { data: organizations } = useAsyncData('organizations', () => {
})
})
const organizationOptions = computed(() =>
(organizations.value ?? []).map((organization) => ({
value: organization.id,
label: organization.name,
})),
)
const selectedOrganization = computed(() =>
(organizations.value ?? []).find((org) => org.id === selectedOrganizationId.value),
)
const UPLOAD_VERSION = 1 << 0
const DELETE_VERSION = 1 << 1
const EDIT_DETAILS = 1 << 2
@@ -642,9 +648,9 @@ const VIEW_ANALYTICS = 1 << 8
const VIEW_PAYOUTS = 1 << 9
const onAddToOrg = useClientTry(async () => {
if (!selectedOrganization.value) return
if (!selectedOrganizationId.value) return
await useBaseFetch(`organization/${selectedOrganization.value.id}/projects`, {
await useBaseFetch(`organization/${selectedOrganizationId.value}/projects`, {
method: 'POST',
body: JSON.stringify({
project_id: project.value.id,
@@ -1002,8 +1008,4 @@ const updateMembers = async () => {
}
}
}
.large-multiselect {
max-width: 24rem;
}
</style>

View File

@@ -25,17 +25,14 @@
The mod loaders you would like to package your data pack for.
</span>
</label>
<multiselect
<MultiSelect
id="package-mod-loaders"
v-model="packageLoaders"
:options="['fabric', 'forge', 'quilt', 'neoforge']"
:custom-label="(value: string) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="true"
class="package-loader-select"
:options="packageLoaderOptions"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose loaders..."
open-direction="top"
force-direction="up"
/>
<div class="button-group">
<ButtonStyled>
@@ -436,11 +433,11 @@ import {
ENVIRONMENTS_COPY,
injectNotificationManager,
injectProjectPageContext,
MultiSelect,
StyledInput,
useFormatDateTime,
} from '@modrinth/ui'
import { formatBytes, renderHighlightedString } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
@@ -504,6 +501,12 @@ const newFiles = ref<File[]>([])
const deleteFiles = ref<string[]>([])
const newFileTypes = ref<Array<{ display: string; value: string } | null>>([])
const packageLoaders = ref(['forge', 'fabric', 'quilt', 'neoforge'])
const packageLoaderOptions = [
{ value: 'fabric', label: 'Fabric' },
{ value: 'forge', label: 'Forge' },
{ value: 'quilt', label: 'Quilt' },
{ value: 'neoforge', label: 'Neoforge' },
]
const showKnownErrors = ref(false)
const shouldPreventActions = ref(false)
const uploadedImageIds = ref<string[]>([])
@@ -1215,11 +1218,6 @@ async function resetProjectVersions() {
margin-bottom: var(--spacing-card-sm);
}
.multiselect {
width: 8rem;
flex-grow: 1;
}
input {
flex-grow: 2;
}
@@ -1270,14 +1268,6 @@ async function resetProjectVersions() {
font-weight: 300;
}
.raised-multiselect {
display: none;
margin: 0 0.5rem;
height: 40px;
max-height: 40px;
min-width: 235px;
}
.raised-button {
margin-left: auto;
background-color: var(--color-raised-bg);
@@ -1286,13 +1276,6 @@ async function resetProjectVersions() {
&:not(:nth-child(2)) {
margin-top: 0.5rem;
}
// TODO: Make file type editing work on mobile
@media (min-width: 600px) {
.raised-multiselect {
display: block;
}
}
}
.additional-files {
@@ -1357,7 +1340,7 @@ async function resetProjectVersions() {
margin-bottom: 1rem;
}
.multiselect {
.package-loader-select {
max-width: 20rem;
}
}

View File

@@ -182,14 +182,11 @@
<div class="push-right">
<div class="labeled-control-row">
Sort by
<Multiselect
<Combobox
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Status', 'Type']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:options="sortOptions"
@update:model-value="projects = updateSort(projects, sortBy, descending)"
/>
<button
@@ -323,6 +320,7 @@ import {
Avatar,
ButtonStyled,
Checkbox,
Combobox,
commonMessages,
CopyCode,
injectNotificationManager,
@@ -332,7 +330,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
@@ -356,6 +353,11 @@ const projects = ref([])
const projectsWithMigrationWarning = ref([])
const selectedProjects = ref([])
const sortBy = ref('Name')
const sortOptions = [
{ value: 'Name', label: 'Name' },
{ value: 'Status', label: 'Status' },
{ value: 'Type', label: 'Type' },
]
const descending = ref(false)
const editLinks = reactive({
showAffected: false,

View File

@@ -106,19 +106,22 @@
@input="updateSearchProjects"
/>
<div class="sort-by">
<span class="label">{{ formatMessage(commonMessages.sortByLabel) }}</span>
<Multiselect
<DropdownSelect
v-slot="{ selected }"
v-model="sortType"
placeholder="Select one"
class="selector"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:options="['relevance', 'downloads', 'follows', 'updated', 'newest']"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="updateSearchProjects"
/>
class="!h-9 !w-max flex-grow"
name="Sort by"
:options="sortOptions"
:display-name="(value) => value?.charAt(0).toUpperCase() + value?.slice(1)"
@change="updateSearchProjects()"
>
<div>
<span class="font-semibold text-primary"
>{{ formatMessage(commonMessages.sortByLabel) }}:
</span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</div>
</DropdownSelect>
</div>
</div>
<div class="results display-mode--list">
@@ -444,6 +447,7 @@ import {
ButtonStyled,
commonMessages,
defineMessages,
DropdownSelect,
IntlFormatted,
ProjectCard,
StyledInput,
@@ -451,7 +455,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { Multiselect } from 'vue-multiselect'
import ATLauncherLogo from '~/assets/images/external/atlauncher.svg?component'
import PrismLauncherLogo from '~/assets/images/external/prism.svg?component'
@@ -464,6 +467,7 @@ const { formatMessage } = useVIntl()
const searchQuery = ref('leave')
const sortType = ref('relevance')
const sortOptions = ['relevance', 'downloads', 'follows', 'updated', 'newest']
const PROJECT_COUNT = 100000
@@ -955,7 +959,7 @@ const creatorFeatureMessages = defineMessages({
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
gap: 0.5rem;
.iconified-input {
width: 100%;
@@ -977,19 +981,6 @@ const creatorFeatureMessages = defineMessages({
display: flex;
gap: 0.75rem;
align-items: center;
.label {
white-space: nowrap;
}
.selector {
min-width: 8rem;
white-space: nowrap;
}
@media screen and (max-width: 500px) {
display: none;
}
}
}

View File

@@ -186,17 +186,12 @@
<div class="push-right">
<div class="labeled-control-row">
Sort by
<Multiselect
<DropdownSelect
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Status', 'Type']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="
sortedProjects = updateSort(sortedProjects, sortBy, descending)
"
class="!w-auto"
name="Sort by"
:options="sortOptions"
@change="sortedProjects = updateSort(sortedProjects, sortBy, descending)"
/>
<button
v-tooltip="descending ? 'Descending' : 'Ascending'"
@@ -331,6 +326,7 @@ import {
Checkbox,
commonMessages,
CopyCode,
DropdownSelect,
injectNotificationManager,
NewModal,
ProjectStatusBadge,
@@ -338,7 +334,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import OrganizationProjectTransferModal from '~/components/ui/OrganizationProjectTransferModal.vue'
@@ -466,6 +461,7 @@ const updateSort = (inputProjects, sort, descending) => {
const sortedProjects = ref(updateSort(projects.value, 'Name'))
const selectedProjects = ref([])
const sortBy = ref('Name')
const sortOptions = ['Name', 'Status', 'Type']
const descending = ref(false)
const editLinksModal = ref(null)
@@ -674,11 +670,6 @@ const onBulkEditLinks = async () => {
white-space: nowrap;
}
.small-select {
width: -moz-fit-content;
width: fit-content;
}
.label-button[data-active='true'] {
--background-color: var(--color-red);
--text-color: var(--color-brand-inverted);