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:
@@ -2,28 +2,29 @@
|
|||||||
<div
|
<div
|
||||||
class="flex h-11 items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
|
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" />
|
<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' }}
|
{{ name || 'Unknown Project' }}
|
||||||
</span>
|
</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">
|
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||||
{{ dependencyType }}
|
{{ dependencyType }}
|
||||||
</TagItem>
|
</TagItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<div v-if="!hideRemove" class="flex shrink-0 items-center justify-end gap-1">
|
||||||
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">
|
|
||||||
<ButtonStyled size="standard" :circular="true">
|
<ButtonStyled size="standard" :circular="true">
|
||||||
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
|
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
@@ -43,12 +44,12 @@ const emit = defineEmits<{
|
|||||||
(e: 'remove'): void
|
(e: 'remove'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
|
const { projectId, name, icon, dependencyType, versionNumber, hideRemove } = defineProps<{
|
||||||
projectId: string
|
projectId: string
|
||||||
name?: string
|
name?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
dependencyType: Labrinth.Versions.v2.DependencyType
|
dependencyType: Labrinth.Versions.v2.DependencyType
|
||||||
versionName?: string
|
versionNumber?: string
|
||||||
hideRemove?: boolean
|
hideRemove?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
:name="dependency.name"
|
:name="dependency.name"
|
||||||
:icon="dependency.icon"
|
:icon="dependency.icon"
|
||||||
:dependency-type="dependency.dependencyType"
|
:dependency-type="dependency.dependencyType"
|
||||||
:version-name="dependency.versionName"
|
:version-number="dependency.versionNumber"
|
||||||
:hide-remove="disableRemove"
|
:hide-remove="disableRemove"
|
||||||
@remove="() => removeDependency(index)"
|
@remove="() => removeDependency(index)"
|
||||||
/>
|
/>
|
||||||
@@ -35,7 +35,7 @@ const addedDependencies = computed(() =>
|
|||||||
if (!dep.project_id) return null
|
if (!dep.project_id) return null
|
||||||
|
|
||||||
const dependencyProject = dependencyProjects.value[dep.project_id]
|
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
|
if (!dependencyProject && projectsFetchLoading.value) return null
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ const addedDependencies = computed(() =>
|
|||||||
name: dependencyProject?.name,
|
name: dependencyProject?.name,
|
||||||
icon: dependencyProject?.icon_url,
|
icon: dependencyProject?.icon_url,
|
||||||
dependencyType: dep.dependency_type,
|
dependencyType: dep.dependency_type,
|
||||||
versionName,
|
versionNumber,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
v-model="projectId"
|
v-model="projectId"
|
||||||
placeholder="Select project"
|
placeholder="Select project"
|
||||||
:options="options"
|
:options="options"
|
||||||
:searchable="true"
|
:search-value="selectedProjectOption?.label"
|
||||||
search-placeholder="Search by name or paste ID..."
|
search-placeholder="Search by name or paste ID..."
|
||||||
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
|
: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-input="(query) => handleSearch(query)"
|
||||||
|
@search-blur="handleSearchBlur"
|
||||||
|
@select="handleSelect"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -15,15 +20,37 @@
|
|||||||
import type { ComboboxOption } from '@modrinth/ui'
|
import type { ComboboxOption } from '@modrinth/ui'
|
||||||
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import { defineAsyncComponent, h } from 'vue'
|
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const projectId = defineModel<string>()
|
const projectId = defineModel<string>()
|
||||||
|
|
||||||
const searchLoading = ref(false)
|
const searchLoading = ref(false)
|
||||||
const options = ref<ComboboxOption<string>[]>([])
|
const options = ref<ComboboxOption<string>[]>([])
|
||||||
|
const selectedProjectOption = ref<ComboboxOption<string>>()
|
||||||
|
const selectedProjectSearchQuery = ref('')
|
||||||
|
|
||||||
const { labrinth } = injectModrinthClient()
|
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) => {
|
const search = async (query: string) => {
|
||||||
query = query.trim()
|
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
|
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]], // remove any non-alphanumeric characters
|
||||||
})
|
})
|
||||||
|
|
||||||
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
|
if (query !== latestSearchQuery) return
|
||||||
label: hit.title,
|
|
||||||
value: hit.project_id,
|
options.value = [...resultsByProjectId.hits, ...results.hits].map(hitToOption)
|
||||||
icon: markRaw(
|
|
||||||
defineAsyncComponent(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
setup: () => () =>
|
|
||||||
h('img', {
|
|
||||||
src: hit.icon_url,
|
|
||||||
alt: hit.title,
|
|
||||||
class: 'h-5 w-5 rounded',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (query !== latestSearchQuery) return
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'An error occurred',
|
title: 'An error occurred',
|
||||||
text: error.data ? error.data.description : error,
|
text: error.data ? error.data.description : error,
|
||||||
type: '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) => {
|
const handleSearch = async (query: string) => {
|
||||||
searchLoading.value = true
|
await runSearch(query, true)
|
||||||
await throttledSearch(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
:name="dependency.name"
|
:name="dependency.name"
|
||||||
:icon="dependency.icon"
|
:icon="dependency.icon"
|
||||||
:dependency-type="dependency.dependency_type"
|
:dependency-type="dependency.dependency_type"
|
||||||
:version-name="dependency.versionName"
|
:version-number="dependency.versionNumber"
|
||||||
@on-add-suggestion="
|
@on-add-suggestion="
|
||||||
() =>
|
() =>
|
||||||
handleAddSuggestion({
|
handleAddSuggestion({
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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" />
|
<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' }}
|
{{ name || 'Unknown Project' }}
|
||||||
</span>
|
</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">
|
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||||
{{ dependencyType }}
|
{{ dependencyType }}
|
||||||
</TagItem>
|
</TagItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<div class="flex shrink-0 items-center justify-end gap-1">
|
||||||
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">
|
|
||||||
<ButtonStyled size="standard" :circular="true" type="transparent">
|
<ButtonStyled size="standard" :circular="true" type="transparent">
|
||||||
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
|
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" />
|
||||||
@@ -41,11 +44,11 @@ const emit = defineEmits<{
|
|||||||
(e: 'onAddSuggestion'): void
|
(e: 'onAddSuggestion'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { name, icon, dependencyType, versionName } = defineProps<{
|
const { name, icon, dependencyType, versionNumber } = defineProps<{
|
||||||
name?: string
|
name?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
dependencyType: Labrinth.Versions.v2.DependencyType
|
dependencyType: Labrinth.Versions.v2.DependencyType
|
||||||
versionName?: string
|
versionNumber?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function emitAddSuggestion() {
|
function emitAddSuggestion() {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<NavTabs
|
<Tabs
|
||||||
v-if="editingVersion"
|
v-if="editingVersion"
|
||||||
mode="local"
|
value="add-files"
|
||||||
:links="editTabLinks"
|
:tabs="editTabs"
|
||||||
:active-index="2"
|
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||||
class="mb-4 border border-solid border-surface-5 shadow-none drop-shadow-none"
|
@change="setEditTab"
|
||||||
@tab-click="setEditTab"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex w-full flex-col gap-4">
|
<div class="flex w-full flex-col gap-4">
|
||||||
<template
|
<template
|
||||||
@@ -99,7 +98,8 @@ import {
|
|||||||
defineMessages,
|
defineMessages,
|
||||||
DropzoneFileInput,
|
DropzoneFileInput,
|
||||||
injectProjectPageContext,
|
injectProjectPageContext,
|
||||||
NavTabs,
|
Tabs,
|
||||||
|
type TabsTab,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { acceptFileFromProjectType } from '@modrinth/utils'
|
import { acceptFileFromProjectType } from '@modrinth/utils'
|
||||||
@@ -124,18 +124,14 @@ const {
|
|||||||
handleNewFiles,
|
handleNewFiles,
|
||||||
} = injectManageVersionContext()
|
} = injectManageVersionContext()
|
||||||
|
|
||||||
const editTabs = [
|
const editTabs: TabsTab[] = [
|
||||||
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
|
{ label: 'Metadata', value: 'metadata' },
|
||||||
{ label: 'Details', href: 'details', stage: 'add-details' },
|
{ label: 'Details', value: 'add-details' },
|
||||||
{ label: 'Files', href: 'files', stage: 'add-files' },
|
{ label: 'Files', value: 'add-files' },
|
||||||
] as const
|
]
|
||||||
|
|
||||||
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
|
function setEditTab(tab: TabsTab) {
|
||||||
|
modal.value?.setStage(tab.value)
|
||||||
function setEditTab(index: number) {
|
|
||||||
const tab = editTabs[index]
|
|
||||||
if (!tab) return
|
|
||||||
modal.value?.setStage(tab.stage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveFile(index: number) {
|
function handleRemoveFile(index: number) {
|
||||||
|
|||||||
@@ -1,182 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex w-full max-w-full flex-col gap-6">
|
<div class="flex w-full max-w-full flex-col gap-3">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="grid gap-2.5">
|
||||||
<span class="font-semibold text-contrast">Add dependency</span>
|
<span class="font-semibold text-contrast">Project</span>
|
||||||
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
|
<DependencySelect v-model="newDependencyProjectId" />
|
||||||
<div class="grid gap-2.5">
|
</div>
|
||||||
<span class="font-semibold text-contrast">Project</span>
|
|
||||||
<DependencySelect v-model="newDependencyProjectId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="newDependencyProjectId">
|
<template v-if="newDependencyProjectId">
|
||||||
<div class="grid gap-2.5">
|
<div class="grid gap-2.5">
|
||||||
<span class="font-semibold text-contrast"> Version </span>
|
<span class="font-semibold text-contrast"> Version </span>
|
||||||
<Combobox
|
<Combobox
|
||||||
v-model="newDependencyVersionId"
|
v-model="newDependencyVersionId"
|
||||||
placeholder="Select version"
|
placeholder="Select version"
|
||||||
:options="[{ label: 'Any version', value: null }, ...newDependencyVersions]"
|
:options="newDependencyVersionOptions"
|
||||||
:searchable="true"
|
:search-value="selectedNewDependencyVersionLabel"
|
||||||
/>
|
:searchable="true"
|
||||||
</div>
|
:select-search-text-on-focus="true"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
|
<div class="grid gap-2.5">
|
||||||
<span class="font-semibold text-contrast">Suggested dependencies</span>
|
<span class="font-semibold text-contrast"> Dependency relation </span>
|
||||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
<Combobox
|
||||||
</div>
|
v-model="newDependencyType"
|
||||||
|
placeholder="Select dependency type"
|
||||||
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
|
:options="[
|
||||||
<span class="font-semibold text-contrast">Added dependencies</span>
|
{ label: 'Required', value: 'required' },
|
||||||
<DependenciesList />
|
{ label: 'Optional', value: 'optional' },
|
||||||
</div>
|
{ label: 'Incompatible', value: 'incompatible' },
|
||||||
|
{ label: 'Embedded', value: 'embedded' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import { Combobox } from '@modrinth/ui'
|
||||||
import {
|
import { computed } from 'vue'
|
||||||
ButtonStyled,
|
|
||||||
Combobox,
|
|
||||||
injectModrinthClient,
|
|
||||||
injectNotificationManager,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
|
|
||||||
|
|
||||||
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
|
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
|
||||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||||
|
|
||||||
import DependenciesList from '../components/DependenciesList.vue'
|
const { newDependencyProjectId, newDependencyType, newDependencyVersionId, newDependencyVersions } =
|
||||||
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
|
injectManageVersionContext()
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const newDependencyVersionOptions = computed(() => [
|
||||||
const { labrinth } = injectModrinthClient()
|
{ label: 'Any version', value: null },
|
||||||
|
...newDependencyVersions.value,
|
||||||
const {
|
])
|
||||||
draftVersion,
|
const selectedNewDependencyVersionLabel = computed(
|
||||||
dependencyProjects,
|
() =>
|
||||||
dependencyVersions,
|
newDependencyVersionOptions.value.find(
|
||||||
projectsFetchLoading,
|
(option) => option.value === newDependencyVersionId.value,
|
||||||
visibleSuggestedDependencies,
|
)?.label,
|
||||||
} = 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 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>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<NavTabs
|
<Tabs
|
||||||
v-if="editingVersion"
|
v-if="editingVersion"
|
||||||
mode="local"
|
value="add-details"
|
||||||
:links="editTabLinks"
|
:tabs="editTabs"
|
||||||
:active-index="1"
|
class="mb-5 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||||
class="mb-4 border border-solid border-surface-5 shadow-none drop-shadow-none"
|
@change="setEditTab"
|
||||||
@tab-click="setEditTab"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex w-full flex-col gap-6">
|
<div class="flex w-full flex-col gap-6">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -18,6 +17,7 @@
|
|||||||
:never-empty="true"
|
:never-empty="true"
|
||||||
:capitalize="true"
|
:capitalize="true"
|
||||||
:disabled="isUploading"
|
:disabled="isUploading"
|
||||||
|
hide-checkmark-icon
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -61,25 +61,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useImageUpload } from '~/composables/image-upload.ts'
|
||||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||||
|
|
||||||
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
|
const { draftVersion, isUploading, editingVersion, modal } = injectManageVersionContext()
|
||||||
|
|
||||||
const editTabs = [
|
const editTabs: TabsTab[] = [
|
||||||
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
|
{ label: 'Metadata', value: 'metadata' },
|
||||||
{ label: 'Details', href: 'details', stage: 'add-details' },
|
{ label: 'Details', value: 'add-details' },
|
||||||
{ label: 'Files', href: 'files', stage: 'add-files' },
|
{ label: 'Files', value: 'add-files' },
|
||||||
] as const
|
]
|
||||||
|
|
||||||
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
|
function setEditTab(tab: TabsTab) {
|
||||||
|
modal.value?.setStage(tab.value)
|
||||||
function setEditTab(index: number) {
|
|
||||||
const tab = editTabs[index]
|
|
||||||
if (!tab) return
|
|
||||||
modal.value?.setStage(tab.stage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onImageUpload(file: File) {
|
async function onImageUpload(file: File) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<NavTabs
|
<Tabs
|
||||||
v-if="editingVersion"
|
v-if="editingVersion"
|
||||||
mode="local"
|
value="metadata"
|
||||||
:links="editTabLinks"
|
:tabs="editTabs"
|
||||||
:active-index="0"
|
class="mb-3 border border-solid border-surface-5 !shadow-none !drop-shadow-none"
|
||||||
class="mb-2 border border-solid border-surface-5 shadow-none drop-shadow-none"
|
@change="setEditTab"
|
||||||
@tab-click="setEditTab"
|
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div v-if="!editingVersion" class="flex flex-col gap-1">
|
<div v-if="!editingVersion" class="flex flex-col gap-1">
|
||||||
@@ -161,40 +160,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="!noDependenciesProject">
|
<template v-if="!noDependenciesProject">
|
||||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
|
<div class="flex flex-col gap-2.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="font-semibold text-contrast"> Suggested dependencies </span>
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold text-contrast"> Dependencies </span>
|
||||||
|
|
||||||
<ButtonStyled type="transparent" size="standard">
|
<ButtonStyled type="transparent" size="standard">
|
||||||
<button @click="editDependencies">
|
<button @click="addDependency">
|
||||||
<EditIcon />
|
<PlusIcon />
|
||||||
Edit
|
Add dependency
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
|
||||||
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
|
<DependenciesList />
|
||||||
class="flex flex-col gap-1"
|
</div>
|
||||||
>
|
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<span class="text-sm font-medium">No dependencies added.</span>
|
||||||
<span class="font-semibold text-contrast"> Dependencies </span>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled type="transparent" size="standard">
|
|
||||||
<button @click="editDependencies">
|
|
||||||
<EditIcon />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
|
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2.5">
|
||||||
<DependenciesList disable-remove />
|
<div class="flex items-center justify-between">
|
||||||
</div>
|
<span class="font-medium"> Suggested </span>
|
||||||
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
|
</div>
|
||||||
<span class="text-sm font-medium">No dependencies added.</span>
|
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -203,14 +194,15 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { EditIcon, getLoaderIcon, UnknownIcon } from '@modrinth/assets'
|
import { EditIcon, getLoaderIcon, PlusIcon, UnknownIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
ENVIRONMENTS_COPY,
|
ENVIRONMENTS_COPY,
|
||||||
FormattedTag,
|
FormattedTag,
|
||||||
injectProjectPageContext,
|
injectProjectPageContext,
|
||||||
NavTabs,
|
Tabs,
|
||||||
|
type TabsTab,
|
||||||
TagItem,
|
TagItem,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -239,18 +231,14 @@ const { projectV2 } = injectProjectPageContext()
|
|||||||
const generatedState = useGeneratedState()
|
const generatedState = useGeneratedState()
|
||||||
const loaders = computed(() => generatedState.value.loaders)
|
const loaders = computed(() => generatedState.value.loaders)
|
||||||
|
|
||||||
const editTabs = [
|
const editTabs: TabsTab[] = [
|
||||||
{ label: 'Metadata', href: 'metadata', stage: 'metadata' },
|
{ label: 'Metadata', value: 'metadata' },
|
||||||
{ label: 'Details', href: 'details', stage: 'add-details' },
|
{ label: 'Details', value: 'add-details' },
|
||||||
{ label: 'Files', href: 'files', stage: 'add-files' },
|
{ label: 'Files', value: 'add-files' },
|
||||||
] as const
|
]
|
||||||
|
|
||||||
const editTabLinks = editTabs.map(({ label, href }) => ({ label, href }))
|
function setEditTab(tab: TabsTab) {
|
||||||
|
modal.value?.setStage(tab.value)
|
||||||
function setEditTab(index: number) {
|
|
||||||
const tab = editTabs[index]
|
|
||||||
if (!tab) return
|
|
||||||
modal.value?.setStage(tab.stage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isModpack = computed(() => projectType.value === 'modpack')
|
const isModpack = computed(() => projectType.value === 'modpack')
|
||||||
@@ -279,7 +267,7 @@ const editEnvironment = () => {
|
|||||||
const editFiles = () => {
|
const editFiles = () => {
|
||||||
modal.value?.setStage('from-details-files')
|
modal.value?.setStage('from-details-files')
|
||||||
}
|
}
|
||||||
const editDependencies = () => {
|
const addDependency = () => {
|
||||||
modal.value?.setStage('from-details-dependencies')
|
modal.value?.setStage('from-details-dependencies')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
|
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
|
||||||
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
|
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
type ComboboxOption,
|
||||||
createContext,
|
createContext,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@@ -49,16 +50,16 @@ export type VersionStage =
|
|||||||
| 'add-loaders'
|
| 'add-loaders'
|
||||||
| 'add-mc-versions'
|
| 'add-mc-versions'
|
||||||
| 'add-environment'
|
| 'add-environment'
|
||||||
| 'add-dependencies'
|
|
||||||
| 'add-changelog'
|
| 'add-changelog'
|
||||||
| 'from-details-loaders'
|
| 'from-details-loaders'
|
||||||
| 'from-details-mc-versions'
|
| 'from-details-mc-versions'
|
||||||
| 'from-details-environment'
|
| 'from-details-environment'
|
||||||
|
| 'from-details-dependencies'
|
||||||
|
|
||||||
export type SuggestedDependency = Labrinth.Versions.v3.Dependency & {
|
export type SuggestedDependency = Labrinth.Versions.v3.Dependency & {
|
||||||
name?: string
|
name?: string
|
||||||
icon?: string
|
icon?: string
|
||||||
versionName?: string
|
versionNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrimaryFile {
|
export interface PrimaryFile {
|
||||||
@@ -79,6 +80,10 @@ export interface ManageVersionContextValue {
|
|||||||
projectsFetchLoading: Ref<boolean>
|
projectsFetchLoading: Ref<boolean>
|
||||||
handlingNewFiles: Ref<boolean>
|
handlingNewFiles: Ref<boolean>
|
||||||
suggestedDependencies: Ref<SuggestedDependency[] | null>
|
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[]>
|
visibleSuggestedDependencies: ComputedRef<SuggestedDependency[]>
|
||||||
primaryFile: ComputedRef<PrimaryFile | null>
|
primaryFile: ComputedRef<PrimaryFile | null>
|
||||||
|
|
||||||
@@ -105,6 +110,8 @@ export interface ManageVersionContextValue {
|
|||||||
replacePrimaryFile: (file: File) => Promise<void>
|
replacePrimaryFile: (file: File) => Promise<void>
|
||||||
getProject: (projectId: string) => Promise<Labrinth.Projects.v3.Project>
|
getProject: (projectId: string) => Promise<Labrinth.Projects.v3.Project>
|
||||||
getVersion: (versionId: string) => Promise<Labrinth.Versions.v3.Version>
|
getVersion: (versionId: string) => Promise<Labrinth.Versions.v3.Version>
|
||||||
|
resetNewDependency: () => void
|
||||||
|
addNewDependency: () => boolean
|
||||||
|
|
||||||
// Submission methods
|
// Submission methods
|
||||||
handleCreateVersion: () => Promise<void>
|
handleCreateVersion: () => Promise<void>
|
||||||
@@ -178,6 +185,10 @@ export function createManageVersionContext(
|
|||||||
const dependencyVersions = ref<Record<string, Labrinth.Versions.v3.Version>>({})
|
const dependencyVersions = ref<Record<string, Labrinth.Versions.v3.Version>>({})
|
||||||
const projectsFetchLoading = ref(false)
|
const projectsFetchLoading = ref(false)
|
||||||
const suggestedDependencies = ref<SuggestedDependency[] | null>(null)
|
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 isSubmitting = ref(false)
|
||||||
const isUploading = ref(false)
|
const isUploading = ref(false)
|
||||||
@@ -254,6 +265,7 @@ export function createManageVersionContext(
|
|||||||
filesToAdd.value = []
|
filesToAdd.value = []
|
||||||
existingFilesToDelete.value = []
|
existingFilesToDelete.value = []
|
||||||
inferredVersionData.value = undefined
|
inferredVersionData.value = undefined
|
||||||
|
resetNewDependency()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNewFiles(newFiles: File[]) {
|
async function handleNewFiles(newFiles: File[]) {
|
||||||
@@ -453,6 +465,44 @@ export function createManageVersionContext(
|
|||||||
return version
|
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
|
// Primary file computed
|
||||||
const primaryFile = computed<PrimaryFile | null>(() => {
|
const primaryFile = computed<PrimaryFile | null>(() => {
|
||||||
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
|
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
|
||||||
@@ -537,6 +587,30 @@ export function createManageVersionContext(
|
|||||||
{ immediate: true, deep: true },
|
{ 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 loaders to infer environment if not set
|
||||||
watch(
|
watch(
|
||||||
() => draftVersion.value.loaders,
|
() => draftVersion.value.loaders,
|
||||||
@@ -603,7 +677,7 @@ export function createManageVersionContext(
|
|||||||
|
|
||||||
if (dep.version_id) {
|
if (dep.version_id) {
|
||||||
const version = await getVersion(dep.version_id)
|
const version = await getVersion(dep.version_id)
|
||||||
dep.versionName = version.name
|
dep.versionNumber = version.version_number
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Failed to fetch project/version data for dependency:`, error)
|
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'
|
return editingVersion.value ? 'Edit loaders' : 'Set loaders'
|
||||||
case 'add-mc-versions':
|
case 'add-mc-versions':
|
||||||
return editingVersion.value ? 'Edit game versions' : 'Set game versions'
|
return editingVersion.value ? 'Edit game versions' : 'Set game versions'
|
||||||
case 'add-dependencies':
|
|
||||||
return editingVersion.value ? 'Edit dependencies' : 'Set dependencies'
|
|
||||||
case 'add-environment':
|
case 'add-environment':
|
||||||
return editingVersion.value ? 'Edit environment' : 'Add environment'
|
return editingVersion.value ? 'Edit environment' : 'Add environment'
|
||||||
case 'add-changelog':
|
case 'add-changelog':
|
||||||
@@ -801,6 +873,10 @@ export function createManageVersionContext(
|
|||||||
handlingNewFiles,
|
handlingNewFiles,
|
||||||
projectsFetchLoading,
|
projectsFetchLoading,
|
||||||
suggestedDependencies,
|
suggestedDependencies,
|
||||||
|
newDependencyProjectId,
|
||||||
|
newDependencyType,
|
||||||
|
newDependencyVersionId,
|
||||||
|
newDependencyVersions,
|
||||||
visibleSuggestedDependencies,
|
visibleSuggestedDependencies,
|
||||||
primaryFile,
|
primaryFile,
|
||||||
|
|
||||||
@@ -826,6 +902,8 @@ export function createManageVersionContext(
|
|||||||
replacePrimaryFile,
|
replacePrimaryFile,
|
||||||
getProject,
|
getProject,
|
||||||
getVersion,
|
getVersion,
|
||||||
|
resetNewDependency,
|
||||||
|
addNewDependency,
|
||||||
handleNewFiles,
|
handleNewFiles,
|
||||||
handleCreateVersion,
|
handleCreateVersion,
|
||||||
handleSaveVersionEdits,
|
handleSaveVersionEdits,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LeftArrowIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||||
import type { StageConfigInput } from '@modrinth/ui'
|
import type { StageConfigInput } from '@modrinth/ui'
|
||||||
import { markRaw } from 'vue'
|
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'
|
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> = {
|
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
|
||||||
id: 'from-details-dependencies',
|
id: 'from-details-dependencies',
|
||||||
stageContent: markRaw(DependenciesStage),
|
stageContent: markRaw(DependenciesStage),
|
||||||
title: 'Edit version',
|
title: 'Add dependency',
|
||||||
nonProgressStage: true,
|
nonProgressStage: true,
|
||||||
leftButtonConfig: (ctx) => ({
|
leftButtonConfig: (ctx) => ({
|
||||||
label: 'Back',
|
label: 'Cancel',
|
||||||
icon: LeftArrowIcon,
|
icon: XIcon,
|
||||||
onClick: () => ctx.modal.value?.setStage('metadata'),
|
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'),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import {
|
|||||||
fromDetailsStageConfig as fromDetailsFilesStageConfig,
|
fromDetailsStageConfig as fromDetailsFilesStageConfig,
|
||||||
stageConfig as addFilesStageConfig,
|
stageConfig as addFilesStageConfig,
|
||||||
} from './add-files-stage'
|
} from './add-files-stage'
|
||||||
import {
|
import { fromDetailsStageConfig as fromDetailsDependenciesStageConfig } from './dependencies-stage'
|
||||||
fromDetailsStageConfig as fromDetailsDependenciesStageConfig,
|
|
||||||
stageConfig as dependenciesStageConfig,
|
|
||||||
} from './dependencies-stage'
|
|
||||||
import { stageConfig as detailsStageConfig } from './details-stage'
|
import { stageConfig as detailsStageConfig } from './details-stage'
|
||||||
import {
|
import {
|
||||||
fromDetailsStageConfig as fromDetailsEnvironmentStageConfig,
|
fromDetailsStageConfig as fromDetailsEnvironmentStageConfig,
|
||||||
@@ -26,11 +23,10 @@ export const stageConfigs = [
|
|||||||
loadersStageConfig,
|
loadersStageConfig,
|
||||||
mcVersionsStageConfig,
|
mcVersionsStageConfig,
|
||||||
environmentStageConfig,
|
environmentStageConfig,
|
||||||
dependenciesStageConfig,
|
|
||||||
metadataStageConfig,
|
metadataStageConfig,
|
||||||
detailsStageConfig,
|
detailsStageConfig,
|
||||||
|
|
||||||
// Non-progress stages for editing from details page
|
// Non-progress stages for editing from metadata/details pages
|
||||||
fromDetailsLoadersStageConfig,
|
fromDetailsLoadersStageConfig,
|
||||||
fromDetailsMcVersionsStageConfig,
|
fromDetailsMcVersionsStageConfig,
|
||||||
fromDetailsEnvironmentStageConfig,
|
fromDetailsEnvironmentStageConfig,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function toggleItem(item: T) {
|
|||||||
background-color: var(--color-brand-highlight);
|
background-color: var(--color-brand-highlight);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 transparent,
|
inset 0 0 0 transparent,
|
||||||
0 0 0 2px var(--color-brand);
|
0 0 0 1px var(--color-brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
class="relative z-[1]"
|
class="relative z-[1]"
|
||||||
@input="handleSearchInput"
|
@input="handleSearchInput"
|
||||||
@keydown="handleSearchKeydown"
|
@keydown="handleSearchKeydown"
|
||||||
@focus="handleSearchFocus"
|
@focusin="handleSearchFocus"
|
||||||
|
@focusout="handleSearchFocusout"
|
||||||
@click="handleSearchClick"
|
@click="handleSearchClick"
|
||||||
>
|
>
|
||||||
<template v-if="showChevron" #right>
|
<template v-if="showChevron" #right>
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
leave-to-class="opacity-0"
|
leave-to-class="opacity-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="shouldRenderDropdown"
|
||||||
ref="dropdownRef"
|
ref="dropdownRef"
|
||||||
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
|
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
|
||||||
:class="[
|
: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="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)"
|
:class="getOptionClasses(item, index)"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
@mousedown.prevent
|
||||||
@click="handleOptionClick(item, index)"
|
@click="handleOptionClick(item, index)"
|
||||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||||
>
|
>
|
||||||
@@ -225,12 +227,15 @@ const props = withDefaults(
|
|||||||
showIconInSelected?: boolean
|
showIconInSelected?: boolean
|
||||||
maxHeight?: number
|
maxHeight?: number
|
||||||
displayValue?: string
|
displayValue?: string
|
||||||
|
searchValue?: string
|
||||||
triggerClass?: string
|
triggerClass?: string
|
||||||
forceDirection?: 'up' | 'down'
|
forceDirection?: 'up' | 'down'
|
||||||
noOptionsMessage?: string
|
noOptionsMessage?: string
|
||||||
disableSearchFilter?: boolean
|
disableSearchFilter?: boolean
|
||||||
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
||||||
syncWithSelection?: boolean
|
syncWithSelection?: boolean
|
||||||
|
/** Select the searchable input text when the field receives focus */
|
||||||
|
selectSearchTextOnFocus?: boolean
|
||||||
/** Show a search icon in the searchable input */
|
/** Show a search icon in the searchable input */
|
||||||
showSearchIcon?: boolean
|
showSearchIcon?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
@@ -245,6 +250,7 @@ const props = withDefaults(
|
|||||||
maxHeight: DEFAULT_MAX_HEIGHT,
|
maxHeight: DEFAULT_MAX_HEIGHT,
|
||||||
noOptionsMessage: 'No results found',
|
noOptionsMessage: 'No results found',
|
||||||
syncWithSelection: true,
|
syncWithSelection: true,
|
||||||
|
selectSearchTextOnFocus: false,
|
||||||
showSearchIcon: false,
|
showSearchIcon: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -256,6 +262,7 @@ const emit = defineEmits<{
|
|||||||
open: []
|
open: []
|
||||||
close: []
|
close: []
|
||||||
searchInput: [query: string]
|
searchInput: [query: string]
|
||||||
|
searchBlur: [query: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const slots = useSlots()
|
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) {
|
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
|
||||||
return [
|
return [
|
||||||
item.class,
|
item.class,
|
||||||
@@ -432,7 +447,7 @@ async function updateDropdownPosition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openDropdown() {
|
async function openDropdown() {
|
||||||
if (props.disabled || isOpen.value) return
|
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
|
||||||
|
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
emit('open')
|
emit('open')
|
||||||
@@ -503,15 +518,20 @@ function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
|
|||||||
|
|
||||||
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
||||||
const length = filteredOptions.value.length
|
const length = filteredOptions.value.length
|
||||||
|
if (length === 0) return -1
|
||||||
|
|
||||||
let index = currentIndex
|
let index = currentIndex
|
||||||
let option
|
|
||||||
|
|
||||||
do {
|
for (let i = 0; i < length; i++) {
|
||||||
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
|
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
|
||||||
option = filteredOptions.value[index]
|
const option = filteredOptions.value[index]
|
||||||
} while (isDivider(option) || option.disabled)
|
|
||||||
|
|
||||||
return index
|
if (!isDivider(option) && !option.disabled) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusOption(index: number) {
|
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) {
|
if (!isOpen.value) {
|
||||||
openDropdown()
|
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() {
|
function handleSearchClick() {
|
||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
openDropdown()
|
openDropdown()
|
||||||
@@ -683,12 +724,24 @@ watch(isOpen, (value) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(shouldRenderDropdown, (value) => {
|
||||||
|
if (value) {
|
||||||
|
updateDropdownPosition()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(filteredOptions, () => {
|
watch(filteredOptions, () => {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
updateDropdownPosition()
|
updateDropdownPosition()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(hasDropdownContent, (value) => {
|
||||||
|
if (!value && isOpen.value) {
|
||||||
|
closeDropdown()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.modelValue, () => props.options],
|
[() => props.modelValue, () => props.options],
|
||||||
([val]) => {
|
([val]) => {
|
||||||
|
|||||||
97
packages/ui/src/components/base/Tabs.vue
Normal file
97
packages/ui/src/components/base/Tabs.vue
Normal 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>
|
||||||
@@ -79,6 +79,8 @@ export { default as StatItem } from './StatItem.vue'
|
|||||||
export { default as StyledInput } from './StyledInput.vue'
|
export { default as StyledInput } from './StyledInput.vue'
|
||||||
export type { TableColumn } from './Table.vue'
|
export type { TableColumn } from './Table.vue'
|
||||||
export { default as Table } 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 TagItem } from './TagItem.vue'
|
||||||
export { default as TagTagItem } from './TagTagItem.vue'
|
export { default as TagTagItem } from './TagTagItem.vue'
|
||||||
export { default as Timeline } from './Timeline.vue'
|
export { default as Timeline } from './Timeline.vue'
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ export const Searchable: Story = {
|
|||||||
],
|
],
|
||||||
searchable: true,
|
searchable: true,
|
||||||
searchPlaceholder: 'Search loaders...',
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
packages/ui/src/stories/base/Tabs.stories.ts
Normal file
50
packages/ui/src/stories/base/Tabs.stories.ts
Normal 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" />
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user