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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
packages/ui/src/components/base/BigOptionButton.vue
Normal file
42
packages/ui/src/components/base/BigOptionButton.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
packages/ui/src/components/base/EmptyState.vue
Normal file
60
packages/ui/src/components/base/EmptyState.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="[
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user