feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search
This commit is contained in:
Truman Gao
2026-05-11 20:46:23 -06:00
committed by GitHub
parent 612934bf34
commit e0056bfc40
18 changed files with 569 additions and 374 deletions

View File

@@ -2,28 +2,29 @@
<div
class="flex h-11 items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex min-w-0 flex-1 items-center justify-start gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || projectId" class="truncate font-semibold text-contrast">
<span
v-tooltip="name || projectId"
class="min-w-0 max-w-fit flex-1 truncate font-semibold text-contrast"
>
{{ name || 'Unknown Project' }}
</span>
<span
v-if="versionNumber"
v-tooltip="versionNumber"
class="min-w-0 max-w-fit flex-1 truncate whitespace-nowrap text-sm font-medium"
>
{{ versionNumber }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="truncate whitespace-nowrap font-medium"
:class="!hideRemove ? 'max-w-[35%]' : 'max-w-[50%]'"
>
{{ versionName }}
</span>
<div v-if="!hideRemove" class="flex items-center justify-end gap-1">
<div v-if="!hideRemove" class="flex shrink-0 items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
<XIcon aria-hidden="true" />
@@ -43,12 +44,12 @@ const emit = defineEmits<{
(e: 'remove'): void
}>()
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
const { projectId, name, icon, dependencyType, versionNumber, hideRemove } = defineProps<{
projectId: string
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
versionNumber?: string
hideRemove?: boolean
}>()

View File

@@ -8,7 +8,7 @@
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
:version-number="dependency.versionNumber"
:hide-remove="disableRemove"
@remove="() => removeDependency(index)"
/>
@@ -35,7 +35,7 @@ const addedDependencies = computed(() =>
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
const versionNumber = dependencyVersions.value[dep.version_id || '']?.version_number ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
@@ -44,7 +44,7 @@ const addedDependencies = computed(() =>
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
versionNumber,
}
})
.filter(Boolean),

View File

@@ -3,11 +3,16 @@
v-model="projectId"
placeholder="Select project"
:options="options"
:searchable="true"
:search-value="selectedProjectOption?.label"
search-placeholder="Search by name or paste ID..."
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
:disable-search-filter="true"
searchable
disable-search-filter
select-search-text-on-focus
:show-chevron="false"
@search-input="(query) => handleSearch(query)"
@search-blur="handleSearchBlur"
@select="handleSelect"
/>
</template>
@@ -15,15 +20,37 @@
import type { ComboboxOption } from '@modrinth/ui'
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h } from 'vue'
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager()
const projectId = defineModel<string>()
const searchLoading = ref(false)
const options = ref<ComboboxOption<string>[]>([])
const selectedProjectOption = ref<ComboboxOption<string>>()
const selectedProjectSearchQuery = ref('')
const { labrinth } = injectModrinthClient()
let latestSearchQuery = ''
function hitToOption(hit: { title: string; project_id: string; icon_url?: string | null }) {
return {
label: hit.title,
value: hit.project_id,
icon: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
),
}
}
const search = async (query: string) => {
query = query.trim()
@@ -53,36 +80,77 @@ const search = async (query: string) => {
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]], // remove any non-alphanumeric characters
})
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
),
}))
if (query !== latestSearchQuery) return
options.value = [...resultsByProjectId.hits, ...results.hits].map(hitToOption)
} catch (error: any) {
if (query !== latestSearchQuery) return
addNotification({
title: 'An error occurred',
text: error.data ? error.data.description : error,
type: 'error',
})
}
searchLoading.value = false
if (query === latestSearchQuery) {
searchLoading.value = false
}
}
const throttledSearch = useDebounceFn(search, 500)
const throttledSearch = useDebounceFn(search, 250)
const runSearch = async (query: string, debounce: boolean) => {
query = query.trim()
latestSearchQuery = query
if (!query) {
searchLoading.value = false
options.value = []
await throttledSearch(query)
return
}
searchLoading.value = true
await (debounce ? throttledSearch(query) : search(query))
}
const handleSearch = async (query: string) => {
searchLoading.value = true
await throttledSearch(query)
await runSearch(query, true)
}
const handleSelect = (option: ComboboxOption<string>) => {
selectedProjectOption.value = option
selectedProjectSearchQuery.value = latestSearchQuery
}
const handleSearchBlur = async () => {
if (!projectId.value) return
const selectedOption =
options.value.find((option) => option.value === projectId.value) ??
(selectedProjectOption.value?.value === projectId.value
? selectedProjectOption.value
: undefined)
if (!selectedOption) return
await runSearch(selectedProjectSearchQuery.value || selectedOption.label, false)
if (!options.value.some((option) => option.value === selectedOption.value)) {
options.value = [selectedOption, ...options.value]
}
}
watch(projectId, (value) => {
if (!value) {
selectedProjectOption.value = undefined
selectedProjectSearchQuery.value = ''
return
}
const option = options.value.find((option) => option.value === value)
if (option) {
selectedProjectOption.value = option
}
})
</script>

