feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -10,14 +10,18 @@
<slot name="title" :open="isOpen" />
<DropdownIcon
v-if="!forceOpen"
class="ml-auto size-5 transition-transform duration-300 shrink-0"
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
<slot name="summary" />
</button>
<div class="accordion-content" :class="{ open: isOpen }">
<div
class="accordion-content"
:class="{ open: isOpen, 'overflow-visible': overflowVisible && showOverflow }"
@transitionend="onTransitionEnd"
>
<div>
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
<slot />
@@ -39,6 +43,7 @@ const props = withDefaults(
contentClass?: string
titleWrapperClass?: string
forceOpen?: boolean
overflowVisible?: boolean
}>(),
{
type: 'standard',
@@ -47,11 +52,13 @@ const props = withDefaults(
contentClass: null,
titleWrapperClass: null,
forceOpen: false,
overflowVisible: false,
},
)
const toggledOpen = ref(props.openByDefault)
const isOpen = computed(() => toggledOpen.value || props.forceOpen)
const showOverflow = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
@@ -71,9 +78,15 @@ function open() {
emit('onOpen')
}
function close() {
showOverflow.value = false
toggledOpen.value = false
emit('onClose')
}
function onTransitionEnd() {
if (isOpen.value) {
showOverflow.value = true
}
}
defineExpose({
open,
@@ -105,4 +118,8 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
.accordion-content.overflow-visible > div {
overflow: visible;
}
</style>

View File

@@ -1,11 +1,28 @@
<template>
<div
:class="[
'flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
'relative flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
typeClasses[type],
]"
>
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div
:class="[
'flex gap-2 items-start',
(header || $slots.header) && 'flex-col',
dismissible && 'pr-8',
]"
>
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component
@@ -28,7 +45,10 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { getSeverityIcon } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
withDefaults(
defineProps<{
@@ -36,15 +56,21 @@ withDefaults(
header?: string
body?: string
showActionsUnderneath?: boolean
dismissible?: boolean
}>(),
{
type: 'info',
header: '',
body: '',
showActionsUnderneath: false,
dismissible: false,
},
)
defineEmits<{
dismiss: []
}>()
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
@@ -56,4 +82,10 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red'> = {
info: 'blue',
warning: 'orange',
critical: 'red',
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<button
class="group flex w-full hover:cursor-pointer items-center gap-3 rounded-[20px] p-3 text-left transition-all hover:brightness-110 active:scale-[0.98] border-none"
:class="selected ? 'bg-brand-highlight' : 'bg-surface-4'"
@click="$emit('click')"
>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid"
:class="selected ? 'border-brand' : 'border-surface-5'"
>
<component
:is="icon"
class="size-8 text-secondary"
:class="selected ? '!stroke-brand' : ''"
stroke-width="1.5"
/>
</div>
<div class="flex flex-1 flex-col gap-1">
<span class="text-base font-semibold text-contrast">{{ title }}</span>
<span class="text-sm font-medium text-primary">{{ description }}</span>
</div>
<ChevronRightIcon
class="size-5 shrink-0 text-secondary opacity-0 transition-opacity duration-100 group-hover:opacity-100"
/>
</button>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import type { Component } from 'vue'
defineProps<{
icon: Component
title: string
description: string
selected?: boolean
}>()
defineEmits<{
(e: 'click'): void
}>()
</script>

View File

@@ -1,8 +1,12 @@
<template>
<div class="chips">
<div class="chips" role="radiogroup" :aria-label="ariaLabel">
<Button
v-for="item in items"
:key="formatLabel(item)"
v-tooltip="isDisabled(item) ? disabledTooltip : undefined"
role="radio"
:aria-checked="selected === item"
:disabled="isDisabled(item)"
class="btn !brightness-100 hover:!brightness-125"
:class="{
selected: selected === item,
@@ -29,6 +33,9 @@ const props = withDefaults(
neverEmpty?: boolean
capitalize?: boolean
size?: 'standard' | 'small'
ariaLabel?: string
disabledItems?: T[]
disabledTooltip?: string
}>(),
{
neverEmpty: true,
@@ -46,7 +53,12 @@ if (props.items.length > 0 && props.neverEmpty && !selected.value) {
selected.value = props.items[0]
}
function isDisabled(item: T): boolean {
return props.disabledItems?.includes(item) ?? false
}
function toggleItem(item: T) {
if (isDisabled(item)) return
if (selected.value === item && !props.neverEmpty) {
selected.value = null
} else {

View File

@@ -1,5 +1,17 @@
<template>
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
<div
class="accordion-content"
:class="[
baseClass ?? '',
{
open: isOpen,
'no-transition': !shouldAnimate,
'overflow-visible': overflowVisible && isFullyOpen,
},
]"
:style="isHidden ? { display: 'none' } : {}"
@transitionend="onTransitionEnd"
>
<div v-bind="$attrs" :inert="collapsed">
<slot />
</div>
@@ -7,15 +19,78 @@
</template>
<script setup lang="ts">
defineProps<{
import { nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
baseClass?: string
collapsed: boolean
overflowVisible?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
const shouldAnimate = ref(false)
const isHidden = ref(props.collapsed)
const isOpen = ref(!props.collapsed)
const isFullyOpen = ref(!props.collapsed)
onMounted(() => {
requestAnimationFrame(() => {
shouldAnimate.value = true
})
})
watch(
() => props.collapsed,
async (collapsed) => {
if (!collapsed) {
// Opening
isHidden.value = false
isFullyOpen.value = false
if (!shouldAnimate.value) {
isOpen.value = true
isFullyOpen.value = true
return
}
// Wait for display: none removal to take effect, then animate open
await nextTick()
requestAnimationFrame(() => {
isOpen.value = true
})
} else {
// Closing
// Remove overflow-visible so content is clipped during animation
isFullyOpen.value = false
if (!shouldAnimate.value) {
isOpen.value = false
isHidden.value = true
return
}
// Wait a frame for overflow: hidden to apply, THEN start closing
await nextTick()
requestAnimationFrame(() => {
isOpen.value = false
})
}
},
)
function onTransitionEnd(e: TransitionEvent) {
if (e.target !== e.currentTarget) return
if (props.collapsed) {
isHidden.value = true
} else {
isFullyOpen.value = true
}
}
</script>
<style scoped>
.accordion-content {
display: grid;
@@ -23,6 +98,10 @@ defineOptions({
transition: grid-template-rows 0.3s ease-in-out;
}
.accordion-content.no-transition {
transition: none !important;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
@@ -36,4 +115,8 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
.accordion-content.overflow-visible > div {
overflow: visible;
}
</style>

View File

@@ -1,24 +1,49 @@
<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>
<!-- Standard mode: button trigger -->
<span
v-else
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2.5 text-left transition-all duration-200 text-button-text"
:class="[
triggerClasses,
props.triggerClass,
{
'z-[9999]': isOpen,
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:bg-button-bgHover active:bg-button-bgActive': !disabled,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
},
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick"
@click="handleTriggerClick($event)"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
@@ -36,95 +61,89 @@
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 transition-transform duration-300"
class="size-5 shrink-0 transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
<Teleport to="#teleports">
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 !border-solid border-0 shadow-2xl"
:class="[
shouldRoundBottomCorners
? 'rounded-t-none !border-t-[1px] !border-t-surface-5'
: 'rounded-b-none !border-b-[1px] !border-b-surface-5',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div v-if="searchable" class="p-4">
<StyledInput
ref="searchInputRef"
v-model="searchQuery"
:icon="SearchIcon"
type="text"
:placeholder="searchPlaceholder"
wrapper-class="w-full"
input-class="!border !border-solid !border-surface-5"
@keydown.stop="handleSearchKeydown"
@input="emit('searchInput', searchQuery)"
/>
</div>
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="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"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
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"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<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" />
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
</div>
</div>
<slot name="option-suffix" :item="item"></slot>
</div>
</div>
</slot>
</component>
</template>
</div>
</slot>
</component>
</template>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
{{ noOptionsMessage }}
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
<slot name="dropdown-footer"></slot>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -132,16 +151,7 @@
<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,
useSlots,
watch,
} from 'vue'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import StyledInput from './StyledInput.vue'
@@ -160,6 +170,7 @@ export interface ComboboxOption<T> {
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DROPDOWN_GAP = 12
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
@@ -185,11 +196,14 @@ const props = withDefaults(
showIconInSelected?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
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
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -200,8 +214,9 @@ const props = withDefaults(
showChevron: true,
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
noOptionsMessage: 'No results found',
syncWithSelection: true,
showSearchIcon: false,
},
)
@@ -213,19 +228,25 @@ const emit = defineEmits<{
searchInput: [query: string]
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const userHasTyped = ref(false)
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const rafId = ref<number | null>(null)
const effectiveTriggerEl = computed(() => {
if (props.searchable && searchTriggerRef.value) {
return (searchTriggerRef.value as unknown as { $el: HTMLElement }).$el as HTMLElement
}
return triggerRef.value
})
const dropdownStyle = ref({
top: '0px',
left: '0px',
@@ -234,18 +255,6 @@ const dropdownStyle = ref({
const openDirection = ref<'down' | 'up'>('down')
const triggerClasses = computed(() => {
const classes = [props.triggerClass]
if (isOpen.value) {
if (props.extraPosition === 'bottom' && slots?.extra) {
classes.push('!rounded-b-none')
} else if (props.extraPosition === 'top' && slots?.extra) {
classes.push('!rounded-t-none')
}
}
return classes
})
const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is ComboboxOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
@@ -266,7 +275,7 @@ const optionsWithKeys = computed(() => {
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter) {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter || !userHasTyped.value) {
return optionsWithKeys.value
}
@@ -279,9 +288,6 @@ const filteredOptions = computed(() => {
})
})
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
return [
item.class,
@@ -308,12 +314,6 @@ function setInitialFocus() {
}
}
function focusSearchInput() {
if (props.searchable && searchInputRef.value) {
searchInputRef.value.focus()
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
@@ -324,8 +324,10 @@ function determineOpenDirection(
}
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove = triggerRect.top - dropdownRect.height - DROPDOWN_VIEWPORT_MARGIN > 0
triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <=
viewportHeight
const hasSpaceAbove =
triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
@@ -335,7 +337,9 @@ function calculateVerticalPosition(
dropdownRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up' ? triggerRect.top - dropdownRect.height : triggerRect.bottom
return direction === 'up'
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
: triggerRect.bottom + DROPDOWN_GAP
}
function calculateHorizontalPosition(
@@ -356,11 +360,11 @@ function calculateHorizontalPosition(
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
if (!effectiveTriggerEl.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
@@ -382,15 +386,12 @@ async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
searchQuery.value = ''
emit('open')
await nextTick()
await updateDropdownPosition()
setInitialFocus()
focusSearchInput()
startPositionTracking()
}
@@ -399,16 +400,22 @@ function closeDropdown() {
stopPositionTracking()
isOpen.value = false
searchQuery.value = ''
userHasTyped.value = false
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
if (!props.searchable) {
nextTick(() => {
triggerRef.value?.focus()
})
}
}
function handleTriggerClick() {
function handleTriggerClick(event: MouseEvent) {
// Ignore synthetic clicks generated by keyboard (Enter/Space on role="button")
// since handleTriggerKeydown already handles keyboard interaction
if (event.detail === 0) return
if (isOpen.value) {
closeDropdown()
} else {
@@ -432,6 +439,9 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
emit('select', option)
if (option.type !== 'link') {
if (props.searchable) {
searchQuery.value = props.syncWithSelection ? option.label : ''
}
closeDropdown()
}
}
@@ -456,7 +466,6 @@ function focusOption(index: number) {
if (isDivider(option) || option.disabled) return
focusedIndex.value = index
optionRefs.value[index]?.focus()
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
}
@@ -471,13 +480,14 @@ function focusPreviousOption() {
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (isOpen.value) {
handleDropdownKeydown(event)
return
}
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
openDropdown()
break
case 'ArrowUp':
event.preventDefault()
openDropdown()
@@ -526,10 +536,51 @@ function handleSearchKeydown(event: KeyboardEvent) {
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
if (!isOpen.value) {
openDropdown()
}
focusNextOption()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen.value) {
openDropdown()
}
focusPreviousOption()
} else if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (option && !isDivider(option)) {
handleOptionClick(option, focusedIndex.value)
}
}
} else if (event.key === 'Tab' && isOpen.value) {
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
}
}
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!isOpen.value) {
openDropdown()
}
}
function handleSearchFocus() {
if (!isOpen.value) {
openDropdown()
}
}
function handleSearchClick() {
if (!isOpen.value) {
openDropdown()
}
}
@@ -559,7 +610,7 @@ onClickOutside(
() => {
closeDropdown()
},
{ ignore: [triggerRef] },
{ ignore: [triggerRef, containerRef] },
)
onMounted(() => {
@@ -582,4 +633,15 @@ watch(filteredOptions, () => {
updateDropdownPosition()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="mx-auto flex flex-col items-center p-6 text-center">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
<div class="flex flex-col items-center gap-1.5">
<span class="text-2xl font-semibold text-contrast">
<slot name="heading">{{ heading }}</slot>
</span>
<span v-if="$slots.description || description" class="text-secondary">
<slot name="description">{{ description }}</slot>
</span>
</div>
<div v-if="$slots.actions" class="mt-8 flex gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import {
DoneIllustration,
EmptyIllustration,
EmptyInboxIllustration,
ErrorIllustration,
NoConnectionIllustration,
NoCreditCardIllustration,
NoDocumentsIllustration,
NoGPSIllustration,
NoImagesIllustration,
NoItemsCartIllustration,
NoMessagesIllustration,
NoSearchResultIllustration,
NoTasksIllustration,
} from '@modrinth/assets'
import type { Component } from 'vue'
import { computed } from 'vue'
const illustrationMap: Record<string, Component> = {
done: DoneIllustration,
empty: EmptyIllustration,
'empty-inbox': EmptyInboxIllustration,
error: ErrorIllustration,
'no-connection': NoConnectionIllustration,
'no-credit-card': NoCreditCardIllustration,
'no-documents': NoDocumentsIllustration,
'no-gps': NoGPSIllustration,
'no-images': NoImagesIllustration,
'no-items-cart': NoItemsCartIllustration,
'no-messages': NoMessagesIllustration,
'no-search-result': NoSearchResultIllustration,
'no-tasks': NoTasksIllustration,
}
const props = defineProps<{
type?: keyof typeof illustrationMap
heading?: string
description?: string
}>()
const illustration = computed(() => (props.type ? illustrationMap[props.type] : undefined))
</script>

View File

@@ -3,6 +3,7 @@ import { onUnmounted, watch } from 'vue'
const props = defineProps<{
shown: boolean
ariaLabel?: string
}>()
watch(
@@ -20,9 +21,15 @@ onUnmounted(() => {
<template>
<Transition name="floating-action-bar" appear>
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-10 bottom-0">
<div
v-if="shown"
class="floating-action-bar drop-shadow-2xl fixed z-10 p-4 bottom-0"
aria-live="polite"
>
<div
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[75rem] p-4"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
>
<slot />
</div>
@@ -32,6 +39,8 @@ onUnmounted(() => {
<style scoped>
.floating-action-bar {
left: var(--left-bar-width, 0px);
right: var(--right-bar-width, 0px);
transition: bottom 0.25s ease-in-out;
}
@@ -67,3 +76,9 @@ onUnmounted(() => {
}
}
</style>
<style>
.intercom-lightweight-app-launcher {
z-index: 9 !important;
}
</style>

View File

@@ -5,7 +5,7 @@
max-content-height="72vh"
:on-hide="onModalHide"
:closable="true"
:close-on-click-outside="false"
:close-on-click-outside="closeOnClickOutside"
:width="resolvedMaxWidth"
:fade="fade"
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
@@ -59,7 +59,7 @@
</template>
<progress
v-if="nonProgressStage !== true"
v-if="nonProgressStage !== true && !disableProgress"
:value="progressValue"
max="100"
class="w-full h-1 appearance-none border-none absolute top-0 left-0"
@@ -74,7 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
class="!border-surface-5"
class="!border-surface-5 !shadow-none"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -85,19 +85,28 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
:disabled="rightButtonConfig.disabled"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@click="rightButtonConfig.onClick"
>
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'before'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
v-else-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'after'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'after'"
v-else-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
@@ -108,7 +117,7 @@
</template>
<script lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { ChevronRightIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
@@ -119,6 +128,7 @@ export interface StageButtonConfig {
iconPosition?: 'before' | 'after'
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -148,13 +158,20 @@ export function resolveCtxFn<T, R>(value: MaybeCtxFn<T, R>, ctx: T): R {
</script>
<script setup lang="ts" generic="T">
const props = defineProps<{
stages: StageConfigInput<T>[]
context: T
breadcrumbs?: boolean
fitContent?: boolean
fade?: 'standard' | 'warning' | 'danger'
}>()
const props = withDefaults(
defineProps<{
stages: StageConfigInput<T>[]
context: T
breadcrumbs?: boolean
fitContent?: boolean
fade?: 'standard' | 'warning' | 'danger'
disableProgress?: boolean
closeOnClickOutside?: boolean
}>(),
{
closeOnClickOutside: true,
},
)
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const currentStageIndex = ref<number>(0)

View File

@@ -66,11 +66,19 @@ const percent = computed(() => props.progress / props.max)
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
<span>{{ Math.round(percent * 100) }}%</span>
<slot name="progress-icon">
<SpinnerIcon class="size-5 animate-spin" />
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
</slot>
</div>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
<div
role="progressbar"
:aria-valuenow="waiting ? undefined : Math.round(percent * 100)"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="label || undefined"
class="flex h-2 w-full overflow-hidden rounded-full"
:class="[colors[props.color].bg]"
>
<div
class="rounded-full progress-bar"
:class="[

View File

@@ -5,6 +5,7 @@ export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
export { default as AutoLink } from './AutoLink.vue'
export { default as Avatar } from './Avatar.vue'
export { default as Badge } from './Badge.vue'
export { default as BigOptionButton } from './BigOptionButton.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonStyled } from './ButtonStyled.vue'
@@ -21,6 +22,7 @@ export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'