feat: paper channel badges (#5850)
This commit is contained in:
@@ -1,29 +1,44 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative inline-block w-full">
|
||||
<!-- Searchable mode: input trigger -->
|
||||
<StyledInput
|
||||
v-if="searchable"
|
||||
ref="searchTriggerRef"
|
||||
v-model="searchQuery"
|
||||
:icon="showSearchIcon ? SearchIcon : undefined"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder || placeholder"
|
||||
:disabled="disabled"
|
||||
wrapper-class="w-full"
|
||||
:input-class="showChevron ? '!pr-9' : undefined"
|
||||
class="relative"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
@focus="handleSearchFocus"
|
||||
@click="handleSearchClick"
|
||||
>
|
||||
<template v-if="showChevron" #right>
|
||||
<ChevronLeftIcon
|
||||
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
|
||||
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
||||
/>
|
||||
</template>
|
||||
</StyledInput>
|
||||
<div v-if="searchable" class="relative w-full rounded-xl bg-surface-4">
|
||||
<!--
|
||||
Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`,
|
||||
else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay
|
||||
text will not line up with the transparent input text / caret.
|
||||
-->
|
||||
<div
|
||||
v-if="searchSelectionOverlayVisible"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 right-0 z-0 flex min-w-0 items-center gap-2 font-medium text-primary"
|
||||
:class="[showSearchIcon ? 'pl-10' : 'pl-3', showChevron ? 'pr-9' : 'pr-3']"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ searchQuery }}</span>
|
||||
<slot name="search-selection-affix" :option="selectedOption" />
|
||||
</div>
|
||||
<StyledInput
|
||||
ref="searchTriggerRef"
|
||||
v-model="searchQuery"
|
||||
:icon="showSearchIcon ? SearchIcon : undefined"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder || placeholder"
|
||||
:disabled="disabled"
|
||||
wrapper-class="w-full !bg-transparent"
|
||||
:input-class="searchableInputClass"
|
||||
class="relative z-[1]"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
@focus="handleSearchFocus"
|
||||
@click="handleSearchClick"
|
||||
>
|
||||
<template v-if="showChevron" #right>
|
||||
<ChevronLeftIcon
|
||||
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
|
||||
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
||||
/>
|
||||
</template>
|
||||
</StyledInput>
|
||||
</div>
|
||||
|
||||
<!-- Standard mode: button trigger -->
|
||||
<span
|
||||
@@ -108,9 +123,14 @@
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="!item.disabled && (focusedIndex = index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
<slot :name="`option-${item.value}`" :item="item">
|
||||
<slot
|
||||
name="option"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-selected="!!(listbox && item.value === modelValue)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
|
||||
@@ -151,7 +171,16 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import StyledInput from './StyledInput.vue'
|
||||
|
||||
@@ -223,11 +252,14 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: T]
|
||||
select: [option: ComboboxOption<T>]
|
||||
'option-hover': [option: ComboboxOption<T>]
|
||||
open: []
|
||||
close: []
|
||||
searchInput: [query: string]
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const userHasTyped = ref(false)
|
||||
@@ -261,6 +293,23 @@ const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
/** Extra content (e.g. channel pill) next to the label while the search field is idle */
|
||||
const searchSelectionOverlayVisible = computed(() => {
|
||||
if (!props.searchable || !props.syncWithSelection || !selectedOption.value) return false
|
||||
if (!slots['search-selection-affix']) return false
|
||||
if (isOpen.value || userHasTyped.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const searchableInputClass = computed(() => {
|
||||
const parts = ['!bg-transparent']
|
||||
if (props.showChevron) parts.push('!pr-9')
|
||||
if (searchSelectionOverlayVisible.value) {
|
||||
parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent')
|
||||
}
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
const triggerText = computed(() => {
|
||||
if (props.displayValue !== undefined) return props.displayValue
|
||||
if (selectedOption.value) return selectedOption.value.label
|
||||
@@ -446,6 +495,12 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
|
||||
if (option.disabled) return
|
||||
focusedIndex.value = index
|
||||
emit('option-hover', option)
|
||||
}
|
||||
|
||||
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
||||
const length = filteredOptions.value.length
|
||||
let index = currentIndex
|
||||
|
||||
29
packages/ui/src/components/base/PaperChannelBadge.vue
Normal file
29
packages/ui/src/components/base/PaperChannelBadge.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="channel === 'ALPHA'"
|
||||
class="rounded-full bg-bg-red px-2 text-sm font-bold text-red"
|
||||
:class="{ 'shrink-0': affix }"
|
||||
>
|
||||
{{ formatMessage(commonMessages.alpha) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="channel === 'BETA'"
|
||||
class="rounded-full bg-bg-orange px-2 text-sm font-bold text-orange"
|
||||
:class="{ 'shrink-0': affix }"
|
||||
>
|
||||
{{ formatMessage(commonMessages.beta) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
defineProps<{
|
||||
channel: 'ALPHA' | 'BETA' | null | undefined
|
||||
/** When true, prevents the badge from shrinking in flex rows (e.g. search field affix). */
|
||||
affix?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
@@ -51,6 +51,7 @@
|
||||
sync-with-selection
|
||||
placeholder="Select game version"
|
||||
search-placeholder="Search game version..."
|
||||
@option-hover="handleGameVersionHover"
|
||||
>
|
||||
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
|
||||
<button
|
||||
@@ -90,7 +91,28 @@
|
||||
:search-placeholder="
|
||||
isPaperLike ? 'Search build number...' : 'Search loader version...'
|
||||
"
|
||||
/>
|
||||
>
|
||||
<!-- When not Paper, this scoped slot is omitted and Combobox uses default option markup. -->
|
||||
<template v-if="selectedLoader === 'paper'" #option="{ item, isSelected }">
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="font-semibold leading-tight"
|
||||
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<PaperChannelBadge :channel="paperBuildChannelTag(String(item.value))" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="selectedLoader === 'paper'" #search-selection-affix="{ option }">
|
||||
<PaperChannelBadge
|
||||
affix
|
||||
:channel="option ? paperBuildChannelTag(String(option.value)) : null"
|
||||
/>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
@@ -99,6 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Paper } from '@modrinth/api-client'
|
||||
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
@@ -110,6 +133,7 @@ import ButtonStyled from '../../../base/ButtonStyled.vue'
|
||||
import Chips from '../../../base/Chips.vue'
|
||||
import Collapsible from '../../../base/Collapsible.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
|
||||
import PaperChannelBadge from '../../../base/PaperChannelBadge.vue'
|
||||
import StyledInput from '../../../base/StyledInput.vue'
|
||||
import type { LoaderVersionType } from '../creation-flow-context'
|
||||
import { injectCreationFlowContext } from '../creation-flow-context'
|
||||
@@ -192,7 +216,7 @@ const loaderVersionsData = ref<LoaderVersionEntry[]>([])
|
||||
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
|
||||
|
||||
// Paper/Purpur build caches
|
||||
const paperVersions = ref<Record<string, number[]>>({})
|
||||
const paperVersions = ref<Record<string, Paper.Versions.v3.Build[]>>({})
|
||||
const purpurVersions = ref<Record<string, string[]>>({})
|
||||
|
||||
// Paper/Purpur supported game version sets (for filtering the game version combobox)
|
||||
@@ -298,16 +322,33 @@ async function fetchPurpurSupportedVersions() {
|
||||
}
|
||||
}
|
||||
|
||||
function paperBuildChannelTag(buildId: string): 'ALPHA' | 'BETA' | null {
|
||||
const gv = selectedGameVersion.value
|
||||
if (!gv || selectedLoader.value !== 'paper') return null
|
||||
const b = paperVersions.value[gv]?.find((x) => String(x.id) === buildId)
|
||||
if (!b) return null
|
||||
const u = String(b.channel).toUpperCase()
|
||||
if (u === 'ALPHA' || u === 'BETA') return u
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchPaperVersions(mcVersion: string) {
|
||||
if (paperVersions.value[mcVersion]) return
|
||||
try {
|
||||
const data = await client.paper.versions_v3.getBuilds(mcVersion)
|
||||
paperVersions.value[mcVersion] = data.builds.sort((a, b) => b - a)
|
||||
paperVersions.value[mcVersion] = data.builds.toSorted((a, b) => b.id - a.id)
|
||||
} catch {
|
||||
paperVersions.value[mcVersion] = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleGameVersionHover(option: ComboboxOption<string | null>) {
|
||||
const v = option.value
|
||||
if (v == null || v === '') return
|
||||
if (selectedLoader.value === 'paper') void fetchPaperVersions(v)
|
||||
else if (selectedLoader.value === 'purpur') void fetchPurpurVersions(v)
|
||||
}
|
||||
|
||||
async function fetchPurpurVersions(mcVersion: string) {
|
||||
if (purpurVersions.value[mcVersion]) return
|
||||
try {
|
||||
@@ -393,7 +434,7 @@ watch(
|
||||
loaderVersionsLoading.value = false
|
||||
const builds = paperVersions.value[gameVersion]
|
||||
if (builds?.length) {
|
||||
selectedLoaderVersion.value = `${builds[0]}`
|
||||
selectedLoaderVersion.value = `${builds[0].id}`
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -459,7 +500,10 @@ function autoSelectLoaderVersion() {
|
||||
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
if (selectedLoader.value === 'paper' && selectedGameVersion.value) {
|
||||
const builds = paperVersions.value[selectedGameVersion.value] ?? []
|
||||
return builds.map((b) => ({ value: `${b}`, label: `Build ${b}` }))
|
||||
return builds.map((b) => ({
|
||||
value: `${b.id}`,
|
||||
label: `Build ${b.id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {
|
||||
|
||||
Reference in New Issue
Block a user