View File

@@ -8,7 +8,7 @@
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
:version-number="dependency.versionNumber"
@on-add-suggestion="
() =>
handleAddSuggestion({

View File

@@ -1,28 +1,31 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
class="flex h-11 items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex min-w-0 flex-1 items-center justify-start gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || 'Unknown Project'" class="truncate font-semibold text-contrast">
<span
v-tooltip="name || 'Unknown Project'"
class="min-w-0 max-w-fit flex-1 truncate font-semibold text-contrast"
>
{{ name || 'Unknown Project' }}
</span>
<span
v-if="versionNumber"
v-tooltip="versionNumber"
class="min-w-0 max-w-fit flex-1 truncate whitespace-nowrap text-sm font-medium"
>
{{ versionNumber }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<div class="flex shrink-0 items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true" type="transparent">
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
<PlusIcon aria-hidden="true" />
@@ -41,11 +44,11 @@ const emit = defineEmits<{
(e: 'onAddSuggestion'): void
}>()
const { name, icon, dependencyType, versionName } = defineProps<{
const { name, icon, dependencyType, versionNumber } = defineProps<{
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
versionNumber?: string
}>()
function emitAddSuggestion() {

View File

@@ -1,11 +1,10 @@
<template>
<NavTabs
<Tabs
v-if="editingVersion"
mode="local"
:links="editTabLinks"
:active-index="2"
class="mb-4 border border-solid border-surface-5 shadow-none drop-shadow-none"
@tab-click="setEditTab"
value="add-files"
:tabs="editTabs"
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
@change="setEditTab"
/>
<div class="flex w-full flex-col gap-4">
<template
@@ -99,7 +98,8 @@ import {
defineMessages,
DropzoneFileInput,
injectProjectPageContext,
NavTabs,
Tabs,
type TabsTab,
useVIntl,
} from '@modrinth/ui'
import { acceptFileFromProjectType } from '@modrinth/utils'
@@ -124,18 +124,14 @@ const {
handleNewFiles,
} = injectManageVersionContext()
const editTabs = [
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
{ label: 'Details', href: 'details', stage: 'add-details' },
{ label: 'Files', href: 'files', stage: 'add-files' },
] as const
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
function setEditTab(index: number) {
const tab = editTabs[index]
if (!tab) return
modal.value?.setStage(tab.stage)
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
}
function handleRemoveFile(index: number) {

View File

@@ -1,182 +1,58 @@
<template>
<div class="flex w-full max-w-full flex-col gap-6">
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Add dependency</span>
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project</span>
<DependencySelect v-model="newDependencyProjectId" />
</div>
<div class="flex w-full max-w-full flex-col gap-3">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project</span>
<DependencySelect v-model="newDependencyProjectId" />
</div>
<template v-if="newDependencyProjectId">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Version </span>
<Combobox
v-model="newDependencyVersionId"
placeholder="Select version"
:options="[{ label: 'Any version', value: null }, ...newDependencyVersions]"
:searchable="true"
/>
</div>
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Dependency relation </span>
<Combobox
v-model="newDependencyType"
placeholder="Select dependency type"
:options="[
{ label: 'Required', value: 'required' },
{ label: 'Optional', value: 'optional' },
{ label: 'Incompatible', value: 'incompatible' },
{ label: 'Embedded', value: 'embedded' },
]"
/>
</div>
<ButtonStyled color="green">
<button
class="self-start"
:disabled="!newDependencyProjectId"
@click="
() =>
addDependency(
toRaw({
project_id: newDependencyProjectId,
version_id: newDependencyVersionId || undefined,
dependency_type: newDependencyType,
}),
)
"
>
Add Dependency
</button>
</ButtonStyled>
</template>
<template v-if="newDependencyProjectId">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Version </span>
<Combobox
v-model="newDependencyVersionId"
placeholder="Select version"
:options="newDependencyVersionOptions"
:search-value="selectedNewDependencyVersionLabel"
:searchable="true"
:select-search-text-on-focus="true"
/>
</div>
</div>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Added dependencies</span>
<DependenciesList />
</div>
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Dependency relation </span>
<Combobox
v-model="newDependencyType"
placeholder="Select dependency type"
:options="[
{ label: 'Required', value: 'required' },
{ label: 'Optional', value: 'optional' },
{ label: 'Incompatible', value: 'incompatible' },
{ label: 'Embedded', value: 'embedded' },
]"
/>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
} from '@modrinth/ui'
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { Combobox } from '@modrinth/ui'
import { computed } from 'vue'
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import DependenciesList from '../components/DependenciesList.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
const { newDependencyProjectId, newDependencyType, newDependencyVersionId, newDependencyVersions } =
injectManageVersionContext()
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const {
draftVersion,
dependencyProjects,
dependencyVersions,
projectsFetchLoading,
visibleSuggestedDependencies,
} = injectManageVersionContext()
const errorNotification = (err: any) => {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
// reset to defaults when select different project
watch(newDependencyProjectId, async () => {
newDependencyVersionId.value = null
newDependencyType.value = 'required'
if (!newDependencyProjectId.value) {
newDependencyVersions.value = []
} else {
try {
const versions = await labrinth.versions_v3.getProjectVersions(newDependencyProjectId.value)
newDependencyVersions.value = versions.map((version) => ({
label: version.name,
value: version.id,
}))
} catch (error: any) {
errorNotification(error)
}
}
})
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
return {
projectId: dep.project_id,
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
}
})
.filter(Boolean),
const newDependencyVersionOptions = computed(() => [
{ label: 'Any version', value: null },
...newDependencyVersions.value,
])
const selectedNewDependencyVersionLabel = computed(
() =>
newDependencyVersionOptions.value.find(
(option) => option.value === newDependencyVersionId.value,
)?.label,
)
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
if (existing.project_id !== dependency.project_id) return false
if (!existing.version_id && !dependency.version_id) return true
return existing.version_id === dependency.version_id
})
if (alreadyAdded) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
type: 'error',
})
return
}
projectsFetchLoading.value = true
draftVersion.value.dependencies.push(dependency)
newDependencyProjectId.value = undefined
}
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
draftVersion.value.dependencies?.push({
project_id: dependency.project_id,
version_id: dependency.version_id,
dependency_type: dependency.dependency_type,
})
}
</script>

View File

@@ -1,11 +1,10 @@
<template>
<NavTabs
<Tabs
v-if="editingVersion"
mode="local"
:links="editTabLinks"
:active-index="1"
class="mb-4 border border-solid border-surface-5 shadow-none drop-shadow-none"
@tab-click="setEditTab"
value="add-details"
:tabs="editTabs"
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
@change="setEditTab"
/>
<div class="flex w-full flex-col gap-6">
<div class="flex flex-col gap-2">
@@ -18,6 +17,7 @@
:never-empty="true"
:capitalize="true"
:disabled="isUploading"
hide-checkmark-icon
/>
</div>
<div class="flex flex-col gap-2">
@@ -61,25 +61,21 @@
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor, NavTabs, StyledInput } from '@modrinth/ui'
import { Chips, MarkdownEditor, StyledInput, Tabs, type TabsTab } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
const editTabs = [
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
{ label: 'Details', href: 'details', stage: 'add-details' },
{ label: 'Files', href: 'files', stage: 'add-files' },
] as const
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
function setEditTab(index: number) {
const tab = editTabs[index]
if (!tab) return
modal.value?.setStage(tab.stage)
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
}
async function onImageUpload(file: File) {

View File

@@ -1,11 +1,10 @@
<template>
<NavTabs
<Tabs
v-if="editingVersion"
mode="local"
:links="editTabLinks"
:active-index="0"
class="mb-2 border border-solid border-surface-5 shadow-none drop-shadow-none"
@tab-click="setEditTab"
value="metadata"
:tabs="editTabs"
class="mb-3 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
@change="setEditTab"
/>
<div class="flex flex-col gap-6">
<div v-if="!editingVersion" class="flex flex-col gap-1">
@@ -161,40 +160,32 @@
</template>
<template v-if="!noDependenciesProject">
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Suggested dependencies </span>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<ButtonStyled type="transparent" size="standard">
<button @click="addDependency">
<PlusIcon />
Add dependency
</button>
</ButtonStyled>
</div>
<div
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
class="flex flex-col gap-1"
>
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
<DependenciesList />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
</div>
</div>
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
<DependenciesList disable-remove />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2.5">
<div class="flex items-center justify-between">
<span class="font-medium"> Suggested </span>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
</div>
</template>
@@ -203,14 +194,15 @@
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, getLoaderIcon, UnknownIcon } from '@modrinth/assets'
import { EditIcon, getLoaderIcon, PlusIcon, UnknownIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
ENVIRONMENTS_COPY,
FormattedTag,
injectProjectPageContext,
NavTabs,
Tabs,
type TabsTab,
TagItem,
useVIntl,
} from '@modrinth/ui'
@@ -239,18 +231,14 @@ const { projectV2 } = injectProjectPageContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const editTabs = [
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
{ label: 'Details', href: 'details', stage: 'add-details' },
{ label: 'Files', href: 'files', stage: 'add-files' },
] as const
const editTabs: TabsTab[] = [
{ label: 'Metadata', value: 'metadata' },
{ label: 'Details', value: 'add-details' },
{ label: 'Files', value: 'add-files' },
]
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
function setEditTab(index: number) {
const tab = editTabs[index]
if (!tab) return
modal.value?.setStage(tab.stage)
function setEditTab(tab: TabsTab) {
modal.value?.setStage(tab.value)
}
const isModpack = computed(() => projectType.value === 'modpack')
@@ -279,7 +267,7 @@ const editEnvironment = () => {
const editFiles = () => {
modal.value?.setStage('from-details-files')
}
const editDependencies = () => {
const addDependency = () => {
modal.value?.setStage('from-details-dependencies')
}

View File

@@ -1,6 +1,7 @@
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
import {
type ComboboxOption,
createContext,
injectModrinthClient,
injectNotificationManager,
@@ -49,16 +50,16 @@ export type VersionStage =
| 'add-loaders'
| 'add-mc-versions'
| 'add-environment'
| 'add-dependencies'
| 'add-changelog'
| 'from-details-loaders'
| 'from-details-mc-versions'
| 'from-details-environment'
| 'from-details-dependencies'
export type SuggestedDependency = Labrinth.Versions.v3.Dependency & {
name?: string
icon?: string
versionName?: string
versionNumber?: string
}
export interface PrimaryFile {
@@ -79,6 +80,10 @@ export interface ManageVersionContextValue {
projectsFetchLoading: Ref<boolean>
handlingNewFiles: Ref<boolean>
suggestedDependencies: Ref<SuggestedDependency[] | null>
newDependencyProjectId: Ref<string | undefined>
newDependencyType: Ref<Labrinth.Versions.v2.DependencyType>
newDependencyVersionId: Ref<string | null>
newDependencyVersions: Ref<ComboboxOption<string>[]>
visibleSuggestedDependencies: ComputedRef<SuggestedDependency[]>
primaryFile: ComputedRef<PrimaryFile | null>
@@ -105,6 +110,8 @@ export interface ManageVersionContextValue {
replacePrimaryFile: (file: File) => Promise<void>
getProject: (projectId: string) => Promise<Labrinth.Projects.v3.Project>
getVersion: (versionId: string) => Promise<Labrinth.Versions.v3.Version>
resetNewDependency: () => void
addNewDependency: () => boolean
// Submission methods
handleCreateVersion: () => Promise<void>
@@ -178,6 +185,10 @@ export function createManageVersionContext(
const dependencyVersions = ref<Record<string, Labrinth.Versions.v3.Version>>({})
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<SuggestedDependency[] | null>(null)
const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
const isSubmitting = ref(false)
const isUploading = ref(false)
@@ -254,6 +265,7 @@ export function createManageVersionContext(
filesToAdd.value = []
existingFilesToDelete.value = []
inferredVersionData.value = undefined
resetNewDependency()
}
async function handleNewFiles(newFiles: File[]) {
@@ -453,6 +465,44 @@ export function createManageVersionContext(
return version
}
function resetNewDependency() {
newDependencyProjectId.value = undefined
newDependencyVersionId.value = null
newDependencyType.value = 'required'
newDependencyVersions.value = []
}
function addNewDependency() {
if (!newDependencyProjectId.value) return false
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
const dependency: Labrinth.Versions.v3.Dependency = {
project_id: newDependencyProjectId.value,
version_id: newDependencyVersionId.value || undefined,
dependency_type: newDependencyType.value,
}
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
if (existing.project_id !== dependency.project_id) return false
if (!existing.version_id && !dependency.version_id) return true
return existing.version_id === dependency.version_id
})
if (alreadyAdded) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
type: 'error',
})
return false
}
projectsFetchLoading.value = true
draftVersion.value.dependencies.push(dependency)
resetNewDependency()
return true
}
// Primary file computed
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
@@ -537,6 +587,30 @@ export function createManageVersionContext(
{ immediate: true, deep: true },
)
watch(newDependencyProjectId, async () => {
newDependencyVersionId.value = null
newDependencyType.value = 'required'
if (!newDependencyProjectId.value) {
newDependencyVersions.value = []
return
}
try {
const versions = await labrinth.versions_v3.getProjectVersions(newDependencyProjectId.value)
newDependencyVersions.value = versions.map((version) => ({
label: version.version_number,
value: version.id,
}))
} catch (error: any) {
addNotification({
title: 'An error occurred',
text: error.data ? error.data.description : error,
type: 'error',
})
}
})
// Watch loaders to infer environment if not set
watch(
() => draftVersion.value.loaders,
@@ -603,7 +677,7 @@ export function createManageVersionContext(
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
dep.versionNumber = version.version_number
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
@@ -766,8 +840,6 @@ export function createManageVersionContext(
return editingVersion.value ? 'Edit loaders' : 'Set loaders'
case 'add-mc-versions':
return editingVersion.value ? 'Edit game versions' : 'Set game versions'
case 'add-dependencies':
return editingVersion.value ? 'Edit dependencies' : 'Set dependencies'
case 'add-environment':
return editingVersion.value ? 'Edit environment' : 'Add environment'
case 'add-changelog':
@@ -801,6 +873,10 @@ export function createManageVersionContext(
handlingNewFiles,
projectsFetchLoading,
suggestedDependencies,
newDependencyProjectId,
newDependencyType,
newDependencyVersionId,
newDependencyVersions,
visibleSuggestedDependencies,
primaryFile,
@@ -826,6 +902,8 @@ export function createManageVersionContext(
replacePrimaryFile,
getProject,
getVersion,
resetNewDependency,
addNewDependency,
handleNewFiles,
handleCreateVersion,
handleSaveVersionEdits,

View File

@@ -1,4 +1,4 @@
import { LeftArrowIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import { PlusIcon, XIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
@@ -6,54 +6,27 @@ import DependenciesStage from '~/components/ui/create-project-version/stages/Dep
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-dependencies',
stageContent: markRaw(DependenciesStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit version' : 'Dependencies'),
skip: (ctx) => ctx.suggestedDependencies.value != null || ctx.projectType.value === 'modpack',
leftButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? ctx.saveButtonConfig()
: {
label: ctx.getNextLabel(),
icon: RightArrowIcon,
iconPosition: 'after',
onClick: () => ctx.modal.value?.nextStage(),
},
nonProgressStage: (ctx) => ctx.editingVersion.value,
}
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-dependencies',
stageContent: markRaw(DependenciesStage),
title: 'Edit version',
title: 'Add dependency',
nonProgressStage: true,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('metadata'),
label: 'Cancel',
icon: XIcon,
onClick: () => {
ctx.resetNewDependency()
ctx.modal.value?.setStage('metadata')
},
}),
rightButtonConfig: (ctx) => ({
label: 'Add dependency',
icon: PlusIcon,
iconPosition: 'before',
color: 'green',
disabled: !ctx.newDependencyProjectId.value,
onClick: () => {
if (ctx.addNewDependency()) ctx.modal.value?.setStage('metadata')
},
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
}
: {
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}

View File

@@ -2,10 +2,7 @@ import {
fromDetailsStageConfig as fromDetailsFilesStageConfig,
stageConfig as addFilesStageConfig,
} from './add-files-stage'
import {
fromDetailsStageConfig as fromDetailsDependenciesStageConfig,
stageConfig as dependenciesStageConfig,
} from './dependencies-stage'
import { fromDetailsStageConfig as fromDetailsDependenciesStageConfig } from './dependencies-stage'
import { stageConfig as detailsStageConfig } from './details-stage'
import {
fromDetailsStageConfig as fromDetailsEnvironmentStageConfig,
@@ -26,11 +23,10 @@ export const stageConfigs = [
loadersStageConfig,
mcVersionsStageConfig,
environmentStageConfig,
dependenciesStageConfig,
metadataStageConfig,
detailsStageConfig,
// Non-progress stages for editing from details page
// Non-progress stages for editing from metadata/details pages
fromDetailsLoadersStageConfig,
fromDetailsMcVersionsStageConfig,
fromDetailsEnvironmentStageConfig,

View File

@@ -95,7 +95,7 @@ function toggleItem(item: T) {
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 2px var(--color-brand);
0 0 0 1px var(--color-brand);
}
}
</style>

View File

@@ -28,7 +28,8 @@
class="relative z-[1]"
@input="handleSearchInput"
@keydown="handleSearchKeydown"
@focus="handleSearchFocus"
@focusin="handleSearchFocus"
@focusout="handleSearchFocusout"
@click="handleSearchClick"
>
<template v-if="showChevron" #right>
@@ -90,7 +91,7 @@
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
v-if="shouldRenderDropdown"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
@@ -122,6 +123,7 @@
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@mousedown.prevent
@click="handleOptionClick(item, index)"
@mouseenter="handleOptionMouseEnter(item, index)"
>
@@ -225,12 +227,15 @@ const props = withDefaults(
showIconInSelected?: boolean
maxHeight?: number
displayValue?: string
searchValue?: string
triggerClass?: string
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Select the searchable input text when the field receives focus */
selectSearchTextOnFocus?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
}>(),
@@ -245,6 +250,7 @@ const props = withDefaults(
maxHeight: DEFAULT_MAX_HEIGHT,
noOptionsMessage: 'No results found',
syncWithSelection: true,
selectSearchTextOnFocus: false,
showSearchIcon: false,
},
)
@@ -256,6 +262,7 @@ const emit = defineEmits<{
open: []
close: []
searchInput: [query: string]
searchBlur: [query: string]
}>()
const slots = useSlots()
@@ -337,6 +344,14 @@ const filteredOptions = computed(() => {
})
})
const hasDropdownContent = computed(() => {
return filteredOptions.value.length > 0 || !!searchQuery.value || !!slots['dropdown-footer']
})
const shouldRenderDropdown = computed(() => {
return isOpen.value && hasDropdownContent.value
})
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
return [
item.class,
@@ -432,7 +447,7 @@ async function updateDropdownPosition() {
}
async function openDropdown() {
if (props.disabled || isOpen.value) return
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
isOpen.value = true
emit('open')
@@ -503,15 +518,20 @@ function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
const length = filteredOptions.value.length
if (length === 0) return -1
let index = currentIndex
let option
do {
for (let i = 0; i < length; i++) {
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
option = filteredOptions.value[index]
} while (isDivider(option) || option.disabled)
const option = filteredOptions.value[index]
return index
if (!isDivider(option) && !option.disabled) {
return index
}
}
return -1
}
function focusOption(index: number) {
@@ -627,12 +647,33 @@ function handleSearchInput() {
}
}
function handleSearchFocus() {
function handleSearchFocus(event: FocusEvent) {
const target = event.target
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) {
window.setTimeout(() => {
if (document.activeElement === target) {
target.select()
}
})
}
if (!isOpen.value) {
openDropdown()
}
}
function handleSearchFocusout(event: FocusEvent) {
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && containerRef.value?.contains(nextTarget)) return
if (nextTarget instanceof Node && dropdownRef.value?.contains(nextTarget)) return
emit('searchBlur', searchQuery.value)
if (props.searchValue !== undefined) {
searchQuery.value = props.searchValue
}
closeDropdown()
}
function handleSearchClick() {
if (!isOpen.value) {
openDropdown()
@@ -683,12 +724,24 @@ watch(isOpen, (value) => {
}
})
watch(shouldRenderDropdown, (value) => {
if (value) {
updateDropdownPosition()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
}
})
watch(hasDropdownContent, (value) => {
if (!value && isOpen.value) {
closeDropdown()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {

View File

@@ -0,0 +1,97 @@
<template>
<div
v-if="tabs.length > 0"
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1"
role="tablist"
>
<button
v-for="(tab, index) in tabs"
:key="tab.value"
ref="tabButtons"
type="button"
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 py-1 text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
:class="
tab.value === value
? 'border-green bg-highlight-green text-green'
: 'border-transparent bg-transparent text-primary hover:bg-surface-4'
"
role="tab"
:aria-selected="tab.value === value"
:tabindex="tab.value === value || (!hasSelectedTab && index === 0) ? 0 : -1"
@click="selectTab(tab)"
@keydown="onTabKeydown($event, index)"
>
<component
:is="tab.icon"
v-if="tab.icon"
class="size-5 shrink-0"
:class="tab.value === value ? 'text-green' : 'text-secondary'"
/>
<span class="text-nowrap">{{ tab.label }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, ref } from 'vue'
export type TabsValue = string | number
export interface TabsTab {
value: TabsValue
label: string
icon?: Component
}
const props = defineProps<{
value: TabsValue
tabs: TabsTab[]
}>()
const emit = defineEmits<{
'update:value': [value: TabsValue]
change: [tab: TabsTab]
}>()
const tabButtons = ref<HTMLButtonElement[]>()
const hasSelectedTab = computed(() => props.tabs.some((tab) => tab.value === props.value))
function selectTab(tab: TabsTab) {
emit('update:value', tab.value)
emit('change', tab)
}
function selectTabAtIndex(index: number) {
const tab = props.tabs[index]
if (!tab) return
selectTab(tab)
requestAnimationFrame(() => {
tabButtons.value?.[index]?.focus()
})
}
function onTabKeydown(event: KeyboardEvent, index: number) {
if (props.tabs.length === 0) return
const lastIndex = props.tabs.length - 1
let nextIndex: number | undefined
if (event.key === 'ArrowRight') {
nextIndex = index === lastIndex ? 0 : index + 1
} else if (event.key === 'ArrowLeft') {
nextIndex = index === 0 ? lastIndex : index - 1
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = lastIndex
}
if (nextIndex === undefined) return
event.preventDefault()
selectTabAtIndex(nextIndex)
}
</script>

View File

@@ -79,6 +79,8 @@ export { default as StatItem } from './StatItem.vue'
export { default as StyledInput } from './StyledInput.vue'
export type { TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'
export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export { default as Timeline } from './Timeline.vue'

View File

@@ -43,6 +43,24 @@ export const Searchable: Story = {
],
searchable: true,
searchPlaceholder: 'Search loaders...',
selectSearchTextOnFocus: true,
},
}
export const SearchableEmpty: Story = {
args: {
options: [],
searchable: true,
searchPlaceholder: 'Search projects...',
noOptionsMessage: 'No projects found',
},
parameters: {
docs: {
description: {
story:
'Covers the idle empty searchable state: focusing the input should not open an empty dropdown until there is a query or footer content.',
},
},
},
}

View File

@@ -0,0 +1,50 @@
import { ChartIcon, DownloadIcon, UsersIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Tabs from '../../components/base/Tabs.vue'
const meta = {
title: 'Base/Tabs',
component: Tabs,
} satisfies Meta<typeof Tabs>
export default meta
export const Default: StoryObj = {
render: () => ({
components: { Tabs },
setup() {
const value = ref('area')
const tabs = [
{ value: 'line', label: 'Line' },
{ value: 'area', label: 'Area' },
{ value: 'bar', label: 'Bar' },
]
return { value, tabs }
},
template: /* html */ `
<Tabs v-model:value="value" :tabs="tabs" />
`,
}),
}
export const WithIcons: StoryObj = {
render: () => ({
components: { Tabs },
setup() {
const value = ref('downloads')
const tabs = [
{ value: 'overview', label: 'Overview', icon: ChartIcon },
{ value: 'downloads', label: 'Downloads', icon: DownloadIcon },
{ value: 'users', label: 'Users', icon: UsersIcon },
]
return { value, tabs }
},
template: /* html */ `
<Tabs v-model:value="value" :tabs="tabs" />
`,
}),
}